Files
flowgraph/docs/architecture/host-configs.md
glm-5.1 907c33650f 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
2026-05-21 19:40:45 +00:00

468 lines
23 KiB
Markdown

---
status: reviewed
last_updated: 2026-05-22
---
# Host Configs
The two `HostConfig` implementations that render workflow templates to different targets: graphology DAG (structural analysis) and reactive execution engine (runtime status tracking).
## Overview
Flowgraph uses ujsx's `HostConfig` pattern to render the same workflow template (`UNode` tree) to different targets. Each HostConfig implements the `HostConfig<WorkflowTag, Instance, RootCtx>` interface:
| HostConfig | Target | Purpose |
|------------|--------|---------|
| GraphologyHostConfig | `DirectedGraph` | Validate templates, check cycles, compute topological order |
| ReactiveHostConfig | `Map<string, WorkflowNode>` | Runtime execution with signal-driven status propagation |
Both HostConfigs share the same template components (`Operation`, `Sequential`, `Parallel`, `Conditional`, `Map`) and the same tag type. The difference is what `createInstance` and `appendChild` do:
- **GraphologyHostConfig**: Creates graph nodes and edges. `appendChild` adds an edge.
- **ReactiveHostConfig**: Creates a `WorkflowNode` (with a `signal<NodeStatus>`) and registers preconditions. `appendChild` registers the parent-child dependency.
## WorkflowTag Type
```typescript
type WorkflowTag = "operation" | "sequential" | "parallel" | "conditional" | "map";
```
This constrains `HostConfig<TTag, ...>` to only accept workflow-specific element types. Attempting to render an unsupported tag (e.g., `"div"`) is a type error at compile time.
### Component-to-Tag Mapping
Each `UComponent` function produces a `UElement` with a specific `type` string (the `WorkflowTag`). The mapping is:
| Component function | UElement.type (WorkflowTag) |
|-------------------|---------------------------|
| `Operation` | `"operation"` |
| `Sequential` | `"sequential"` |
| `Parallel` | `"parallel"` |
| `Conditional` | `"conditional"` |
| `Map` | `"map"` |
When ujsx's reconciler calls `HostConfig.createInstance(tag, props, ...)`, the `tag` parameter is the `WorkflowTag` string. For example, `h(Operation, { name: "classify" })` produces `{ type: "operation", props: { name: "classify" }, children: [] }`, and `createInstance("operation", { name: "classify" }, ctx)` is called.
## GraphologyHostConfig
### Type Parameters
```typescript
const graphologyHost: HostConfig<WorkflowTag, GraphNode, GraphContext>
```
- **TTag**: `WorkflowTag`
- **Instance**: `GraphNode` (a logical representation of what each template node becomes in the graph)
- **RootCtx**: `GraphContext` (the root context carrying the graph and metadata)
```typescript
interface GraphNode {
key: string; // The graphology node key
attributes: OperationNodeAttrs | TemplateNodeAttrs;
}
```
Where `TemplateNodeAttrs` is a type alias for `OperationNodeAttrs` (see [schema.md](schema.md#TemplateNodeAttrs)) — template nodes carry the same attributes as operation nodes. Structural containers (`Sequential`, `Parallel`, `Conditional`, `Map`) return a `GraphNode` with an empty `attributes` object and a synthetic key.
The `RootCtx` is:
```typescript
interface GraphContext {
graph: DirectedGraph; // The graphology DAG being built
parentStack: string[]; // Stack of parent node keys for edge creation
operationRegistry?: OperationRegistry; // Optional, for name resolution
}
```
### createRootContext
```typescript
createRootContext(container, options, context): GraphContext {
const graph = new DirectedGraph({ type: "directed", multi: false, allowSelfLoops: false });
return { graph, parentStack: [], operationRegistry: options?.registry };
}
```
Creates a fresh `DirectedGraph` with DAG constraints (no self-loops, no parallel edges). The `container` parameter is unused — the graph IS the container.
### createInstance
```typescript
createInstance(tag: WorkflowTag, props, ctx: GraphContext, parent?: GraphNode): GraphNode {
switch (tag) {
case "operation": {
const key = props.name as string;
ctx.graph.addNode(key, { ...operationAttrs, name: key });
return { key, attributes };
}
case "sequential":
case "parallel":
case "conditional":
case "map":
// Structural containers — no node in the graph, just manage parentStack
return { key: `__${tag}_${counter++}`, attributes: {} };
}
}
```
`Operation` elements create real graph nodes. Structural containers (`Sequential`, `Parallel`, `Conditional`, `Map`) do NOT create graph nodes — they manage the `parentStack` to influence edge creation for their children.
This is a key design decision: **structural containers are transparent in the graph**. A `Sequential` node doesn't appear as a node in the DAG. It only affects the edges between its children.
### appendChild
```typescript
appendChild(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
// Only add edges between real nodes (not structural containers)
if (!isStructuralContainer(parent) && !isStructuralContainer(child)) {
const edgeType = inferEdgeType(ctx, parent.key, child.key);
ctx.graph.addEdgeWithKey(
`${parent.key}->${child.key}`,
parent.key,
child.key,
{ edgeType, compatible: true }
);
}
}
```
Edge creation depends on the context:
- Children of a `Sequential` container: sequential edges between consecutive siblings
- Children of a `Parallel` container: no edges between siblings
- Children of a `Conditional` container: conditional edge to the test branch
### How Sequential edges are created
The `Sequential` component doesn't create edges itself. Instead, the HostConfig tracks the `parentStack` and creates edges between consecutive siblings:
```typescript
// In the rendering of <Sequential>
// After child1 is appended: parentStack = [child1.key]
// After child2 is appended: edge child1→child2 is created, parentStack = [child2.key]
// After child3 is appended: edge child2→child3 is created, parentStack = [child3.key]
```
The `parentStack` is managed by the `Sequential` component's `finalizeInstance` hook — it pops the last child after rendering all children, replacing it with the overall group's last child.
### How Parallel handles edges
The `Parallel` component renders all children without creating inter-child edges. It pushes a "parallel group" marker onto the `parentStack` so that the group's successors connect to ALL parallel children, not just the last one.
This requires the HostConfig to understand parent-child relationships for `Parallel` groups: the group's successors should connect to each parallel child.
### finalizeInstance
```typescript
finalizeInstance?(instance: GraphNode, ctx: GraphContext): void {
// Pop the structural container from the parentStack after all children are rendered
// This is important for Sequential and Parallel to clean up their structural state
}
```
### removeChild
```typescript
removeChild(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
// Remove the edge between parent and child
// Structural containers are transparent, so parent/child are real operation nodes
if (!isStructuralContainer(parent) && !isStructuralContainer(child)) {
ctx.graph.dropEdge(`${parent.key}->${child.key}`);
}
}
```
`removeChild` is called by the ujsx reconciler when a child is removed from a parent. In the `GraphologyHostConfig`, this removes the corresponding DAG edge. The child node itself is NOT removed from the graph — node removal is handled by `removeFromGraph` (see below).
**Note**: The ujsx reconciler is not yet implemented. Currently, `removeChild` is defined but only called in tests. The `GraphologyHostConfig` is mount-only until the reconciler is available.
### removeChildFromHost (node removal)
```typescript
removeChildFromHost?(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
// Remove the child node from the graph
ctx.graph.dropNode(child.key);
}
```
When the reconciler removes a child entirely (not just moving it to a different parent), it calls `removeChildFromHost`. This removes the node and ALL attached edges (graphology cascading removal). This is important for cleanup when a template is re-rendered and a node no longer exists.
### Cycle Detection
After rendering, the HostConfig checks for cycles using `graphology-dag.hasCycle()`. If a cycle is detected, the rendering throws `CycleError` with the cycle paths.
This is the primary validation step: a valid workflow template must produce a valid DAG. Cycles in a template mean infinite loops in execution, which are always design errors.
## ReactiveHostConfig
### Type Parameters
```typescript
const reactiveHost: HostConfig<WorkflowTag, WorkflowNode, ReactiveContext>
```
- **TTag**: `WorkflowTag`
- **Instance**: `WorkflowNode` (carries a `signal<NodeStatus>` and computed preconditions)
- **RootCtx**: `ReactiveContext` (carries the operation registry and status tracking)
### WorkflowNode
```typescript
interface WorkflowNode {
key: string; // Operation name or structural container ID
type: "operation" | "sequential" | "parallel" | "conditional" | "map";
status: Signal<NodeStatus>; // Reactive status signal
preconditions: Computed<boolean>; // Computed: true when all preconditions are met
blockedByFailure: Computed<boolean>; // Computed: true when any predecessor failed/aborted (uncaught)
operationId?: string; // For operation nodes: the fully qualified ID
output?: Signal<unknown>; // For operation nodes: the call result (when completed)
children: WorkflowNode[]; // Child nodes (structural containers have children)
}
```
Each `WorkflowNode` holds:
- A `signal<NodeStatus>` that tracks the call's lifecycle (`idle``waiting``ready``running``completed`/`failed`/`aborted`/`skipped`)
- A `computed` that derives `preconditions` from parent nodes' statuses (true when all predecessors are `completed` or `skipped`)
- A `computed` that derives `blockedByFailure` from parent nodes' statuses (true when any predecessor is `failed` or `aborted`)
- An optional `output` signal that holds the call result when completed
### ReactiveContext
```typescript
interface ReactiveContext {
operationRegistry: OperationRegistry; // Resolves operation names to specs
nodes: Map<string, WorkflowNode>; // All nodes by key
statusSignals: Map<string, Signal<NodeStatus>>; // Status signals by key (owned by WorkflowReactiveRoot)
preconditions: Map<string, Computed<boolean>>; // Precondition computeds by key (owned by WorkflowReactiveRoot)
blockedByFailure: Map<string, Computed<boolean>>; // blockedByFailure computeds by key (owned by WorkflowReactiveRoot)
resultProjection: EventLogProjection; // Result projection from ADR-005 event log
parentMap: Map<string, string>; // Child → parent key mapping (for precondition computation)
siblingMap: Map<string, string[]>; // Parent → children keys (for structural context)
results: Map<string, Computed<CallResult | undefined>>; // Result computeds by key (derived from event log)
}
```
The `ReactiveContext` is constructed during `ReactiveHostConfig` initialization. It receives the `operationRegistry`, empty maps, and the `EventLogProjection` from the `WorkflowReactiveRoot`. During `createInstance`, nodes and signals are registered in the context maps. After rendering completes, the context holds a complete index of the reactive workflow tree.
**Important**: `statusSignals`, `preconditions`, and `blockedByFailure` are references to the `WorkflowReactiveRoot`'s maps. The `ReactiveHostConfig` does not own these signals — it looks them up during `createInstance` to wire `WorkflowNode` references. Disposal is the `WorkflowReactiveRoot`'s responsibility.
**Result projection (ADR-005)**: The `resultProjection` provides `getResult(nodeId)` which returns `CallResult | undefined`. This is derived from the event log, not from direct signal reads. `Conditional.test` and `Map.over` functions access predecessor results through this projection, ensuring they always see the most recent data — including retry results.
### createInstance
```typescript
createInstance(tag: WorkflowTag, props, ctx: ReactiveContext, parent?: WorkflowNode): WorkflowNode {
const key = props.key ?? generateKey();
const status = signal<NodeStatus>("idle");
const node: WorkflowNode = {
key,
type: tag,
status,
preconditions: computed(() => computePreconditions(node, ctx)),
children: [],
};
ctx.nodes.set(key, node);
ctx.statusSignals.set(key, status);
if (tag === "operation") {
node.operationId = props.name as string;
node.output = signal<unknown>(undefined);
}
return node;
}
```
### Prerequisite Computation
The `preconditions` computed signal for each node derives from its structural context:
- **Sequential child**: preconditions = previous sibling is `completed`
- **Parallel child**: preconditions = parent's preconditions are met
- **Conditional child**: preconditions = parent's preconditions are met AND condition evaluates to true
```typescript
function computePreconditions(node: WorkflowNode, ctx: ReactiveContext): boolean {
// Sequential: previous sibling must be completed
// Parallel: parent must be ready
// Conditional: condition must evaluate to true
const predecessorKeys = getPredecessorKeys(node, ctx);
return predecessorKeys.every(key => {
const status = ctx.statusSignals.get(key)?.value;
return status === "completed" || status === "skipped";
});
}
```
### Status Propagation
When a node's `status` signal changes, its dependents' `preconditions` and `blockedByFailure` computed values automatically re-evaluate. If preconditions are met, the node transitions to `ready`; if blocked by failure, it transitions to `aborted`:
```typescript
// Start when preconditions are met
effect(() => {
if (node.preconditions.value) {
if (node.status.value === "idle" || node.status.value === "waiting") {
node.status.value = "ready";
}
}
});
// Abort when a predecessor fails (uncaught failure propagation)
effect(() => {
if (node.blockedByFailure.value) {
if (node.status.value === "idle" || node.status.value === "waiting") {
node.status.value = "aborted";
}
}
});
```
The reactive engine then starts the call associated with the node (when `ready`), which sets `status` to `running`, and eventually `completed` or `failed`.
**Note**: Failure propagation follows dependency edges, not structural scope. A failed node only causes its downstream dependents (via DAG edges) to abort. Sibling branches in a `Parallel` group are independent and continue running. See [reactive-execution.md](reactive-execution.md) for the full failure propagation model.
### removeChild (ReactiveHostConfig)
```typescript
removeChild(parent: WorkflowNode, child: WorkflowNode, ctx: ReactiveContext): void {
// Remove the dependency between parent and child
// The child's preconditions are recomputed automatically (reactive)
parent.children = parent.children.filter(c => c.key !== child.key);
// The child's preconditions and blockedByFailure computeds will re-evaluate
// because the predecessor list changes
}
```
`removeChild` in the reactive host removes the parent-child dependency. Because preconditions and `blockedByFailure` are `computed` values, they automatically re-evaluate when predecessor nodes are removed.
```typescript
removeChildFromHost?(parent: WorkflowNode, child: WorkflowNode, ctx: ReactiveContext): void {
// Dispose the child's reactive state
ctx.nodes.delete(child.key);
ctx.statusSignals.delete(child.key);
ctx.preconditions.delete(child.key);
ctx.blockedByFailure.delete(child.key);
if (child.output) {
// Signal disposal is handled by WorkflowReactiveRoot.dispose()
// Here we just remove the reference from the context maps
}
}
```
For complete reactive teardown (`removeChildFromHost`), the node's signal references are removed from the context maps. The signals themselves (owned by `WorkflowReactiveRoot`) are disposed via `root.dispose()` which is the authoritative cleanup path.
**Important**: Individual node disposal (removing a node mid-execution) is not fully supported until the ujsx reconciler is implemented. Currently, the reactive tree is built once and torn down as a whole via `WorkflowReactiveRoot.dispose()`.
### Abort Cascading
System-level abort (e.g., provider outage) aborts the entire workflow:
```typescript
function abortAll(root: WorkflowReactiveRoot): void {
for (const [nodeId, status] of root.statusMap) {
if (status.value !== "completed" && status.value !== "failed") {
status.value = "aborted";
}
}
}
```
This is reactive — when a parent node's status changes to `aborted`, the `effect` on each child evaluates and cascades the abort.
## Two HostConfigs, One Template
The key insight: the same ujsx template renders to both targets:
```
ujsx Template (UNode tree)
┌─────────┴─────────┐
│ │
GraphologyHostConfig ReactiveHostConfig
│ │
▼ ▼
DirectedGraph (DAG) Reactive Signal Graph
┌──────────────────┐ ┌──────────────────┐
│ Nodes: operations │ │ Nodes: WorkflowNode│
│ Edges: sequential │ │ signal<NodeStatus>│
│ conditional │ │ computed<precond> │
│ typed │ │ computed<blocked> │
└──────────────────┘ └──────────────────┘
│ │
▼ ▼
Structural Runtime Execution
Analysis & Status Tracking &
Validation Abort Propagation
```
```typescript
const template = h(Sequential, {},
h(Operation, { name: "architect" }),
h(Operation, { name: "reviewer" }),
);
// Validate structure
const dagRoot = createRoot(graphologyHost, new DirectedGraph());
dagRoot.render(template);
dagRoot.ctx.graph.hasCycles(); // → false (valid DAG)
// Execute reactively
const reactiveRoot = createRoot(reactiveHost, { registry });
reactiveRoot.render(template);
// Each operation node now has a signal<NodeStatus>
```
No template-specific logic is needed in either HostConfig. The same `UNode` tree, the same components, the same rendering pipeline — just different `createInstance`/`appendChild` implementations.
## Known Gaps
### ujsx Reconciler Not Yet Available
The current ujsx `HostConfig` is mount-only (see [host-configs.md](../../../ujsx/docs/architecture/host-config.md)). The reconciler research (see [reconciler.md](../../../ujsx/docs/architecture/reconciler.md)) has not been implemented yet. This means:
- `render()` can only be called once per root
- No incremental template updates
- No `prepareUpdate`/`commitUpdate` flow
For flowgraph, this is acceptable in v1 because:
- Template rendering is typically done once at startup
- Runtime status updates flow through signals, not through template re-rendering
- When the reconciler is implemented, flowgraph gains incremental template updates "for free"
### Structural Container Handling
The current design where `Sequential`, `Parallel`, and `Conditional` don't create graph nodes is clean for the DAG, but creates complexity for the reactive engine — the "previous sibling" precondition depends on understanding the structural context, which isn't stored on the node itself.
Alternative: Create "virtual" nodes for structural containers that hold `signal<NodeStatus>` but don't correspond to graph nodes. This makes the reactive engine simpler (every node has a status and preconditions) at the cost of a slightly larger node tree.
### Conditional Test Evaluation
The `Conditional.test` prop can be a function or a string. At the template level, it's stored as a prop. At runtime, the reactive engine evaluates it as a `computed` that depends on referenced nodes' outputs from the **result projection** (per ADR-005). This evaluation reads from `getResult(nodeId)` which derives from the event log, ensuring that `Conditional.test` always sees the most recent state — including retry results.
## Constraints
- **Both HostConfigs share the same `WorkflowTag` type** — element types that workflow templates use. Non-workflow tags (`"div"`, `"span"`, etc.) are type errors.
- **GraphologyHostConfig produces a static DAG** — the rendered DAG is immutable after rendering. No re-rendering until the reconciler is available.
- **ReactiveHostConfig requires an operation registry** — `Operation` nodes reference operations by name, and the registry resolves them at render time.
- **Template rendering is one-shot** — until the reconciler is implemented, `createRoot(host, container).render(template)` can only be called once per root.
- **Structural containers are transparent in the DAG** — Sequential, Parallel, Conditional create edges between children, not nodes for themselves.
- **HostConfigs must follow ujsx's post-order append contract** — children are appended to parents after all descendants are created. This guarantees that edges are created bottom-up.
## Open Questions
1. ~~**Should structural containers create "virtual" nodes in the reactive engine?**~~ **Resolved (OQ-05)**: Containers stay transparent. Aggregate status for structural containers is computed as a projection from children's statuses, without requiring nodes in the event log or DAG. The `parentMap` and `siblingMap` in `ReactiveContext` provide the structural context for precondition computation.
2. ~~**Should the GraphologyHostConfig produce a separate graph for edge types?**~~ **Resolved (OQ-029)**: No — all edge types share a single graph, with `edgeType` as a universal required attribute on every edge. Separate graphs per edge type would add complexity (cross-graph traversal, cache coherence, multi-graph queries) for a marginal performance gain at current scale. Single-graph filtering by `edgeType` is O(n) on edges and negligible for expected graph sizes. If a concrete performance issue arises with very large template graphs, a `Map<EdgeType, DirectedGraph>` index can be added as an internal optimization without changing the API. See ADR-006 for the full decision on `edgeType` consistency.
3. ~~**How does the ReactiveHostConfig interact with the call protocol?**~~ **Resolved (ADR-005)**: The reactive layer bridges to the call protocol through the event log. The hub coordinator appends call protocol events; the reactive layer projects them into status and results. The `ReactiveHostConfig` reads from the `EventLogProjection` interface (via `getStatus()` and `getResult()`), not from direct signal mutations by the coordinator.
4. ~~**Should the reactive engine own the call graph?**~~ **Resolved (ADR-005)**: Neither owns the other. Both the call graph and the reactive engine are projections of the same event log. The call graph projects the structural view (who triggered whom). The reactive engine projects the behavioral view (what's running, what's blocked).
## References
- ujsx HostConfig: `@alkdev/ujsx/docs/architecture/host-config.md`
- ujsx reconciler research: `@alkdev/ujsx/docs/research/reconciler/05-flowgraph-host-configs.md`
- Workflow templates: [workflow-templates.md](workflow-templates.md)
- Reactive execution: [reactive-execution.md](reactive-execution.md)
- Schema: [schema.md](schema.md)