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
187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-05-22
|
|
---
|
|
|
|
# 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", mismatches: [] });
|
|
```
|
|
|
|
`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?**~~ **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.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 |