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
23 KiB
status, last_updated
| status | last_updated |
|---|---|
| reviewed | 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.
appendChildadds an edge. - ReactiveHostConfig: Creates a
WorkflowNode(with asignal<NodeStatus>) and registers preconditions.appendChildregisters the parent-child dependency.
WorkflowTag Type
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
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)
interface GraphNode {
key: string; // The graphology node key
attributes: OperationNodeAttrs | TemplateNodeAttrs;
}
Where TemplateNodeAttrs is a type alias for OperationNodeAttrs (see schema.md) — 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:
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
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
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
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
Sequentialcontainer: sequential edges between consecutive siblings - Children of a
Parallelcontainer: no edges between siblings - Children of a
Conditionalcontainer: 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:
// 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
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
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)
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
const reactiveHost: HostConfig<WorkflowTag, WorkflowNode, ReactiveContext>
- TTag:
WorkflowTag - Instance:
WorkflowNode(carries asignal<NodeStatus>and computed preconditions) - RootCtx:
ReactiveContext(carries the operation registry and status tracking)
WorkflowNode
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
computedthat derivespreconditionsfrom parent nodes' statuses (true when all predecessors arecompletedorskipped) - A
computedthat derivesblockedByFailurefrom parent nodes' statuses (true when any predecessor isfailedoraborted) - An optional
outputsignal that holds the call result when completed
ReactiveContext
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
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
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:
// 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 for the full failure propagation model.
removeChild (ReactiveHostConfig)
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.
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:
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
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). The reconciler research (see reconciler.md) has not been implemented yet. This means:
render()can only be called once per root- No incremental template updates
- No
prepareUpdate/commitUpdateflow
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
WorkflowTagtype — 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 —
Operationnodes 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
-
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. TheparentMapandsiblingMapinReactiveContextprovide the structural context for precondition computation. -
Should the GraphologyHostConfig produce a separate graph for edge types?Resolved (OQ-029): No — all edge types share a single graph, withedgeTypeas 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 byedgeTypeis O(n) on edges and negligible for expected graph sizes. If a concrete performance issue arises with very large template graphs, aMap<EdgeType, DirectedGraph>index can be added as an internal optimization without changing the API. See ADR-006 for the full decision onedgeTypeconsistency. -
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. TheReactiveHostConfigreads from theEventLogProjectioninterface (viagetStatus()andgetResult()), not from direct signal mutations by the coordinator. -
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
- Reactive execution: reactive-execution.md
- Schema: schema.md