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
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:
- Nodes — one per operation, key =
namespace.name, attributes =OperationNodeAttrs - 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 edgescompatible— whether the source's output schema is compatible with the target's input schemadetail— 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:
-
Cycle detection — throws
CycleErrorif any cycle exists. Unlike taskgraph (which allows cycles and detects them viahasCycles()), 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. -
Dangling references — edges that reference operations not in the graph are structural errors.
addTypedEdgethrowsOperationNotFoundErrorif either endpoint doesn't exist. -
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:
- Exact match —
outputSchemais identical toinputSchema→ compatible - Subtype match —
outputSchemais a subtype ofinputSchema→ compatible (e.g., output has extra fields beyond what input requires) - Unknown passthrough — if either schema is
Type.Unknown(), compatibility is unknown → no edge added (not incompatible, just unresolvable) - Incompatible — structural mismatch (e.g., output is
string, input requiresnumber) → edge added withcompatible: 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 flow —
A → Bmeans 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.nameas keys — this matches the call protocol'soperationIdformat and ensures uniqueness within a registry.
Open Questions
-
ShouldResolved (OQ-001/ADR-005): Type-compatibility edges are only added on edges wherefromSpecs()add ALL possible edges or only compatible ones?dataFlow: true. Temporal-only edges bypass type checking. Both compatible and incompatible edges are added where data flows for diagnostic value. -
How to handle version conflicts?Resolved (OQ-026): The current design usesnamespace.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 tonamespace.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. -
Should subscription operations be treated differently in type compatibility?Resolved (OQ-003): For v1, subscriptions are treated identically to queries/mutations intypeCompat(). A subscription'soutputSchemadescribes a single stream element, andtypeCompat()checks whether that single element is compatible with the downstream input. This is correct for theMapcomponent (which processes stream elements individually) and may be misleading for direct subscription→operation connections. TheOperationNodeAttrs.typefield is available for consumers that need subscription-aware behavior. A v2 extension could add astreaming: booleanflag on edges to capture stream semantics explicitly, but this adds complexity without a current use case. -
How granular should type compatibility be?Resolved (OQ-002/ADR-005): Type compatibility checking only applies to state-transfer edges (wheredataFlow: true). ThetypeCompat()function returns{ compatible, detail?, mismatches? }for state-transfer edges only. Temporal-only edges bypass type checking entirely.
References
- Schema: schema.md —
OperationNodeAttrs,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-dagpackage