Files
flowgraph/docs/architecture/operation-graph.md
glm-5.1 eaeba38e71 resolve architecture review round 2: criticals, warnings, suggestions
- C-05: Add flowgraph-api.md with complete public API surface
- C-06: Document <Map> component in workflow-templates.md
- C-07: Specify Conditional else-branch behavior
- C-08: Add lifecycle/ownership section to reactive-execution.md
- C-09: Add consumer-integration.md end-to-end walkthrough
- W-02: Add reactive error boundary semantics (3 levels)
- W-03: Complete ReactiveContext interface definition
- W-04: Add template composition rules (8 rules)
- W-05: Document removeChild for both HostConfigs
- W-06: Document signal/effect disposal lifecycle
- W-07: Add ADR-004 (no schema version field)
- W-08: Add type compatibility depth/contract to analysis.md
- W-11: Add performance characteristics section
- S-01: Getting Started merged into consumer-integration.md
- S-02: Add flow diagrams for template rendering pipeline
- S-03: Add node status state machine diagram
- S-04: Add testing strategy section
- S-06: Validate source structure cross-references

Review round 2 fixes:
- Define TemplateNodeAttrs as alias for OperationNodeAttrs
- Document CallEventMapValue and CallResult types in schema.md
- Standardize CycleError naming (replace CircularDependencyError)
- Add function form to Map.over type definition
- Define Map aggregate completion/failure semantics
- Fix immutability claim for fromCallEvents
- Clarify edgeType storage alongside OperationEdgeAttrs
- Clarify WorkflowNode.status === statusMap (same Signal)
- Add component-to-tag mapping for WorkflowTag
2026-05-19 13:05:35 +00:00

187 lines
9.9 KiB
Markdown

---
status: draft
last_updated: 2026-05-20
---
# Operation Graph (Static)
The static operation graph built from `OperationSpec`s 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()
```typescript
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
```typescript
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()
```typescript
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
```typescript
const graph = new FlowGraph();
graph.addOperation(spec);
graph.addTypedEdge("task.classify", "task.enrich", { compatible: true, detail: "output → input" });
```
`addOperation` adds a node. `addTypedEdge` adds a type-compatibility edge. Both throw on duplicates (matching taskgraph's behavior).
## Node Attributes
See [schema.md](schema.md#OperationNodeAttrs) 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
```typescript
{
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:
```typescript
function typeCompat(
outputSchema: TSchema,
inputSchema: TSchema,
): { compatible: boolean; detail?: string }
```
### Compatibility rules
The analysis is **structural**, not **semantic**:
1. **Exact match**`outputSchema` is identical to `inputSchema` → compatible
2. **Subtype match**`outputSchema` 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](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](call-graph.md)).
- **Edge direction is data flow** — `A → 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?** The current design adds both compatible and incompatible edges. An alternative is to only add compatible edges, with a separate `potentialEdges()` query that computes incompatible connections on demand. Pro: smaller graph. Con: loses diagnostic information.
2. **How to handle version conflicts?** If two versions of the same operation exist in the registry, should they be separate nodes (`task.classify@1.0.0` vs `task.classify@2.0.0`) or should the latest version win? The current design uses `namespace.name` (no version) as the node key, meaning only one version per operation can exist in the graph.
3. **Should subscription operations be treated differently?** A subscription produces a stream, not a single output. Its `outputSchema` describes a single stream element, but the data flow semantics are different from query/mutation. Should the type compatibility check account for this?
4. **How granular should type compatibility be?** The current `detail` field is a string. A more structured approach would be `{ compatible: boolean, mismatchPaths: string[] }` listing the specific JSON paths that don't match. This adds complexity but improves diagnostics.
## References
- Schema: [schema.md](schema.md) — `OperationNodeAttrs`, `OperationEdgeAttrs`, `CallStatus`, `EdgeType`
- Type compatibility: [analysis.md](analysis.md)
- Call graph: [call-graph.md](call-graph.md)
- Operation types: `@alkdev/operations/src/types.ts`
- Taskgraph construction: `@alkdev/taskgraph_ts/src/graph/construction.ts`
- Graphology DAG: `graphology-dag` package