Resolve the three open consequences from ADR-005 (Event Log as Single Source of Truth) and transition from Proposed to Accepted: 1. Event log IS the call protocol event stream — not a separate type, but an EventLogProjection interface (append/getStatus/getResult/ getEvents) over CallEventMapValue[] with an append-only contract. 2. Event log persists across template re-renders — projections recompute against the new DAG; orphaned events stay in log for audit but don't affect active projections. 3. Edges get dataFlow: boolean attribute on TemplateEdgeAttrs — inferred (not manual) by GraphologyHostConfig from template expressions. typeCompat() only runs on dataFlow: true edges. Inference rules are precisely specified for Conditional.test, Map.over, and Operation.input. Also resolve OQ-05 (structural containers stay transparent; aggregate status is a projection from children) and OQ-10 (running node failure is a FailurePolicy configuration, default continues-running). Cascading updates to: - reactive-execution.md: add hybrid status model (event-log-driven vs projection-driven vs signal-mutation), EventLogProjection interface, result projection respecting retries, FailurePolicy type - host-configs.md: ReactiveContext now includes resultProjection and computed results; resolved Q1/Q3/Q4 - schema.md: dataFlow attribute on TemplateEdgeAttrs with inference rules and type checking implications - workflow-templates.md: edge creation rules with dataFlow, result projection in Conditional/Map, resolved Q1/Q4 - open-questions.md: all ADR-005 questions marked resolved, updated summary table and cross-cutting themes, removed duplicate OQ-07 7 files changed, 464 insertions, 139 deletions
22 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-21 |
Workflow Templates
ujsx-based workflow definition — compose operations as declarative template trees, render them to DAGs or reactive execution engines.
Overview
Workflow templates are ujsx trees that define reusable call patterns. Instead of hardcoding operation sequences in the hub coordinator, templates provide a declarative, composable way to define "what should happen in what order":
import { h, createRoot } from "@alkdev/ujsx";
import { Operation, Sequential, Parallel, Conditional, Map } from "@alkdev/flowgraph/component";
import { GraphologyHostConfig } from "@alkdev/flowgraph/host/graphology";
const sddPipeline = h(Sequential, {},
h(Operation, { name: "architect" }),
h(Operation, { name: "architecture-reviewer" }),
h(Conditional, {
test: (results) => results["architecture-reviewer"].approved
},
h(Sequential, {},
h(Operation, { name: "decomposer" }),
h(Operation, { name: "coordinator" }),
h(Operation, { name: "specialist" }),
),
),
h(Operation, { name: "code-reviewer" }),
);
The template is a UNode tree — a plain data structure that can be:
- Serialized to JSON for storage and transmission
- Validated against the operation graph (are all referenced operations registered? are there type mismatches?)
- Rendered to a graphology DAG via the
GraphologyHostConfigfor structural analysis - Rendered to a reactive execution engine via the
ReactiveHostConfigfor runtime status tracking
This is the same UNode tree that ujsx defines, with flowgraph-specific component functions (Operation, Sequential, Parallel, Conditional, Map) that produce UElement nodes with workflow-specific props and meaning.
Why ujsx as Template IR
The alternative to ujsx would be a custom template format — an array of step objects with type discriminators:
// Alternative: custom template format
const template = [
{ type: "operation", name: "architect" },
{ type: "sequential", steps: [
{ type: "operation", name: "decomposer" },
{ type: "operation", name: "coordinator" },
]},
];
ujsx is better for several reasons:
-
Composability — Nested elements are the natural representation of hierarchical workflows.
Sequential({ children: [...] })is cleaner than a recursive type discriminator. -
No new format — ujsx already defines the tree structure, type guards, reactive layer, and reconciler. We don't need to design, implement, and maintain a template parser/serializer.
-
Host target switching — The
HostConfigpattern means the same template renders to different targets without template-specific logic. Graphology for analysis, reactive engine for runtime. No template→IR→DAG compilation step. -
Incremental updates — The ujsx reconciler enables incremental template updates. Add a step, remove a step, reorder steps — the reconciler computes the diff and applies minimal mutations to the DAG, rather than rebuilding the entire graph.
-
Reactive props —
@preact/signals-coreenables signal-driven prop updates. AnOperationnode'snamecould be a signal, enabling dynamic workflow modification at runtime.
See ADR-001 for the full decision record.
Component Definitions
<Operation>
Represents a single operation invocation in the workflow:
const Operation: UComponent<{
name: string; // Operation name (namespace.name or just name if namespace is inherited)
input?: unknown; // Static input for the call
retries?: number; // Number of retries on failure (default: 0)
timeout?: number; // Deadline in ms from call start
}>;
Operation produces a UElement with type: "operation" and the given props. When rendered to a graphology DAG, it creates a node with the operation's attributes. When rendered to the reactive engine, it creates a signal<NodeStatus> that tracks the call's lifecycle.
<Sequential>
Represents sequential execution — children run in order, each child waits for the previous to complete:
const Sequential: UComponent<{
id?: string; // Optional identifier for the sequence
}>;
Sequential children are rendered in order. In the graphology DAG, each child has a sequential edge to the next child. In the reactive engine, each child's precondition is "previous child is completed".
<Parallel>
Represents parallel execution — all children start simultaneously:
const Parallel: UComponent<{
id?: string; // Optional identifier for the parallel group
maxConcurrency?: number; // Maximum concurrent children (default: unlimited)
}>;
Parallel children have no ordering edges between them. In the reactive engine, all children's preconditions are "parent's prerequisites are met", so they all become ready at the same time.
maxConcurrency is a runtime hint, not a structural constraint. The DAG doesn't encode it — it's a scheduling hint for the execution engine.
<Conditional>
Represents conditional branching — children only execute if the test passes:
const Conditional: UComponent<{
test: ((results: Record<string, CallResult>) => boolean) | string;
// If string: operation name whose result to check
// If function: receives results of previous steps, returns boolean
else?: UNode; // Alternative branch if test fails
}>;
When rendered to a graphology DAG, Conditional creates an edge with edgeType: "conditional" and dataFlow: true (conditional edges always carry data — the test reads a predecessor's result). When rendered to the reactive engine, the condition is evaluated as a computed that depends on the result projection (from the event log per ADR-005).
If the test evaluates to false and no else branch is provided, the branch nodes transition to skipped in NodeStatus.
Else-branch behavior
When the else prop is provided, the Conditional renders two subgraphs:
DAG rendering (GraphologyHostConfig):
- The
thenbranch (child) renders with an edge from the conditional's predecessor to the first child, withedgeType: "conditional"andcondition: <test>. - The
elsebranch renders as a separate subgraph withedgeType: "conditional"andcondition: <negated test>. The negated condition is derived automatically. - Both branches share the same predecessor — the
Conditionalnode's structural position in the template determines the common starting point.
Reactive rendering (ReactiveHostConfig):
- When
testevaluates totrue:then-branch nodes becomeready(preconditions met).else-branch nodes transition toskipped. Theirpreconditionsare satisfied by theskippedstate — downstream nodes see theConditionalas completed regardless of which branch was taken. - When
testevaluates tofalse:else-branch nodes becomeready.then-branch nodes transition toskipped. Downstream nodes after theConditionalsee all branches as resolved. - When no
elseprop is provided: thefalsebranch simply doesn't exist. Nodes after theConditionalthat depend on it still see it ascompletedbecause theConditionalitself resolves regardless of which path is taken. - The
testfunction receives its data from the result projection (ADR-005).results["nodeName"]reads fromgetResult("nodeName"), which derives from the event log. This ensures retries are reflected — if a node is retried, its result updates when the retry'scall.respondedevent arrives.
This means a Conditional with an else branch acts as a complete error boundary — downstream nodes are insulated from the branch choice. The Conditional is completed whether the then or else branch executed.
<Map>
Represents mapping over an array — creates one child instance per array item:
const Map: UComponent<{
over: Signal<unknown[]> | unknown[] | ((results: Record<string, CallResult>) => unknown[]);
// Static array, signal, or function that resolves against predecessor results
as: string; // Variable name for each item
children: UNode; // Template rendered per item
}>;
The <Map> component dynamically replicates its child template for each element in the over array. Each replica gets the current element bound to the variable named by as.
DAG rendering (GraphologyHostConfig):
- For each item in
over, renders a copy of the child template as a node. - Each mapped node has a
sequentialedge from theMap's predecessor (all mapped nodes start at the same point, likeParallel). - Mapped nodes are named with a composite key:
${parentKey}.${as}[${index}]. For example,<Map over={items} as="item">with 3 items creates nodesitem[0],item[1],item[2]. - The
Mapcontainer itself is transparent in the graph (no node for the container).
Reactive rendering (ReactiveHostConfig):
- For each item in
over, creates aWorkflowNodewith its ownsignal<NodeStatus>andcomputedpreconditions. - All mapped nodes' preconditions are identical: the
Map's predecessor must becompleted(same asParallel). - Each mapped node's result is available from the result projection (ADR-005).
getResult(nodeKey)derives from the event log. - The
Mapresult is available as an aggregated computed containing all mapped nodes' results from the result projection.
Example:
h(Sequential, {},
h(Operation, { name: "fetch-items" }),
h(Map, {
over: (results) => results["fetch-items"].output.items,
as: "item"
},
h(Operation, { name: "process-item", input: (results, { item }) => item }),
),
)
This creates a Sequential where process-item is called once per item returned by fetch-items. Each call gets its corresponding item as input.
Edge type: Mapped children use the same TemplateEdgeAttrs as Parallel children (no sequential edges between siblings). The Map component is structurally equivalent to a Parallel group where the children are dynamically generated from an array.
Aggregate completion semantics: A Map node's status follows "worst-case" semantics:
- If all mapped nodes reach a satisfying terminal state (
completedorskipped), theMapis consideredcompleted. - If any mapped node reaches
failed, theMapis consideredfailed(unless caught by aConditional). - If any mapped node reaches
aborted, theMapis consideredaborted. - Downstream nodes whose preconditions include the
Mapwill seeblockedByFailure = trueif theMaphas anyfailedorabortedchildren.
This means a Map with partial failure (some nodes succeeded, one failed) propagates as a failure to downstream dependents. If partial success is needed, the template author should use a Conditional to handle the failure case, or process the Map results individually rather than treating the Map as a single dependency.
Reactive behavior for mapped nodes:
- All mapped nodes become
readysimultaneously when theMap's predecessor completes (parallel start). - If any mapped node fails, only that node transitions to
failed. Other mapped nodes continue independently (failure follows dependency edges, not structural scope, consistent withParallelbehavior). - Mapped nodes participate in failure propagation like any other node: downstream dependents see
blockedByFailureif a mapped node fails and they depend on it.
Template → DAG Conversion
The GraphologyHostConfig renders a template to a graphology DAG:
import { createRoot } from "@alkdev/ujsx";
import { GraphologyHostConfig } from "@alkdev/flowgraph/host/graphology";
const host = new GraphologyHostConfig();
const root = createRoot(host, new DirectedGraph());
const template = h(Sequential, {},
h(Operation, { name: "architect" }),
h(Operation, { name: "reviewer" }),
h(Operation, { name: "decomposer" }),
);
root.render(template);
// Now root.ctx is a DirectedGraph with:
// - nodes: "architect", "reviewer", "decomposer"
// - edges: "architect" → "reviewer" → "decomposer" (sequential)
The HostConfig maps ujsx component types to graphology operations:
| UElement type | Graphology operation |
|---|---|
"operation" |
Add node with OperationNodeAttrs |
"sequential" |
Add sequential edges between consecutive children |
"parallel" |
No edges between children (they run concurrently) |
"conditional" |
Add conditional edge with test attribute |
Edge creation rules
- Sequential: For children C1, C2, ..., Cn, edges C1→C2, C2→C3, ..., C(n-1)→Cn are added. Each edge carries
edgeType: "sequential". If the downstream node references the upstream node's result (viaConditional.test,Map.over, orOperation.input), the edge also carriesdataFlow: true. Otherwise,dataFlow: false(temporal ordering only). - Parallel: No edges between children. All children have the same preconditions as the parallel group itself.
- Conditional: Edge from the conditional node's prerequisite to the first child of the branch, with
edgeType: "conditional"anddataFlow: true(conditional edges always carry data — the condition reads a predecessor's result). - Nested: A
Sequentialinside aParallelhas its own internal edges. AParallelinside aSequentialcreates a subgraph where all parallel children share the same predecessor.
Root node handling
The template's root URoot is transparent — its children are mounted directly into the graph. Sequential and Parallel component functions are also transparent in terms of graph structure — they produce edges between their children, but do not create nodes for themselves.
This means a template like:
h(Sequential, {},
h(Operation, { name: "A" }),
h(Parallel, {},
h(Operation, { name: "B" }),
h(Operation, { name: "C" }),
),
h(Operation, { name: "D" }),
);
Produces a DAG with nodes A, B, C, D and edges A→B, A→C, B→D, C→D. No "parallel" or "sequential" nodes.
Template → Reactive Execution
The ReactiveHostConfig renders a template to a reactive execution engine:
import { createRoot } from "@alkdev/ujsx";
import { ReactiveHostConfig } from "@alkdev/flowgraph/host/reactive";
const host = new ReactiveHostConfig(operationRegistry, workflowRoot);
const root = createRoot(host, {});
const template = h(Sequential, {},
h(Operation, { name: "architect" }),
h(Operation, { name: "reviewer" }),
);
root.render(template);
// Now each operation node has a signal<NodeStatus>:
// - "architect": signal("idle")
// - "reviewer": signal("idle")
// The reviewer's precondition is: architect.status === "completed"
See reactive-execution.md for the full reactive execution architecture.
Serialization
Since workflow templates are ujsx UNode trees, they are JSON-serializable by design:
import { Value } from "@alkdev/typebox/value";
import { UJSX } from "@alkdev/ujsx";
const template = h(Sequential, {},
h(Operation, { name: "architect" }),
h(Operation, { name: "reviewer" }),
);
// Serialize
const json = JSON.stringify(template);
// → {"type":"sequential","props":{"name":"sequential"},"children":[...]}
// Deserialize
const parsed = JSON.parse(json);
if (Value.Check(UJSX.Import("UElement"), parsed)) {
// Valid UElement — can render to any HostConfig
}
Note: function-valued props (like Conditional.test with a function) are not serializable. For storage, conditional tests must be expressed as strings (operation references) rather than functions. The HostConfig resolves string references to functions at render time.
Validation
A workflow template can be validated against an operation graph before execution:
function validateTemplate(
template: UNode,
operationGraph: FlowGraph<OperationNodeAttrs, OperationEdgeAttrs>,
): ValidationError[]
Validation checks:
- All operation names exist in the registry — every
<Operation name="X">must have a matching node in the operation graph - Type compatibility — sequential operations have type-compatible edges in the operation graph
- No cycles — the rendered DAG has no cycles (inherited from FlowGraph's DAG enforcement)
- Reachability from start — all operations in the template are reachable from the first operation
Validation returns an array of ValidationError objects (never throws). See analysis.md for the full validation algorithm.
Composition Rules
Not all component combinations are valid. The following rules govern which components can appear as children of which:
| Parent | Valid children | Notes |
|---|---|---|
Sequential |
Operation, Sequential, Parallel, Conditional, Map |
Children execute in order |
Parallel |
Operation, Sequential, Parallel, Conditional, Map |
Children execute concurrently |
Conditional (then) |
Operation, Sequential, Parallel, Map |
Single child or wrapped in structural container |
Conditional (else) |
Operation, Sequential, Parallel, Map |
Single child or wrapped in structural container |
Map |
Operation, Sequential, Parallel, Conditional |
Template rendered per item |
Rules
-
Operationhas no children — anOperationis a leaf node. Nesting insideOperationis a template validation error. -
Conditionaltakes a single then-child via children, and optional else viaelseprop — thechildrenofConditionalare the then-branch. Theelseprop is the alternative branch. Both branches can be singleOperationnodes or structural containers (Sequential,Parallel,Map). -
Conditional.testcannot reference anOperationinside the Conditional — the test evaluates results from predecessor operations, not from the conditional branch itself. This would create a circular dependency. -
Map.overmust be a serializable expression or signal — the array can be a static value, a signal, or a function that receives results from predecessor operations. Function-valuedoverprops don't survive JSON round-trips (same limitation asConditional.test). -
Sequentialwith one child is valid but degenerate — it produces no edges (no sequential ordering needed). A single-childSequentialis equivalent to the child alone. -
Parallelwith one child is valid but degenerate — it produces no edges (no concurrency needed). A single-childParallelis equivalent to the child alone. -
Nesting is allowed to any depth —
SequentialinsideParallelinsideSequentialis valid. The DAG flattens nesting into edges between leafOperationnodes. -
Template root must be a structural container — the root element must be
Sequential,Parallel, orMap. A bareOperationas root is technically valid but produces a single-node DAG with no edges.
Constraints
- Templates are ujsx trees — no custom format, no parser, no compiler. Components are
UComponentfunctions that produceUElementnodes. Operationprops are workflow metadata —name,input,retries,timeoutare NOT passed to the HostConfig'screateInstance. They're workflow-level configuration that the reactive execution engine uses to configure the call.- Function props are not serializable —
Conditional.testwith a function cannot be round-tripped through JSON. Use string references for stored templates. - Sequential ordering is structural, not temporal — a
Sequentialgroup means "these operations should complete in order", not "start the next only after the previous completes" (though the reactive engine implements this via preconditions). - Parallel has no structural edges — a
Parallelgroup produces no DAG edges between its children. The execution engine starts them concurrently when the group's preconditions are met. - Conditional branches are either/or — a
Conditionalnode renders to one branch or theelsebranch. There's no "both" evaluation.
Open Questions
-
ShouldResolved (OQ-05): Containers stay transparent. No nodes forSequentialandParallelbe transparent in the graph?Sequential,Parallel, orConditionalin the DAG. Aggregate status for containers is computed as a projection from children's statuses. TheparentMapandsiblingMapinReactiveContextprovide the structural context for precondition computation. -
Should templates support loops?Resolved: The<Map>component provides array iteration — one child per array element. It does NOT support general loops (while, do-while). For repeated execution with conditional exit, useConditionalinside aSequentialgroup. General-purpose loops with arbitrary termination conditions are not supported because they would require cycle-supporting templates, which conflicts with the DAG-only invariant. -
Should templates support
depends_onedges explicitly? Currently dependencies are inferred from structure (sequential implies dependency). An explicit<DependsOn target="operation-name" />component would make data dependencies visible in the template without relying on sequential ordering. With ADR-005'sdataFlowattribute, data dependencies are now inferable from template expressions —Conditional.testandMap.overthat reference predecessor results setdataFlow: trueon the corresponding edge. Explicitdepends_onedges would add manual annotation capability, but thedataFlowinference may be sufficient for v1. -
How does template instantiation interact with the call protocol?Resolved (ADR-005): The template bridges to the call protocol through the event log. The hub coordinator appends call protocol events; the reactive layer projects them. Each<Operation>node'srequestIdmaps to call protocol events via thenodeKeyToRequestIdmap. No callback, no boomerang — the event log is the bridge.
References
- ujsx architecture:
@alkdev/ujsx/docs/architecture/ - ujsx HostConfig:
@alkdev/ujsx/docs/architecture/host-config.md - ujsx reactive layer:
@alkdev/ujsx/docs/architecture/reactive-layer.md - Host configs: host-configs.md
- Reactive execution: reactive-execution.md
- Analysis and validation: analysis.md