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
This commit is contained in:
2026-05-19 13:05:35 +00:00
parent 1dbaccbde3
commit eaeba38e71
13 changed files with 1489 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-05-19
last_updated: 2026-05-20
---
# Reactive Execution
@@ -87,15 +87,49 @@ For each operation node in the DAG:
The signal-based status lifecycle mirrors `CallStatus` with workflow-specific additions:
```
idle → waiting → ready → running → completed
↓ ↑
failed
↓ │
(uncaught) → aborted ←──┘
(cascade from failed predecessor)
skipped (conditional)
┌──────┐
┌────────│ idle │────────────┐
└──┬───┘
│ predecessor │ (no predecessors —
│ starts running │ root node)
│ ▼
┌───────┐ │
│waiting│
└───┬───┘
│ │ all preds │
│ │ completed/ │
│ ┌────┤ skipped │
│ │ │ ▼
│ │ │ ┌──────┐
│ │ └──────────►│ready │
│ │ └──┬───┘
│ │ │ hub starts call
│ │ ▼
│ │ ┌────────┐
│ │ │running │──── ──── ──── ────►
│ │ └──┬──┬──┘ │
│ │ │ │ │
│ │ call │ │ call │ call
│ │ completed │ │ failed │ aborted
│ │ │ │ │
│ │ ▼ ▼ ▼
│ │ ┌───────────┐ ┌──────┐ ┌────────┐
│ │ │ completed │ │failed│ │aborted │
│ │ └───────────┘ └──────┘ └────────┘
│ │ │ │ │
│ │ │ │ (uncaught) │
│ │ │ ▼ │
│ │ │ cascades to all │
│ │ │ downstream dependents │
│ │ │ via blockedByFailure │
│ │ │ │
└──────┼──────────────┼────────────────────────────┘
│ │
│ ┌─────────┐│
└───►│skipped ││ (Conditional branch
└─────────┘│ not taken)
└─── all are terminal states
```
Full transition rules:
@@ -442,6 +476,84 @@ abortAll(): void {
This transitions all non-terminal, non-failed nodes to `aborted`. It's for cases where the entire workflow should stop, regardless of which branches are independent.
## Reactive Error Boundaries
The reactive execution layer has three levels of error handling, each with distinct scope and semantics:
### Level 1: Signal-level errors (per-node)
When a call fails, the hub coordinator sets the node's status to `"failed"`:
```typescript
status.value = "failed"; // Individual node failure
```
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.
### Level 2: Conditional error boundaries (branch-level)
A `Conditional` node catches failures and redirects to an alternative branch:
```typescript
h(Conditional, {
test: (results) => results["fetch-data"].status !== "failed",
},
// then-branch (happy path)
h(Operation, { name: "process" }),
// else-branch (fallback)
h(Operation, { name: "handle-error" }),
)
```
When the `Conditional`'s `test` function evaluates to `false` (because a predecessor failed), the then-branch transitions to `skipped` and the else-branch becomes `ready`. Downstream nodes after the `Conditional` see it as `completed` — the failure is contained.
This is the reactive equivalent of a `try/catch` block. Without a `Conditional`, failures cascade uncaught through dependency edges.
### Level 3: Workflow abort (system-level)
For failures that should cancel everything, the hub calls `workflowRoot.abortAll()`:
```typescript
workflowRoot.abortAll(); // All non-terminal nodes → "aborted"
```
This is for system-level failures: provider outage, authentication failure, or any condition where the workflow cannot meaningfully continue regardless of branch independence.
### WorkflowErrorBoundary (coordinator-level)
The hub coordinator wraps the entire reactive execution in a `WorkflowErrorBoundary` — a conceptual boundary, not a signal:
```typescript
try {
// Drive the workflow
for (const [nodeId, preconditions, blockedByFailure] of workflowRoot.nodes) {
effect(() => { /* start calls when ready */ });
effect(() => { /* abort when blocked */ });
}
} catch (error) {
// Unhandled reactive error — signal graph inconsistency
// This shouldn't happen in normal operation
workflowRoot.abortAll();
prm.abortAll(pendingRequestIds);
}
```
The `WorkflowErrorBoundary` catches errors that escape the signal graph (e.g., a `computed` that throws, an `effect` that errors). These are catastrophic — the reactive state is inconsistent. The boundary's job is to:
1. Abort all calls via `prm.abortAll()`
2. Set all non-terminal nodes to `"aborted"` via `workflowRoot.abortAll()`
3. Dispose the reactive root
4. Log the error for diagnostics
**Error propagation summary**:
| Error type | Scope | Mechanism | Recovery |
|------------|-------|-----------|----------|
| Call failure | Single node | `status.value = "failed"` | Cascades to dependents via `blockedByFailure` |
| 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` |
| System failure | Entire workflow | `abortAll()` | All non-terminal nodes to `aborted` |
| Reactive error | Signal graph | `WorkflowErrorBoundary` catch | Abort everything, dispose, log |
## Constraints
- **Signals are in-memory** — `WorkflowReactiveRoot` state is not persisted. If the hub restarts, the reactive state is lost and must be reconstructed from call protocol events + template re-render.
@@ -453,6 +565,94 @@ This transitions all non-terminal, non-failed nodes to `aborted`. It's for cases
- **`failed` and `aborted` block preconditions** — a `failed` or `aborted` predecessor means the dependent's preconditions can never be met. The `blockedByFailure` effect transitions the dependent to `aborted`.
- **`NodeStatus` and `CallStatus` share terminal states** — `running`, `completed`, `failed`, `aborted` map directly. `idle`, `waiting`, `ready`, `skipped` are workflow-specific additions.
## Lifecycle and Ownership
The reactive execution pipeline has a clear creation order and ownership model:
### Creation Order
```
1. Template (UNode tree)
↓ GraphologyHostConfig
2. DAG (DirectedGraph)
↓ WorkflowReactiveRoot constructor
3. Signal graph (statusMap, preconditions, blockedByFailure)
↓ ReactiveHostConfig.render()
4. WorkflowNode tree (with effects registered)
```
1. **Template → DAG**: The consumer provides a template and renders it through `GraphologyHostConfig`. This produces a `DirectedGraph` stored in the `GraphContext`.
2. **DAG → Signal graph**: The consumer creates a `WorkflowReactiveRoot` from the DAG. The constructor iterates over all operation nodes in the DAG and creates `signal<NodeStatus>`, `computed<boolean>` (preconditions), and `computed<boolean>` (blockedByFailure) for each.
3. **Signal graph → WorkflowNode tree**: The consumer renders the template through `ReactiveHostConfig`. The `createInstance` call for each `Operation` node looks up the corresponding signal in the `ReactiveRoot` and wires the node's effects.
### Ownership
| Object | Owned by | Disposed by |
|--------|----------|-------------|
| Template (`UNode` tree) | Consumer | Consumer (not a reactive resource) |
| DAG (`DirectedGraph`) | GraphologyHostConfig's `GraphContext` | Consumer (static, no disposal needed) |
| `WorkflowReactiveRoot` | Consumer (typically the hub coordinator) | Consumer calls `root.dispose()` |
| Signal graph (statusMap, preconditions, etc.) | `WorkflowReactiveRoot` | `root.dispose()` clears all maps |
| `WorkflowNode` tree | `ReactiveContext` (created by ReactiveHostConfig) | Cleared when `ReactiveContext` is garbage collected |
| Effects | `WorkflowReactiveRoot.effectDisposers` | `root.dispose()` calls all disposers |
**Key ownership rules**:
- `WorkflowReactiveRoot` owns the signal graph. It creates every `signal` and `computed`, tracks every `effect` disposer, and is responsible for cleaning them all up.
- `ReactiveHostConfig` is stateless after rendering. It creates `WorkflowNode` instances and registers effects, but the effects are tracked by `WorkflowReactiveRoot`, not by the HostConfig.
- The consumer owns the `WorkflowReactiveRoot` lifecycle. It creates it, drives execution by setting status values, and disposes it when done.
### Disposal
```typescript
// When workflow completes or is cancelled:
workflowRoot.dispose();
```
`dispose()` performs the following in order:
1. Calls every `effect()` disposer, unsubscribing all reactive effects.
2. Clears `statusMap`, `preconditions`, and `blockedByFailure` maps, releasing signal references.
3. The `WorkflowNode` tree becomes inert — status signals no longer exist, so no updates propagate.
**When to dispose**:
- Workflow completes successfully (all nodes `completed`)
- Workflow is aborted (consumer calls `abortAll()`, then `dispose()`)
- Template is being re-rendered (dispose the old root before creating a new one — until ujsx reconciler supports re-rendering)
**What NOT to dispose**:
- The DAG (`DirectedGraph`) is not a reactive resource. It doesn't need disposal.
- The template (`UNode` tree) is plain data. It doesn't need disposal.
### Interaction with ReactiveHostConfig
The `ReactiveHostConfig` does NOT own the reactive state. It creates `WorkflowNode` instances during rendering, but these nodes reference signals that belong to `WorkflowReactiveRoot`. The rendering flow is:
```typescript
// 1. Create ReactiveRoot from DAG
const workflowRoot = new WorkflowReactiveRoot(dag);
// 2. Create ReactiveHostConfig with reference to ReactiveRoot's signals
const hostConfig = new ReactiveHostConfig(operationRegistry, workflowRoot);
// 3. Render template
const root = createRoot(hostConfig, {});
root.render(template);
// 4. Drive execution (hub coordinator sets status values)
workflowRoot.statusMap.get("architect")!.value = "ready";
// ... external code starts the call, eventually:
workflowRoot.statusMap.get("architect")!.value = "completed";
// ... which triggers downstream preconditions
// 5. Cleanup
workflowRoot.dispose();
```
The `ReactiveContext` passed to `ReactiveHostConfig` includes a reference to `workflowRoot.statusSignals` so that `createInstance` can look up and wire signals for each node. The context does not own these signals — it's a lookup table.
**Important**: `WorkflowNode.status` and `WorkflowReactiveRoot.statusMap.get(nodeId)` reference the **same** `Signal<NodeStatus>` instance. There is one signal per node, owned by `WorkflowReactiveRoot`, and both the `WorkflowNode` and the `statusMap` hold references to it. Setting `workflowRoot.statusMap.get("architect").value = "running"` and setting `workflowNode.status.value = "running"` (where `workflowNode.key === "architect"`) are equivalent operations on the same signal. Similarly, `WorkflowNode.preconditions` and `WorkflowReactiveRoot.preconditions.get(nodeId)` reference the **same** `Computed<boolean>` instance.
## Open Questions
1. **Should preconditions support OR logic?** Currently all predecessors must complete (AND logic). An `anyOf` predicate would allow "start this node as soon as any predecessor completes." This would require an edge attribute or node-level configuration.