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

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. appendChild adds an edge.
  • ReactiveHostConfig: Creates a WorkflowNode (with a signal<NodeStatus>) and registers preconditions. appendChild registers 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 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:

// 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 a signal<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 (idlewaitingreadyrunningcompleted/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

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/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 registryOperation 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
  • Reactive execution: reactive-execution.md
  • Schema: schema.md