Files
flowgraph/docs/architecture/operation-graph.md
glm-5.1 f3e084d02f resolve all remaining open questions (OQ-03–OQ-29), add ADR-006
Resolve all 19 remaining open questions across the architecture. Every
question now has a documented resolution with rationale:

- OQ-004/OQ-029: edgeType is a universal required attribute on all edges,
  single graph per FlowGraph instance (ADR-006)
- OQ-011: No OR preconditions for v1; preconditionMode as v2 extension
- OQ-012: maxConcurrency enforced via reactive counting semaphore
- OQ-014: Unknown operationId creates node with pending status
- OQ-017: Expose common graphology traversal methods on FlowGraph (80/20)
- OQ-020: condition as Type.Unknown() with string/function documentation
- OQ-022: Identity imported from @alkdev/operations peer dep
- All other questions resolved with documented rationale

Fix three critical issues found by architecture review:
1. edgeType serialization/validation gap: document two-step validation
2. CallEdgeAttrs runtime discrimination: edgeType as runtime discriminant,
   depends_on edges clarified as observability-only (not execution)
3. ADR-005 signal mutation inconsistency: explicitly distinguish call-level
   statuses (event-log-driven) from workflow-derived statuses (signal-mutation)

Additional clarifications:
- dataFlow inference uses conservative strategy (defaults false)
- Conditional.test string resolution: operationName → status === completed
- Add negated field to TemplateEdgeAttrs for else-branch conditions
- Document edge key priority convention for composite keys
- Add maxConcurrency semaphore design to reactive-execution.md
2026-05-21 09:25:55 +00:00

11 KiB

status, last_updated
status last_updated
draft 2026-05-22

Operation Graph (Static)

The static operation graph built from OperationSpecs at startup. Nodes represent operations, edges represent type compatibility between output and input schemas.

Overview

The operation graph is built once at startup from the OperationRegistry. It answers structural questions about the operation space:

  • Type compatibility: Can operation A's output feed into operation B's input?
  • Cycle detection: Are there circular operation dependencies?
  • Reachability: What operations are reachable from a given starting point?
  • Template validation: Is a proposed call sequence structurally valid?

The operation graph is immutable after construction. Operations don't appear or disappear at runtime — they're registered once and the graph is built from the registry. If the registry changes, the graph is rebuilt from scratch.

Construction

fromSpecs()

static fromSpecs(specs: OperationSpec[]): FlowGraph

The primary construction path. Takes an array of OperationSpec objects (from OperationRegistry.getAll()) and builds a directed graph where:

  1. Nodes — one per operation, key = namespace.name, attributes = OperationNodeAttrs
  2. Typed edges — added between operations where the output schema of the source is compatible with the input schema of the target
const graph = FlowGraph.fromSpecs(registry.getAll());

Edge construction calls the typeCompat analysis function for each (source, target) pair. An edge is added if typeCompat(source.outputSchema, target.inputSchema).compatible === true.

The number of edges is O(n²) in the worst case (all operations are type-compatible with all others). For realistic registries (10-50 operations), this is sub-millisecond. If the registry grows large, edge construction can be deferred to query time.

fromJSON()

static fromJSON(data: OperationGraphSerialized): FlowGraph

Deserialize from graphology native JSON format. Validates against OperationGraphSerialized schema using Value.Check(). Throws InvalidInputError on validation failure.

Round-trip: fromSpecs()export()fromJSON() is lossless.

Incremental construction

const graph = new FlowGraph();
graph.addOperation(spec);
graph.addTypedEdge("task.classify", "task.enrich", { compatible: true, detail: "output → input", mismatches: [] });

