fix: architecture review - address 5 critical issues, 6 warnings, 3 suggestions
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
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-21
|
last_updated: 2026-05-21
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,6 +72,7 @@ Flowgraph is in Phase 0/1 (exploration → architecture). No code exists yet. Th
|
|||||||
| [003](decisions/003-storage-decoupled.md) | Storage is not flowgraph's concern — in-memory graph with export/import boundary |
|
| [003](decisions/003-storage-decoupled.md) | Storage is not flowgraph's concern — in-memory graph with export/import boundary |
|
||||||
| [004](decisions/004-no-schema-version.md) | No schema version field in serialized format — consumers wrap in their own versioned envelope |
|
| [004](decisions/004-no-schema-version.md) | No schema version field in serialized format — consumers wrap in their own versioned envelope |
|
||||||
| [005](decisions/005-event-log-as-source-of-truth.md) | Execution Event Log as single source of truth — call protocol events as ground truth, status/result/call-graph as projections |
|
| [005](decisions/005-event-log-as-source-of-truth.md) | Execution Event Log as single source of truth — call protocol events as ground truth, status/result/call-graph as projections |
|
||||||
|
| [006](decisions/006-edge-type-consistency.md) | `edgeType` as universal required attribute on all edges; single shared graph per `FlowGraph` instance |
|
||||||
|
|
||||||
### Open Questions
|
### Open Questions
|
||||||
|
|
||||||
@@ -178,15 +179,16 @@ Architecture documents use YAML frontmatter with `status` and `last_updated` fie
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
status: draft | stable | deprecated
|
status: draft | reviewed | stable | deprecated
|
||||||
last_updated: YYYY-MM-DD
|
last_updated: YYYY-MM-DD
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
| Status | Meaning | Transitions |
|
| Status | Meaning | Transitions |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `draft` | Under active development. Content may change significantly. Implementation should not start until the document reaches `stable`. | → `stable` when implementation is complete and API contract is verified by tests. |
|
| `draft` | Under active development. Content may change significantly. Implementation should not start until the document reaches `reviewed`. | → `reviewed` when all open questions are resolved and cross-cutting issues are addressed. |
|
||||||
| `stable` | API contracts are locked. Changes require a review cycle and may warrant an ADR if they affect documented decisions. | → `deprecated` when superseded. → `draft` if a fundamental redesign is needed (rare). |
|
| `reviewed` | Architecture is final and reviewed. Implementation may begin. API contracts are specified but not yet verified by tests. Changes require a review cycle. | → `stable` when implementation is complete and API contracts are verified by tests. → `draft` if a fundamental redesign is needed (rare). |
|
||||||
|
| `stable` | API contracts are locked and verified by tests. Changes require a review cycle and may warrant an ADR if they affect documented decisions. | → `deprecated` when superseded. → `draft` if a fundamental redesign is needed (rare). |
|
||||||
| `deprecated` | Superseded by another document. Kept for reference. Links should point to the replacement. | Removed when no longer referenced. |
|
| `deprecated` | Superseded by another document. Kept for reference. Links should point to the replacement. | Removed when no longer referenced. |
|
||||||
|
|
||||||
ADR documents use a separate `Status` field in their body: `Proposed`, `Accepted`, `Deprecated`, or `Superseded`. ADRs never revert from `Accepted`.
|
ADR documents use a separate `Status` field in their body: `Proposed`, `Accepted`, `Deprecated`, or `Superseded`. ADRs never revert from `Accepted`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-20
|
last_updated: 2026-05-20
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,9 +236,9 @@ Following the taskgraph pattern, each module has a sub-path export:
|
|||||||
| `@alkdev/flowgraph` | Barrel export (everything) | Full import |
|
| `@alkdev/flowgraph` | Barrel export (everything) | Full import |
|
||||||
| `@alkdev/flowgraph/component` | `<Operation>`, `<Sequential>`, `<Parallel>`, `<Conditional>`, `<Map>` | Template authoring |
|
| `@alkdev/flowgraph/component` | `<Operation>`, `<Sequential>`, `<Parallel>`, `<Conditional>`, `<Map>` | Template authoring |
|
||||||
| `@alkdev/flowgraph/host` | `GraphologyHostConfig`, `ReactiveHostConfig` | ujsx HostConfig implementations |
|
| `@alkdev/flowgraph/host` | `GraphologyHostConfig`, `ReactiveHostConfig` | ujsx HostConfig implementations |
|
||||||
| `@alkdev/flowgraph/schema` | TypeBox schemas, enums, types | Schema-only import (no graph dependency) |
|
| `@alkdev/flowgraph/schema` | TypeBox schemas, enums, types (including `CallResultSchema`, `CallResult`) | Schema-only import (no graph dependency) |
|
||||||
| `@alkdev/flowgraph/graph` | `FlowGraph` class, construction, mutation, queries | Core graph operations |
|
| `@alkdev/flowgraph/graph` | `FlowGraph` class, construction, mutation, queries | Core graph operations |
|
||||||
| `@alkdev/flowgraph/reactive` | `WorkflowReactiveRoot`, signal-based execution | Runtime execution |
|
| `@alkdev/flowgraph/reactive` | `WorkflowReactiveRoot`, `EventLogProjection`, signal-based execution | Runtime execution |
|
||||||
| `@alkdev/flowgraph/analysis` | `typeCompat`, `validateTemplate`, ordering functions | Analysis and validation |
|
| `@alkdev/flowgraph/analysis` | `typeCompat`, `validateTemplate`, ordering functions | Analysis and validation |
|
||||||
| `@alkdev/flowgraph/error` | Error classes | Error handling |
|
| `@alkdev/flowgraph/error` | Error classes | Error handling |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-19
|
last_updated: 2026-05-19
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,22 +152,50 @@ const reactiveHost = new ReactiveHostConfig(registry, workflowRoot);
|
|||||||
const reactiveRoot = createRoot(reactiveHost, {});
|
const reactiveRoot = createRoot(reactiveHost, {});
|
||||||
reactiveRoot.render(template);
|
reactiveRoot.render(template);
|
||||||
|
|
||||||
// 4. Subscribe to status changes and effect-driven execution
|
// 4. Drive execution via the event log (ADR-005 pattern)
|
||||||
|
// The hub coordinator appends call protocol events; the projections derive state.
|
||||||
for (const [nodeId, node] of workflowRoot.nodes) {
|
for (const [nodeId, node] of workflowRoot.nodes) {
|
||||||
// Start the call when preconditions are met
|
// Start the call when preconditions are met
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (node.preconditions.value && node.status.value === "idle" || node.status.value === "waiting") {
|
if (node.preconditions.value && (node.status.value === "idle" || node.status.value === "waiting")) {
|
||||||
node.status.value = "running";
|
// All preconditions satisfied — start the call by appending to the event log
|
||||||
// getInput resolves the node's input from predecessor outputs and static config
|
const operationId = dag.getNodeAttributes(nodeId).name;
|
||||||
// For Operation nodes, input comes from the template props or aggregated predecessor results
|
const requestId = crypto.randomUUID();
|
||||||
const input = resolveInput(nodeId, workflowRoot);
|
workflowRoot.nodeKeyToRequestId.set(nodeId, requestId);
|
||||||
registry.execute(node.operationId, input, { parentRequestId: parentCallId })
|
|
||||||
.then(result => { node.status.value = "completed"; node.output.value = result; })
|
// Append call.requested event — the status projection derives "running" from this
|
||||||
.catch(error => { node.status.value = "failed"; });
|
workflowRoot.append({
|
||||||
|
type: "call.requested",
|
||||||
|
requestId,
|
||||||
|
operationId,
|
||||||
|
input: getInput(nodeId, workflowRoot),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the actual call — when it completes, append the result event
|
||||||
|
registry.execute(operationId, getInput(nodeId, workflowRoot), { parentRequestId })
|
||||||
|
.then(result => {
|
||||||
|
// Append call.responded event — the status projection derives "completed" from this
|
||||||
|
workflowRoot.append({
|
||||||
|
type: "call.responded",
|
||||||
|
requestId,
|
||||||
|
output: result,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Append call.error event — the status projection derives "failed" from this
|
||||||
|
workflowRoot.append({
|
||||||
|
type: "call.error",
|
||||||
|
requestId,
|
||||||
|
error: { code: error.code, message: error.message },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track failures
|
// Track failures for logging
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (node.status.value === "failed") {
|
if (node.status.value === "failed") {
|
||||||
console.error(`Node ${nodeId} failed`);
|
console.error(`Node ${nodeId} failed`);
|
||||||
|
|||||||
65
docs/architecture/decisions/006-edge-type-consistency.md
Normal file
65
docs/architecture/decisions/006-edge-type-consistency.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR-006: Edge Type Consistency and Single-Graph Architecture
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two related questions affect how edge types are represented in flowgraph:
|
||||||
|
|
||||||
|
1. **Should `edgeType` be a required attribute on all edges, or only on edges where it varies?** Operation graphs only have `typed` edges, call graphs have `triggered` and `depends_on`, and template DAGs have `sequential` and `conditional`. Making `edgeType` required on every edge adds redundancy for operation graphs (where it's always `"typed"`) but ensures consistent serialization.
|
||||||
|
|
||||||
|
2. **Should `GraphologyHostConfig` produce separate graphs per edge type, or a single shared graph?** Separate graphs per edge type would enable type-specific queries and avoid attribute namespace collisions, but add complexity for cross-graph traversal and cache coherence.
|
||||||
|
|
||||||
|
These decisions affect the schema (schema.md), the serialized format, the call graph mutation API, and the host config implementations.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **`edgeType` is a required universal attribute on every edge**, stored alongside (not inside) the mode-specific attribute schemas. This means the stored edge attributes are always `{ edgeType, ...modeSpecificAttrs }` at the graphology level.
|
||||||
|
|
||||||
|
2. **All edge types share a single graphology `DirectedGraph` instance per `FlowGraph`.** No separate graphs per edge type.
|
||||||
|
|
||||||
|
3. **Mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, `DependencyEdgeAttrs`) do NOT include `edgeType`.** It's stored separately at the graphology level alongside the mode-specific attributes.
|
||||||
|
|
||||||
|
4. **`TemplateEdgeAttrs` includes `edgeType` as a constrained union (`"sequential" | "conditional"`)** because template edges need to distinguish their type for rendering.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### Why `edgeType` on every edge
|
||||||
|
|
||||||
|
- **Consistent serialization/deserialization** — graphology's native JSON format requires edge attributes. Having `edgeType` always present simplifies the format: every edge object has an `edgeType` field. No conditional logic to determine whether `edgeType` is present.
|
||||||
|
|
||||||
|
- **Uniform graphology queries** — filtering edges by type is always `edge.attributes.edgeType === "triggered"`, regardless of graph mode. No need to check whether the attribute exists first.
|
||||||
|
|
||||||
|
- **The redundancy for operation graphs is a minor cost** — `edgeType: "typed"` on every operation edge is two extra bytes per edge. The consistency benefit far outweighs the storage cost.
|
||||||
|
|
||||||
|
### Why a single shared graph
|
||||||
|
|
||||||
|
- **Cross-graph traversal is unnecessary at current scale** — the expected graph sizes (tens to hundreds of nodes) make separate graphs per edge type an over-engineering. Filtering by `edgeType` is O(n) on edges and negligible.
|
||||||
|
|
||||||
|
- **Single graph simplifies the API** — `addEdge`, `removeEdge`, `getEdgeAttributes` work on a single graph. No need for `addTriggeredEdge`, `addDependencyEdge`, `getTypedEdge` etc.
|
||||||
|
|
||||||
|
- **Separate graphs add complexity** — maintaining multiple graphology instances, keeping node sets synchronized across graphs, and handling cross-graph queries would add significant implementation and testing burden.
|
||||||
|
|
||||||
|
- **Future optimization is possible without API changes** — if a concrete performance issue arises with very large graphs, an internal `Map<EdgeType, Set<string>>` edge index can be added as an optimization without changing the public API.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **All `FlowGraph` instances store edges with `{ edgeType, ...modeSpecificAttrs }` at the graphology level.** The TypeBox schemas for mode-specific attributes (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, `DependencyEdgeAttrs`) define only what's unique to each mode; `edgeType` is added separately during edge creation.
|
||||||
|
|
||||||
|
- **Edge-type filtering is done via standard graphology attribute queries.** Example: `graph.filterEdges((edge, attrs) => attrs.edgeType === "triggered")`.
|
||||||
|
|
||||||
|
- **The `CallEdgeAttrs` union type is discriminated by `edgeType` at runtime** (not by TypeBox schema validation, since `TriggeredEdgeAttrs` and `DependencyEdgeAttrs` are both empty objects). When validating serialized call graph edges, the two-step process applies: (1) check that `edgeType` is present and matches the expected value for the graph mode, (2) validate remaining attributes against the mode-specific schema.
|
||||||
|
|
||||||
|
- **Edge key format uses composite keys for multi-type scenarios.** The `triggered` edge type gets the simple `${source}->${target}` key format; `depends_on` always gets the composite `${source}->${target}:depends_on` format. This ensures deterministic keys even when multiple edge types connect the same node pair.
|
||||||
|
|
||||||
|
- **Serialization validation is a two-step process:** (1) check that `edgeType` is present and matches the expected value for the graph mode, (2) validate remaining attributes against the mode-specific schema.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Schema: [schema.md](../schema.md) — Edge attribute schemas, Edge Key Convention
|
||||||
|
- Call graph: [call-graph.md](../call-graph.md) — `triggered` and `depends_on` edges
|
||||||
|
- Operation graph: [operation-graph.md](../operation-graph.md) — `typed` edges
|
||||||
|
- Host configs: [host-configs.md](../host-configs.md) — Single vs. separate graph decision
|
||||||
|
- Open questions: [open-questions.md](../open-questions.md) — OQ-004, OQ-029
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-20
|
last_updated: 2026-05-20
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,6 +87,8 @@ static fromJSON(data: FlowGraphSerialized<NodeAttrs, EdgeAttrs>): FlowGraph<Node
|
|||||||
|
|
||||||
Deserializes from graphology native JSON format. Validates against the appropriate schema (`OperationGraphSerialized` or `CallGraphSerialized`). Throws `InvalidInputError` on validation failure.
|
Deserializes from graphology native JSON format. Validates against the appropriate schema (`OperationGraphSerialized` or `CallGraphSerialized`). Throws `InvalidInputError` on validation failure.
|
||||||
|
|
||||||
|
After schema validation, `fromJSON()` validates DAG invariants by running cycle detection on the deserialized graph. If cycles are found, it throws `CycleError` with the cycle paths. This enforces ADR-002's DAG-only invariant even for externally-provided data — a corrupted or adversarial JSON input cannot produce a cyclic graph that would violate downstream assumptions (e.g., that `topologicalOrder()` always succeeds).
|
||||||
|
|
||||||
Round-trip guarantee: `fromSpecs()` → `export()` → `fromJSON()` is lossless.
|
Round-trip guarantee: `fromSpecs()` → `export()` → `fromJSON()` is lossless.
|
||||||
|
|
||||||
## Mutation Methods
|
## Mutation Methods
|
||||||
@@ -266,7 +268,7 @@ This is an escape hatch. Direct graph mutation bypasses flowgraph's validation (
|
|||||||
| `validate()`, `hasCycles()` | No — reads | Validation results |
|
| `validate()`, `hasCycles()` | No — reads | Validation results |
|
||||||
| `typeCompat()` | No — reads | `TypeCompatResult` |
|
| `typeCompat()` | No — reads | `TypeCompatResult` |
|
||||||
|
|
||||||
**Key invariant**: The operation graph produced by `fromSpecs()` is immutable after construction — no mutation methods are exposed. If the registry changes, rebuild the graph. The call graph produced by `fromCallEvents()` supports incremental mutation via `addCall`, `updateStatus`, and `addDependency`. The initial events populate the graph, and subsequent events update it.
|
**Key invariant**: The operation graph produced by `fromSpecs()` is conventionally immutable — consumers should not call mutation methods on it after construction. The mutation methods (`addNode`, `addEdge`, etc.) are available for incremental construction via `new FlowGraph()` + `addOperation()`/`addTypedEdge()`. If the registry changes, rebuild the graph. The call graph produced by `fromCallEvents()` supports incremental mutation via `addCall`, `updateStatus`, and `addDependency`. The initial events populate the graph, and subsequent events update it.
|
||||||
|
|
||||||
## Exports Map
|
## Exports Map
|
||||||
|
|
||||||
@@ -275,10 +277,10 @@ This is an escape hatch. Direct graph mutation bypasses flowgraph's validation (
|
|||||||
| `@alkdev/flowgraph` | `FlowGraph`, all public types |
|
| `@alkdev/flowgraph` | `FlowGraph`, all public types |
|
||||||
| `@alkdev/flowgraph/graph` | `FlowGraph`, `FlowGraphOptions` |
|
| `@alkdev/flowgraph/graph` | `FlowGraph`, `FlowGraphOptions` |
|
||||||
| `@alkdev/flowgraph/analysis` | `typeCompat`, `buildTypeEdges`, `validateGraph`, `validateTemplate`, `topologicalOrder`, `parallelGroups`, `criticalPath`, `reachableFrom` |
|
| `@alkdev/flowgraph/analysis` | `typeCompat`, `buildTypeEdges`, `validateGraph`, `validateTemplate`, `topologicalOrder`, `parallelGroups`, `criticalPath`, `reachableFrom` |
|
||||||
| `@alkdev/flowgraph/schema` | `OperationNodeAttrs`, `CallNodeAttrs`, `OperationEdgeAttrs`, `CallEdgeAttrs`, `TemplateEdgeAttrs`, `CallStatus`, `NodeStatus`, `EdgeType` |
|
| `@alkdev/flowgraph/schema` | `OperationNodeAttrs`, `CallNodeAttrs`, `OperationEdgeAttrs`, `CallEdgeAttrs`, `TemplateEdgeAttrs`, `CallResultSchema`, `CallResult`, `CallStatus`, `NodeStatus`, `EdgeType` |
|
||||||
| `@alkdev/flowgraph/component` | `Operation`, `Sequential`, `Parallel`, `Conditional`, `Map` |
|
| `@alkdev/flowgraph/component` | `Operation`, `Sequential`, `Parallel`, `Conditional`, `Map` |
|
||||||
| `@alkdev/flowgraph/host` | `GraphologyHostConfig`, `ReactiveHostConfig` |
|
| `@alkdev/flowgraph/host` | `GraphologyHostConfig`, `ReactiveHostConfig` |
|
||||||
| `@alkdev/flowgraph/reactive` | `WorkflowReactiveRoot`, `WorkflowNode`, `ReactiveContext` |
|
| `@alkdev/flowgraph/reactive` | `WorkflowReactiveRoot`, `WorkflowNode`, `ReactiveContext`, `EventLogProjection` |
|
||||||
| `@alkdev/flowgraph/error` | `FlowgraphError`, `ConstructionError`, `CycleError`, `ValidationError`, `TypeIncompatError`, `InvalidTransitionError` |
|
| `@alkdev/flowgraph/error` | `FlowgraphError`, `ConstructionError`, `CycleError`, `ValidationError`, `TypeIncompatError`, `InvalidTransitionError` |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
@@ -288,7 +290,7 @@ This is an escape hatch. Direct graph mutation bypasses flowgraph's validation (
|
|||||||
- **No parallel edges** — `addEdge` throws `DuplicateEdgeError` if an edge already exists between the same (source, target) pair.
|
- **No parallel edges** — `addEdge` throws `DuplicateEdgeError` if an edge already exists between the same (source, target) pair.
|
||||||
- **No self-loops** — enforced at the graphology level (`allowSelfLoops: false`).
|
- **No self-loops** — enforced at the graphology level (`allowSelfLoops: false`).
|
||||||
- **Edge keys are deterministic** — `${source}->${target}` format. No user-specified edge keys.
|
- **Edge keys are deterministic** — `${source}->${target}` format. No user-specified edge keys.
|
||||||
- **Operation graph is immutable after construction** — no mutation methods are exposed after `fromSpecs()`. If the registry changes, rebuild the graph.
|
- **Operation graph is conventionally immutable after construction** — `fromSpecs()` produces a graph that consumers should treat as immutable. Mutation methods (`addOperation`, `addTypedEdge`) are available for incremental construction but should not be used on factory-produced graphs. If the registry changes, rebuild the graph.
|
||||||
- **Call graph supports incremental mutation** — `addCall`, `updateStatus`, `addDependency` are the primary mutation paths.
|
- **Call graph supports incremental mutation** — `addCall`, `updateStatus`, `addDependency` are the primary mutation paths.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -303,34 +303,9 @@ Cross-cutting compilation of all unresolved questions across the flowgraph archi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-006: Edge Type Consistency and Single-Graph Architecture
|
### ADR-006: Edge Type Consistency and Single-Graph Architecture
|
||||||
|
|
||||||
**Status**: Accepted
|
See [decisions/006-edge-type-consistency.md](decisions/006-edge-type-consistency.md) for the full decision record.
|
||||||
|
|
||||||
**Context**: Two related questions (OQ-04, OQ-29) affect how edge types are represented in flowgraph:
|
|
||||||
- Should `edgeType` be a required attribute on all edges, or only on edges where it varies?
|
|
||||||
- Should `GraphologyHostConfig` produce separate graphs per edge type, or a single shared graph?
|
|
||||||
|
|
||||||
**Decision**:
|
|
||||||
1. `edgeType` is a required universal attribute on every edge, stored alongside (not inside) mode-specific attribute schemas.
|
|
||||||
2. All edge types share a single graphology `DirectedGraph` instance per `FlowGraph`.
|
|
||||||
3. Mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, `DependencyEdgeAttrs`) do **not** include `edgeType` — it's stored separately at the graphology level.
|
|
||||||
4. `TemplateEdgeAttrs` includes `edgeType` as a constrained union (`"sequential" | "conditional"`) because template edges need to distinguish their type for rendering.
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Consistent serialization/deserialization (graphology native JSON format requires edge attributes)
|
|
||||||
- Uniform graphology queries and edge-type filtering across all graph modes
|
|
||||||
- The redundancy for operation graphs (`edgeType` is always `"typed"`) is a minor cost for significant consistency gains
|
|
||||||
- Separate graphs per edge type would add complexity (cross-graph traversal, cache coherence, multi-graph queries) without benefit at current scale
|
|
||||||
- Single-graph filtering by `edgeType` is O(n) on edges — negligible for expected graph sizes
|
|
||||||
|
|
||||||
**Consequences**:
|
|
||||||
- All `FlowGraph` instances store edges with `{ edgeType, ...modeSpecificAttrs }` at the graphology level
|
|
||||||
- Edge-type filtering is done via standard graphology attribute queries
|
|
||||||
- The `CallEdgeAttrs` union type is discriminated by `edgeType` at runtime (not by TypeBox schema validation, since both variants are empty objects)
|
|
||||||
- Serialization validation is a two-step process: (1) check that `edgeType` is present and matches the expected value for the graph mode, (2) validate remaining attributes against the mode-specific schema
|
|
||||||
- The `triggered` edge type gets the simple `${source}->${target}` key format; `depends_on` always gets the composite `${source}->${target}:depends_on` format (see schema.md Edge Key Convention)
|
|
||||||
- Future optimization (if needed) could add an internal `Map<EdgeType, Set<string>>` index without changing the public API
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -509,6 +509,38 @@ function callStatusToNodeStatus(callStatus: CallStatus): NodeStatus {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Aggregate Status
|
||||||
|
|
||||||
|
For consumers that need to check whether a workflow has completed, the `WorkflowReactiveRoot` provides convenience methods:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Returns true when all nodes have reached a terminal state
|
||||||
|
* (completed, failed, aborted, or skipped).
|
||||||
|
* Useful for checking workflow completion without manually
|
||||||
|
* iterating statusMap.
|
||||||
|
*/
|
||||||
|
isComplete(): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an aggregate status summary for the workflow.
|
||||||
|
* Useful for observability and completion tracking.
|
||||||
|
*/
|
||||||
|
getAggregateStatus(): {
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
aborted: number;
|
||||||
|
skipped: number;
|
||||||
|
running: number;
|
||||||
|
waiting: number;
|
||||||
|
ready: number;
|
||||||
|
idle: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These methods derive from the `statusMap` and align with ADR-005's projection model — they read signal values rather than scanning the event log directly, since the signals are already projections of the log.
|
||||||
|
|
||||||
## Event-Driven Execution
|
## Event-Driven Execution
|
||||||
|
|
||||||
Under ADR-005, the hub coordinator's responsibility shifts from directly setting signal values to **appending events to the log**. The reactive layer drives execution via `effect()`s that watch projections and invoke calls when preconditions are met.
|
Under ADR-005, the hub coordinator's responsibility shifts from directly setting signal values to **appending events to the log**. The reactive layer drives execution via `effect()`s that watch projections and invoke calls when preconditions are met.
|
||||||
@@ -634,13 +666,18 @@ The reactive execution layer has three levels of error handling, each with disti
|
|||||||
|
|
||||||
### Level 1: Signal-level errors (per-node)
|
### Level 1: Signal-level errors (per-node)
|
||||||
|
|
||||||
When a call fails, the hub coordinator sets the node's status to `"failed"`:
|
When a call fails, the hub coordinator appends a `call.error` event to the event log:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
status.value = "failed"; // Individual node failure
|
workflowRoot.append({
|
||||||
|
type: "call.error",
|
||||||
|
requestId,
|
||||||
|
error: { code: error.code, message: error.message },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
This triggers `blockedByFailure` in all downstream dependents, causing them to transition to `"aborted"`. The failure propagates through the signal graph reactively — no manual error handling is needed.
|
The status projection derives `NodeStatus.failed` from this event. The `blockedByFailure` computed in all downstream dependents automatically re-evaluates, causing them to transition to `"aborted"`. The failure propagates through the signal graph reactively — no manual error handling is needed.
|
||||||
|
|
||||||
### Level 2: Conditional error boundaries (branch-level)
|
### Level 2: Conditional error boundaries (branch-level)
|
||||||
|
|
||||||
@@ -700,7 +737,7 @@ The `WorkflowErrorBoundary` catches errors that escape the signal graph (e.g., a
|
|||||||
|
|
||||||
| Error type | Scope | Mechanism | Recovery |
|
| Error type | Scope | Mechanism | Recovery |
|
||||||
|------------|-------|-----------|----------|
|
|------------|-------|-----------|----------|
|
||||||
| Call failure | Single node | `status.value = "failed"` | Cascades to dependents via `blockedByFailure` |
|
| Call failure | Single node | `workflowRoot.append({ type: "call.error", ... })` | Cascades to dependents via `blockedByFailure` |
|
||||||
| Caught by Conditional | Branch | `Conditional.test` evaluates against failed status | Redirect to else-branch, downstream sees `completed` |
|
| Caught by Conditional | Branch | `Conditional.test` evaluates against failed status | Redirect to else-branch, downstream sees `completed` |
|
||||||
| Uncaught cascade | Downstream chain | `blockedByFailure` effects | Downstream nodes transition to `aborted` |
|
| Uncaught cascade | Downstream chain | `blockedByFailure` effects | Downstream nodes transition to `aborted` |
|
||||||
| System failure | Entire workflow | `abortAll()` | All non-terminal nodes to `aborted` |
|
| System failure | Entire workflow | `abortAll()` | All non-terminal nodes to `aborted` |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,21 +101,22 @@ type NodeStatus = Static<typeof NodeStatusEnum>;
|
|||||||
|
|
||||||
### CallResult
|
### CallResult
|
||||||
|
|
||||||
The result of a completed call, used by `Conditional.test` and `Map.over` to access predecessor outputs:
|
The result of a completed call, used by `Conditional.test` and `Map.over` to access predecessor outputs. Following the TypeBox-as-single-source-of-truth principle, `CallResult` is defined as a TypeBox schema with the corresponding type derived via `Static`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface CallResult {
|
const CallResultSchema = Type.Object({
|
||||||
status: NodeStatus; // Status of the call (completed, failed, aborted, skipped)
|
status: NodeStatusEnum, // Status of the call (completed, failed, aborted, skipped)
|
||||||
output: unknown; // Call output (if completed)
|
output: Type.Unknown(), // Call output (if completed)
|
||||||
error?: { // Call error (if failed)
|
error: Type.Optional(Type.Object({ // Call error (if failed)
|
||||||
code: string;
|
code: Type.String(),
|
||||||
message: string;
|
message: Type.String(),
|
||||||
details?: unknown;
|
details: Type.Optional(Type.Unknown()),
|
||||||
};
|
})),
|
||||||
}
|
});
|
||||||
|
type CallResult = Static<typeof CallResultSchema>;
|
||||||
```
|
```
|
||||||
|
|
||||||
`CallResult` is the value in the `results` map passed to `Conditional.test` and `Map.over` functions. It's derived from `CallNodeAttrs` but simplified for template use — it omits `requestId`, `operationId`, `identity`, and timestamps, preserving only what template logic needs.
|
`CallResult` is the value in the `results` map passed to `Conditional.test` and `Map.over` functions. It's derived from `CallNodeAttrs` but simplified for template use — it omits `requestId`, `operationId`, `identity`, and timestamps, preserving only what template logic needs. The `output` field uses `Type.Unknown()` because call outputs are arbitrary data; the `error` field mirrors the `CallNodeAttrs.error` structure.
|
||||||
|
|
||||||
### OperationTypeEnum
|
### OperationTypeEnum
|
||||||
|
|
||||||
@@ -262,7 +263,7 @@ type OperationEdgeAttrs = Static<typeof OperationEdgeAttrs>;
|
|||||||
|
|
||||||
Type-compatibility edges carry a boolean `compatible` flag, an optional `detail` string, and optional structured `mismatches`. This allows the operation graph to include both compatible edges (green paths) and incompatible edges (red paths) for diagnostics. The `detail` field provides a human-readable summary, while `mismatches` provides machine-readable field-level diagnostics. The `TypeCompatResult` from `typeCompat()` populates both fields: `detail` for compatible edges and `mismatches` for incompatible ones.
|
Type-compatibility edges carry a boolean `compatible` flag, an optional `detail` string, and optional structured `mismatches`. This allows the operation graph to include both compatible edges (green paths) and incompatible edges (red paths) for diagnostics. The `detail` field provides a human-readable summary, while `mismatches` provides machine-readable field-level diagnostics. The `TypeCompatResult` from `typeCompat()` populates both fields: `detail` for compatible edges and `mismatches` for incompatible ones.
|
||||||
|
|
||||||
**Edge type storage (OQ-004)**: `edgeType` is a required universal attribute stored on every edge, regardless of graph mode. This applies uniformly: operation graph edges have `edgeType: "typed"`, call graph edges have `edgeType: "triggered"` or `"depends_on"`, and template edges have `edgeType: "sequential"` or `"conditional"`. The `edgeType` field is stored alongside mode-specific attributes in graphology, not inside the mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, etc.). This ensures consistent serialization/deserialization, uniform graphology queries, and straightforward edge-type filtering. See ADR-006 for the full decision record.
|
**Edge type storage (OQ-004)**: `edgeType` is a required universal attribute stored on every edge, regardless of graph mode. This applies uniformly: operation graph edges have `edgeType: "typed"`, call graph edges have `edgeType: "triggered"` or `"depends_on"`, and template edges have `edgeType: "sequential"` or `"conditional"`. The `edgeType` field is stored alongside mode-specific attributes in graphology, not inside the mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, etc.). This ensures consistent serialization/deserialization, uniform graphology queries, and straightforward edge-type filtering. See [ADR-006](decisions/006-edge-type-consistency.md) (flowgraph) for the full decision record.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// How operation graph edges are stored in graphology:
|
// How operation graph edges are stored in graphology:
|
||||||
@@ -347,12 +348,6 @@ Edges where `dataFlow` cannot be determined (e.g., `Operation.input` is an opaqu
|
|||||||
|
|
||||||
Over-marking `dataFlow: true` is safe (it just causes an unnecessary type compatibility check), while under-marking is safe (it skips a check that would have passed anyway, but could let a type-incompatible connection through). The conservative strategy errs on the side of under-marking.
|
Over-marking `dataFlow: true` is safe (it just causes an unnecessary type compatibility check), while under-marking is safe (it skips a check that would have passed anyway, but could let a type-incompatible connection through). The conservative strategy errs on the side of under-marking.
|
||||||
|
|
||||||
The `dataFlow` attribute is **inferred** by the `GraphologyHostConfig` during template rendering, not manually specified by template authors:
|
|
||||||
|
|
||||||
- A `Sequential` edge where the downstream node references `results["upstreamNode"]` in any expression gets `dataFlow: true`
|
|
||||||
- A `Sequential` edge where no such reference exists gets `dataFlow: false` (the default)
|
|
||||||
- A `Conditional` edge always gets `dataFlow: true` (the condition always reads a predecessor's result)
|
|
||||||
|
|
||||||
This resolves OQ-01 and OQ-02: `typeCompat()` only runs on edges where `dataFlow: true`. Temporal-only edges bypass type checking entirely, since no data flows between the connected nodes.
|
This resolves OQ-01 and OQ-02: `typeCompat()` only runs on edges where `dataFlow: true`. Temporal-only edges bypass type checking entirely, since no data flows between the connected nodes.
|
||||||
|
|
||||||
**Note**: `TemplateEdgeAttrs.edgeType` uses a constrained union of `"sequential" | "conditional"` rather than the full `EdgeTypeEnum`. Template DAGs never have `triggered`, `depends_on`, or `typed` edges — those belong to call graphs and operation graphs respectively.
|
**Note**: `TemplateEdgeAttrs.edgeType` uses a constrained union of `"sequential" | "conditional"` rather than the full `EdgeTypeEnum`. Template DAGs never have `triggered`, `depends_on`, or `typed` edges — those belong to call graphs and operation graphs respectively.
|
||||||
@@ -427,7 +422,7 @@ For call graphs, edges can be either `triggered` or `depends_on`, distinguished
|
|||||||
|
|
||||||
## Edge Key Convention
|
## Edge Key Convention
|
||||||
|
|
||||||
Following taskgraph's ADR-006, edge keys are deterministic:
|
Following taskgraph's ADR-006 (edge key convention), edge keys are deterministic:
|
||||||
|
|
||||||
```
|
```
|
||||||
${source}->${target}
|
${source}->${target}
|
||||||
@@ -453,8 +448,8 @@ This is an exception to the simple `${source}->${target}` pattern, but it's nece
|
|||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- **TypeBox schemas are the single source of truth** — no hand-written `interface` or `type` definitions for data shapes. All types are derived via `Static<typeof Schema>`.
|
- **TypeBox schemas are the single source of truth** — no hand-written `interface` or `type` definitions for data shapes that participate in graph attributes, serialization, or runtime validation. All such types are derived via `Static<typeof Schema>`. Exception: analysis result types returned by validation and compatibility functions (e.g., `ValidationError`, `GraphValidationError`, `TypeIncompatError`) are plain interfaces because they are ephemeral result objects, not serialized graph data. They don't need TypeBox schemas because they are never persisted or transmitted — they are consumed locally and discarded.
|
||||||
- **Edge keys are deterministic** — `${source}->${target}` format, following ADR-006 in taskgraph.
|
- **Edge keys are deterministic** — `${source}->${target}` format, following taskgraph's ADR-006 (edge key convention).
|
||||||
- **No parallel edges** — `multi: false` in graphology. At most one edge per (source, target) pair.
|
- **No parallel edges** — `multi: false` in graphology. At most one edge per (source, target) pair.
|
||||||
- **No self-loops** — `allowSelfLoops: false`. An operation cannot be its own prerequisite.
|
- **No self-loops** — `allowSelfLoops: false`. An operation cannot be its own prerequisite.
|
||||||
- **ISO timestamp strings** — Call graph timestamps are ISO 8601 strings, matching the storage schema.
|
- **ISO timestamp strings** — Call graph timestamps are ISO 8601 strings, matching the storage schema.
|
||||||
@@ -464,7 +459,7 @@ This is an exception to the simple `${source}->${target}` pattern, but it's nece
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
1. ~~**Should `edgeType` be a required field on ALL edges, or only on call graph and template edges?**~~ **Resolved (OQ-004)**: `edgeType` is required on all edges, stored as a universal attribute alongside mode-specific attributes. The mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, `DependencyEdgeAttrs`) do NOT include `edgeType` — it's stored separately in graphology at the same level as the mode-specific attributes. This ensures consistent serialization/deserialization, uniform graphology queries, and straightforward edge-type filtering across all graph modes. See ADR-006.
|
1. ~~**Should `edgeType` be a required field on ALL edges, or only on call graph and template edges?**~~ **Resolved (OQ-004)**: `edgeType` is required on all edges, stored as a universal attribute alongside mode-specific attributes. The mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, `DependencyEdgeAttrs`) do NOT include `edgeType` — it's stored separately in graphology at the same level as the mode-specific attributes. This ensures consistent serialization/deserialization, uniform graphology queries, and straightforward edge-type filtering across all graph modes. See [ADR-006](decisions/006-edge-type-consistency.md) (flowgraph).
|
||||||
|
|
||||||
2. ~~**Should `CallNodeAttrs.identity` be a `Type.Record` or the structured `Identity` type from operations?**~~ **Resolved (OQ-022)**: Import the `Identity` type structure from `@alkdev/operations` (peer dependency). Since `@alkdev/operations` is already a peer dependency (for `CallEventMapValue`), adding this type import creates minimal additional coupling. The `CallNodeAttrs.identity` field mirrors the `Identity` interface: `{ id, scopes, resources? }`. Version alignment is handled by semver ranges. The TypeBox schema for `identity` is defined inline in `CallNodeAttrs` to match the shape (not imported as a TypeBox schema, since `@alkdev/operations` defines `Identity` as a TypeScript interface), but the field semantics match exactly.
|
2. ~~**Should `CallNodeAttrs.identity` be a `Type.Record` or the structured `Identity` type from operations?**~~ **Resolved (OQ-022)**: Import the `Identity` type structure from `@alkdev/operations` (peer dependency). Since `@alkdev/operations` is already a peer dependency (for `CallEventMapValue`), adding this type import creates minimal additional coupling. The `CallNodeAttrs.identity` field mirrors the `Identity` interface: `{ id, scopes, resources? }`. Version alignment is handled by semver ranges. The TypeBox schema for `identity` is defined inline in `CallNodeAttrs` to match the shape (not imported as a TypeBox schema, since `@alkdev/operations` defines `Identity` as a TypeScript interface), but the field semantics match exactly.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: reviewed
|
||||||
last_updated: 2026-05-22
|
last_updated: 2026-05-22
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user