addOperation adds a node. addTypedEdge adds a type-compatibility edge. Both throw on duplicates (matching taskgraph's behavior).

Node Attributes

See schema.md for the full schema definition. Key fields:

Field Purpose
name Operation name (e.g., "classify")
namespace Namespace (e.g., "task")
type "query" | "mutation" | "subscription"
inputSchema TypeBox schema for input — used by type-compatibility analysis
outputSchema TypeBox schema for output — used by type-compatibility analysis

The node key is ${namespace}.${name}, matching the operationId format.

Edges

Edges represent type compatibility. The edge direction is:

source → target  (source's output is compatible with target's input)

Following graphology convention: graph.inNeighbors("task.enrich") returns operations that can feed into enrich. graph.outNeighbors("task.classify") returns operations that classify can feed into.

This direction matches the data flow: classify produces output that enrich can consume. It also matches taskgraph's prerequisite → dependent convention.

Edge attributes

{
  edgeType: "typed",
  compatible: true,
  detail: "classify.output → enrich.input"
}
  • edgeType — always "typed" for operation graph edges
  • compatible — whether the source's output schema is compatible with the target's input schema
  • detail — optional human-readable description of the compatibility relationship

Why compatible: false edges?

The operation graph includes incompatible edges (compatible: false) alongside compatible ones. This is intentional:

  • Diagnostic value — showing all potential connections, both valid and invalid, helps developers understand the operation space.
  • Template authoring — when building a workflow template, seeing that A → B is incompatible (and why) is more useful than seeing no edge at all.
  • Type mismatch prevention — incompatible edges make it clear where type conversions would be needed.

The typeCompat analysis function determines compatibility. Edges where compatibility cannot be determined (e.g., inputSchema is Unknown) are not added at all — there's no "unknown compatibility" edge.

Validation

The operation graph validates:

  1. Cycle detection — throws CycleError if any cycle exists. Unlike taskgraph (which allows cycles and detects them via hasCycles()), flowgraph enforces acyclicity at construction time. A cycle in the operation graph means an operation's output feeds back into its own input, which is a design error.

  2. Dangling references — edges that reference operations not in the graph are structural errors. addTypedEdge throws OperationNotFoundError if either endpoint doesn't exist.

  3. Schema compatibility — warns (via validateGraph()) about nodes that have no incoming or outgoing edges (isolated operations that aren't connected to the type flow graph).

Queries

The operation graph supports the same query functions as taskgraph, delegated to graphology-dag:

Query Method Returns
Topological order topologicalOrder() string[] of node keys in prerequisite→dependent order
Has cycles hasCycles() boolean (should always be false if construction validated)
Find cycles findCycles() string[][] of cycle paths
Ancestors ancestors(nodeId) string[] of all nodes reachable via incoming edges
Descendants descendants(nodeId) string[] of all nodes reachable via outgoing edges
Predecessors predecessors(nodeId) string[] of direct incoming neighbors
Successors successors(nodeId) string[] of direct outgoing neighbors
Reachable from reachableFrom(nodeIds) Set<string> of all nodes reachable from the given start nodes

These are thin wrappers around graphology and graphology-dag functions, following the same pattern as taskgraph.

Type Compatibility Analysis

The typeCompat function compares two TypeBox schemas and returns a compatibility result:

function typeCompat(
  outputSchema: TSchema,
  inputSchema: TSchema,
): { compatible: boolean; detail?: string }

Compatibility rules

The analysis is structural, not semantic:

  1. Exact matchoutputSchema is identical to inputSchema → compatible
  2. Subtype matchoutputSchema is a subtype of inputSchema → compatible (e.g., output has extra fields beyond what input requires)
  3. Unknown passthrough — if either schema is Type.Unknown(), compatibility is unknown → no edge added (not incompatible, just unresolvable)
  4. Incompatible — structural mismatch (e.g., output is string, input requires number) → edge added with compatible: false

See analysis.md for the full type-compatibility algorithm.

Constraints

  • Immutable after construction — the operation graph is not mutated after fromSpecs() builds it. If the registry changes, rebuild the graph.
  • DAG-only — cycles are rejected at construction time. The operation graph must be a valid DAG.
  • No parallel edges — at most one edge per (source, target) pair. If A's output is compatible with B's input at multiple JSON paths, that's recorded in detail, not as multiple edges.
  • No self-loops — an operation cannot depend on its own output. Self-referential operations (e.g., recursive subscriptions) are modeled differently (see call-graph.md).
  • Edge direction is data flowA → B means A produces data that B consumes. inNeighbors(B) returns A's dependencies, outNeighbors(A) returns A's dependents. This matches taskgraph's convention.
  • Operation nodes use namespace.name as keys — this matches the call protocol's operationId format and ensures uniqueness within a registry.

Open Questions

  1. Should fromSpecs() add ALL possible edges or only compatible ones? Resolved (OQ-001/ADR-005): Type-compatibility edges are only added on edges where dataFlow: true. Temporal-only edges bypass type checking. Both compatible and incompatible edges are added where data flows for diagnostic value.

  2. How to handle version conflicts? Resolved (OQ-026): The current design uses namespace.name (no version) as the node key, meaning only one version per operation can exist in the graph. This is intentional simplicity. Version conflicts are a niche concern that can be addressed when a concrete use case arises. If versioning becomes needed, the node key format could be extended to namespace.name@version, but this is a significant change that requires careful consideration. For v1, the one-version-per-operation constraint is sufficient and keeps the key format simple and consistent.

  3. Should subscription operations be treated differently in type compatibility? Resolved (OQ-003): For v1, subscriptions are treated identically to queries/mutations in typeCompat(). A subscription's outputSchema describes a single stream element, and typeCompat() checks whether that single element is compatible with the downstream input. This is correct for the Map component (which processes stream elements individually) and may be misleading for direct subscription→operation connections. The OperationNodeAttrs.type field is available for consumers that need subscription-aware behavior. A v2 extension could add a streaming: boolean flag on edges to capture stream semantics explicitly, but this adds complexity without a current use case.

  4. How granular should type compatibility be? Resolved (OQ-002/ADR-005): Type compatibility checking only applies to state-transfer edges (where dataFlow: true). The typeCompat() function returns { compatible, detail?, mismatches? } for state-transfer edges only. Temporal-only edges bypass type checking entirely.

References

  • Schema: schema.mdOperationNodeAttrs, OperationEdgeAttrs, CallStatus, EdgeType
  • Type compatibility: analysis.md
  • Call graph: call-graph.md
  • Operation types: @alkdev/operations/src/types.ts
  • Taskgraph construction: @alkdev/taskgraph_ts/src/graph/construction.ts
  • Graphology DAG: graphology-dag package