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
24 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-21 |
Schema
TypeBox Module, TypeScript types, categorical enums, node/edge attribute schemas, and the design decisions behind them.
Overview
Flowgraph's schema layer follows the same pattern as taskgraph: TypeBox schemas are the single source of truth for both runtime validation and TypeScript type derivation. All data shapes are defined as TypeBox schemas, with Static<typeof Schema> producing the corresponding TypeScript types.
The schema is organized around two distinct graph types (operation graph and call graph) plus shared enums and the serialized graph factory.
Design Decision: TypeBox as Single Source of Truth
Identical to taskgraph's approach:
- Static TypeScript types via
Static<typeof Schema>— every schema constant has a correspondingtype X = Static<typeof X>alias - Runtime validation via
Value.Check()/Value.Errors()— structured field-level error reporting - JSON Schema export for consumers that need schema-based contracts
No separate interface or type definitions outside of Static<typeof>. No Zod.
Naming Convention
| Category | Convention | Example |
|---|---|---|
| Enum schema constant | PascalCase + Enum suffix |
CallStatusEnum |
| Enum type alias | PascalCase, no suffix | type CallStatus = Static<typeof CallStatusEnum> |
| Object schema constant | PascalCase, no suffix | OperationNodeAttrs, CallNodeAttrs |
| Object type alias | Same name as schema constant | type OperationNodeAttrs = Static<typeof OperationNodeAttrs> |
| Graph attribute schemas | PascalCase + suffix |
FlowGraphSerialized, OperationGraphSerialized |
| Factory function | PascalCase | SerializedGraph(NodeAttrs, EdgeAttrs, GraphAttrs) |
Nullable Helper
Same Nullable helper as taskgraph:
const Nullable = <T extends TSchema>(schema: T) => Type.Union([schema, Type.Null()]);
Used for fields that can be explicitly set to null (distinct from absent).
Enums
CallStatus
The lifecycle states of a call invocation. Matches the call graph storage schema in @alkdev/alkhub_ts/docs/architecture/storage/call-graph.md.
const CallStatusEnum = Type.Union([
Type.Literal("pending"), // Call requested, not yet dispatched
Type.Literal("running"), // Handler executing
Type.Literal("completed"), // Successfully finished (call.responded + call.completed)
Type.Literal("failed"), // Handler threw or call.error emitted
Type.Literal("aborted"), // Call.aborted emitted (parent cancelled, deadline exceeded)
]);
type CallStatus = Static<typeof CallStatusEnum>;
Transitions:
pending → running → completed
→ failed
→ aborted
pending → running: Handler starts executingrunning → completed:call.responded+call.completedreceivedrunning → failed:call.errorreceivedpending → aborted:call.abortedreceived before handler started (e.g., deadline exceeded)running → aborted:call.abortedreceived during execution (parent cancelled)
completed, failed, and aborted are terminal states — no further transitions.
NodeStatus
A derived status for workflow template nodes. While CallStatus tracks individual call invocations, NodeStatus reflects the template-level view:
const NodeStatusEnum = Type.Union([
Type.Literal("idle"), // Not started, no call yet
Type.Literal("waiting"), // Preconditions not met, waiting for upstream
Type.Literal("ready"), // Preconditions met, eligible to start
Type.Literal("running"), // Call in progress
Type.Literal("completed"), // Call completed successfully
Type.Literal("failed"), // Call failed
Type.Literal("skipped"), // Conditional branch not taken
Type.Literal("aborted"), // Call aborted
]);
type NodeStatus = Static<typeof NodeStatusEnum>;
NodeStatus extends CallStatus with workflow-specific states (idle, waiting, ready, skipped) that have no call protocol equivalent. A node that is waiting has no call yet because its preconditions haven't been met.
Precondition semantics: A predecessor in completed or skipped status satisfies a dependent's preconditions. A predecessor in failed or aborted status does NOT satisfy preconditions — it blocks the dependent and triggers failure propagation (the dependent transitions to aborted). This enables partial success: independent parallel branches continue running even when one branch fails.
CallResult
The result of a completed call, used by Conditional.test and Map.over to access predecessor outputs:
interface CallResult {
status: NodeStatus; // Status of the call (completed, failed, aborted, skipped)
output: unknown; // Call output (if completed)
error?: { // Call error (if failed)
code: string;
message: string;
details?: unknown;
};
}
CallResult is the value in the results map passed to Conditional.test and Map.over functions. It's derived from CallNodeAttrs but simplified for template use — it omits requestId, operationId, identity, and timestamps, preserving only what template logic needs.
OperationTypeEnum
The type of an operation, determining its call semantics:
const OperationTypeEnum = Type.Union([
Type.Literal("query"), // Read-only, idempotent
Type.Literal("mutation"), // Side effects, not idempotent
Type.Literal("subscription"), // Streaming, produces multiple results
]);
type OperationType = Static<typeof OperationTypeEnum>;
This enum is used in OperationNodeAttrs.type to classify operations by their call behavior.
CallEventMapValue
CallEventMapValue is imported from @alkdev/operations (peer dependency). It represents a single call protocol event — the union type of all event types (CallRequestedEvent | CallRespondedEvent | CallErrorEvent | CallAbortedEvent | CallCompletedEvent). The full definition lives in @alkdev/operations/src/call.ts.
Flowgraph's fromCallEvents() and updateFromEvent() accept this type directly. The mapping from CallEventMapValue to CallNodeAttrs is:
| Event type | Action |
|---|---|
call.requested |
Add node with status: "pending", add triggered edge if parentRequestId present |
call.responded |
Update node status to completed, set output and completedAt |
call.error |
Update node status to failed, set error and completedAt |
call.aborted |
Update node status to aborted, set completedAt |
call.completed |
Update node status to completed, set completedAt (if not already set) |
EdgeType
The type of edge in a flowgraph. Matches the call graph storage schema's edgeType column. This is a universal enum that covers all graph modes (operation, call, template), but each graph mode uses only a subset:
const EdgeTypeEnum = Type.Union([
Type.Literal("triggered"), // Source caused target to execute (parent→child in call hierarchy)
Type.Literal("depends_on"), // Source requires target's result before it can complete (data dependency)
Type.Literal("typed"), // Type compatibility edge (output schema A → input schema B)
Type.Literal("sequential"), // Sequential flow edge (template: <Sequential> ordering)
Type.Literal("conditional"), // Conditional flow edge (template: <Conditional> branch)
]);
type EdgeType = Static<typeof EdgeTypeEnum>;
| Edge Type | Graph Mode | Meaning |
|---|---|---|
triggered |
Call graph | Parent call triggered child call. Corresponds to parentRequestId. |
depends_on |
Call graph | Data dependency — source needs target's result. |
typed |
Operation graph | Type compatibility — source's output schema is compatible with target's input schema. |
sequential |
Template DAG | Sequential ordering from <Sequential> component. |
conditional |
Template DAG | Conditional branch from <Conditional> component. |
EdgeTypeEnum is the universal enumeration. Each graph mode constrains its edge types through its specific edge attribute schemas:
- Operation graphs only use
typededges (OperationEdgeAttrs) - Call graphs use
triggeredanddepends_onedges (CallEdgeAttrs) - Template DAGs use
sequentialandconditionaledges (TemplateEdgeAttrs)
Node Attribute Schemas
OperationNodeAttrs
Attributes for nodes in the operation graph. Derived from OperationSpec but carrying only graph-relevant data:
const OperationNodeAttrs = Type.Object({
name: Type.String(), // Operation name (e.g., "classify")
namespace: Type.String(), // Namespace (e.g., "task")
version: Type.String(), // Semantic version
type: OperationTypeEnum, // "query" | "mutation" | "subscription"
inputSchema: Type.Unknown(), // JSON Schema for input (TypeBox schema)
outputSchema: Type.Unknown(), // JSON Schema for output (TypeBox schema)
description: Type.Optional(Type.String()),
tags: Type.Optional(Type.Array(Type.String())),
});
type OperationNodeAttrs = Static<typeof OperationNodeAttrs>;
The node key is namespace.name (e.g., "task.classify"), matching the operationId format used in the call protocol. The full OperationSpec is not stored on the graph — accessControl, errorSchemas, and handler belong to the registry, not the graph.
Why inputSchema and outputSchema on the graph: These are needed for type-compatibility edge construction. An edge from operation A to operation B exists if A's outputSchema is compatible with B's inputSchema. Storing the schemas on the node avoids a round-trip to the registry during graph queries.
CallNodeAttrs
Attributes for nodes in the call graph. Populated from call events:
const CallNodeAttrs = Type.Object({
requestId: Type.String(), // Unique call identifier
operationId: Type.String(), // namespace.name of the operation
status: CallStatusEnum, // Current call status
parentRequestId: Type.Optional(Type.String()), // Parent call (null = top-level)
input: Type.Unknown(), // Call input
output: Type.Optional(Type.Unknown()), // Call output (on completion)
error: Type.Optional(Type.Object({ // Call error (on failure)
code: Type.String(),
message: Type.String(),
details: Type.Optional(Type.Unknown()),
})),
identity: Type.Optional(Type.Object({ // Caller identity
id: Type.String(),
scopes: Type.Array(Type.String()),
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
})),
startedAt: Type.Optional(Type.String()), // ISO timestamp when call was dispatched
completedAt: Type.Optional(Type.String()), // ISO timestamp when call completed/failed/aborted
});
type CallNodeAttrs = Static<typeof CallNodeAttrs>;
The node key is requestId. This matches the call protocol's correlation mechanism and the call graph storage schema.
Why ISO timestamps as strings: Following the call protocol, timestamps are ISO 8601 strings rather than numbers. This makes the graph directly serializable to JSON without transformation and aligns with the storage schema's timestamp with tz columns.
Why parentRequestId is both a node attribute and an edge: Following the same denormalization pattern as the storage schema — parentRequestId on the node enables fast point lookups ("who is this call's parent?"), while triggered edges enable traversal queries. Both are kept consistent by construction.
Edge Attribute Schemas
OperationEdgeAttrs (Operation Graph)
const OperationEdgeAttrs = Type.Object({
compatible: Type.Boolean({ description: "Whether the source output schema is compatible with the target input schema" }),
detail: Type.Optional(Type.String({ description: "Human-readable description of compatibility or mismatch" })),
mismatches: Type.Optional(Type.Array(Type.Object({ // Structured mismatch details (populated when compatible: false)
path: Type.String(),
expected: Type.String(),
actual: Type.String(),
}))),
});
type OperationEdgeAttrs = Static<typeof OperationEdgeAttrs>;
Type-compatibility edges carry a boolean compatible flag, an optional detail string, and optional structured mismatches. This allows the operation graph to include both compatible edges (green paths) and incompatible edges (red paths) for diagnostics. The detail field provides a human-readable summary, while mismatches provides machine-readable field-level diagnostics. The TypeCompatResult from typeCompat() populates both fields: detail for compatible edges and mismatches for incompatible ones.
Edge type storage: Operation graph edges always have edgeType: "typed" stored on the edge as a separate attribute alongside OperationEdgeAttrs. Graphology edges carry both the OperationEdgeAttrs (compatible, detail, mismatches) and the edgeType field. The edgeType is not inside OperationEdgeAttrs because it's a universal edge classification that applies to all edge types across all graph modes (operation, call, template). The OperationEdgeAttrs schema only defines the mode-specific attributes.
// How operation graph edges are stored in graphology:
{
edgeType: "typed", // Universal classification (stored alongside attrs)
compatible: true, // OperationEdgeAttrs field
detail: "classify.output → enrich.input", // OperationEdgeAttrs field
mismatches: [] // Empty when compatible
}
Naming note: Previously named TypedEdgeAttrs. Renamed to follow the {GraphType}EdgeAttrs pattern used by CallEdgeAttrs and TemplateEdgeAttrs.
TriggeredEdgeAttrs (Call Graph)
const TriggeredEdgeAttrs = Type.Object({});
type TriggeredEdgeAttrs = Static<typeof TriggeredEdgeAttrs>;
Parent-child edges in the call graph carry no additional attributes — the relationship is fully captured by the edge direction and type. This may be extended in the future with latency or metadata attributes.
DependencyEdgeAttrs (Call Graph)
const DependencyEdgeAttrs = Type.Object({});
type DependencyEdgeAttrs = Static<typeof DependencyEdgeAttrs>;
Data dependency edges also carry no additional attributes. Future extensions may include dataPath (which field of the output feeds which field of the input).
CallEdgeAttrs (Call Graph Union)
type CallEdgeAttrs = TriggeredEdgeAttrs | DependencyEdgeAttrs;
A union type used as the edge attribute type parameter for call graphs (FlowGraph<CallNodeAttrs, CallEdgeAttrs>). Call graph edges can be either triggered (parent-child) or depends_on (data dependency), distinguished by their edge type. The union type follows the {GraphType}EdgeAttrs naming pattern consistent with OperationEdgeAttrs and TemplateEdgeAttrs.
TemplateEdgeAttrs (Workflow Templates)
const TemplateEdgeAttrs = Type.Object({
edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]),
condition: Type.Optional(Type.Unknown()), // For conditional edges: the condition function or expression
dataFlow: Type.Optional(Type.Boolean({ default: false, description: "Whether this edge carries data (state transfer) or only ordering (temporal notification)" })),
});
type TemplateEdgeAttrs = Static<typeof TemplateEdgeAttrs>;
Template edges carry an edgeType to distinguish sequential flow from conditional branching. Conditional edges optionally store a condition that determines whether the target node executes.
dataFlow attribute (ADR-005): Distinguishes temporal-only edges from state-transfer edges. This attribute is critical for type compatibility checking:
dataFlow: false(default): The edge expresses temporal ordering only — the downstream node starts after the upstream node completes, but doesn't read the upstream node's output. No type compatibility check is needed.dataFlow: true: The edge carries data — the downstream node reads the upstream node's output viaConditional.test,Map.over, orOperation.input. Type compatibility checking (typeCompat()) should verify that the upstream output schema is compatible with the downstream input schema.
The dataFlow attribute is inferred by the GraphologyHostConfig during template rendering, not manually specified by template authors:
- A
Sequentialedge where the downstream node referencesresults["upstreamNode"]in any expression getsdataFlow: true - A
Sequentialedge where no such reference exists getsdataFlow: false(the default) - A
Conditionaledge always getsdataFlow: true(the condition always reads a predecessor's result)
This resolves OQ-01 and OQ-02: typeCompat() only runs on edges where dataFlow: true. Temporal-only edges bypass type checking entirely, since no data flows between the connected nodes.
Note: TemplateEdgeAttrs.edgeType uses a constrained union of "sequential" | "conditional" rather than the full EdgeTypeEnum. Template DAGs never have triggered, depends_on, or typed edges — those belong to call graphs and operation graphs respectively.
TemplateNodeAttrs (Workflow Templates)
Template DAGs use OperationNodeAttrs for their operation nodes — the template doesn't need a separate node type because every node in a template DAG corresponds to an operation invocation. The template's structural information (Sequential, Parallel, Conditional, Map) is expressed through edges, not through special node types.
// Template DAGs use OperationNodeAttrs for operation nodes
type TemplateNodeAttrs = OperationNodeAttrs;
// This alias makes the intent explicit: a template node represents an operation invocation
The separation between OperationNodeAttrs and TemplateNodeAttrs is a type alias for clarity. In the template context, each node carries the same attributes as an operation node (name, namespace, type, input/output schemas), but with template-specific edges (sequential, conditional) rather than type-compatibility edges (typed).
SerializedGraph Factory
Following the taskgraph pattern, a generic factory for graphology native JSON format:
const SerializedGraph = <N extends TSchema, E extends TSchema, G extends TSchema>(
NodeAttrs: N,
EdgeAttrs: E,
GraphAttrs: G,
) =>
Type.Object({
attributes: GraphAttrs,
options: Type.Object({
type: Type.Literal("directed"),
multi: Type.Literal(false),
allowSelfLoops: Type.Literal(false),
}),
nodes: Type.Array(Type.Object({
key: Type.String(),
attributes: NodeAttrs,
})),
edges: Type.Array(Type.Object({
key: Type.String(),
source: Type.String(),
target: Type.String(),
attributes: EdgeAttrs,
})),
});
multi: false: Flowgraph edges are unique per (source, target, edgeType) triple. No parallel edges between the same node pair with the same type.
allowSelfLoops: false: Operations and calls cannot be their own prerequisite. Self-loops are rejected at construction time.
type: "directed": All edges have direction. A → B means A is prerequisite/source, B is dependent/target. This matches the graphology convention and the call graph storage schema.
FlowGraphSerialized variants
Two specialized serialization types, one for each graph type:
const OperationGraphSerialized = SerializedGraph(
OperationNodeAttrs,
OperationEdgeAttrs,
Type.Object({}), // No graph-level attributes
);
const CallGraphSerialized = SerializedGraph(
CallNodeAttrs,
CallEdgeAttrs,
Type.Object({}), // No graph-level attributes
);
For call graphs, edges can be either triggered or depends_on, distinguished by their attributes rather than separate schemas.
Edge Key Convention
Following taskgraph's ADR-006, edge keys are deterministic:
${source}->${target}
For the operation graph, this means keys like "task.classify->task.enrich". For the call graph, keys like "req_abc123->req_def456".
When multiple edge types exist between the same (source, target) pair (e.g., in the call graph where both triggered and depends_on edges can connect the same calls), a composite key format is used:
${source}->${target}:${edgeType}
For example, a depends_on edge in the call graph uses "req_abc123->req_def456:depends_on" while the triggered edge between the same pair uses "req_abc123->req_def456".
Since multi: false, there can be at most one edge per key. The composite key format ensures deterministic keys even when multiple edge types connect the same pair.
This is an exception to the simple ${source}->${target} pattern, but it's necessary for the call graph's dual-edge-type scenario. If multi-edge support becomes more broadly needed, the constraint can be relaxed and a uniform composite key format adopted.
Constraints
- TypeBox schemas are the single source of truth — no hand-written
interfaceortypedefinitions for data shapes. All types are derived viaStatic<typeof Schema>. - Edge keys are deterministic —
${source}->${target}format, following ADR-006 in taskgraph. - No parallel edges —
multi: falsein graphology. At most one edge per (source, target) pair. - No self-loops —
allowSelfLoops: false. An operation cannot be its own prerequisite. - ISO timestamp strings — Call graph timestamps are ISO 8601 strings, matching the storage schema.
- Nullable categorical fields — Following taskgraph's convention,
Type.Optional(Nullable(Enum))for optional fields that can be explicitly null. inputSchemaandoutputSchemaon operation nodes — These are TypeBox schemas (unknown at the graph level), stored for type-compatibility checking. The graph does not validate these schemas — it stores them and makes them available for thetypeCompatanalysis function.- No schema version field — Following taskgraph, the serialized format does not include a version field. It follows graphology's native JSON format and is not a persistence format with backward-compatibility guarantees. Consumers that need persistence wrap it in their own versioned envelope.
Open Questions
-
Should
edgeTypebe a required field on ALL edges, or only on call graph and template edges? Operation graph edges are alwaystyped, so requiring an explicitedgeTypeattribute there is redundant. Options: (a) makeedgeTyperequired on all edges, (b) have separate edge attribute types per graph mode, (c) use a union type on edge attributes and let the consumer tag the edge. -
Should
CallNodeAttrs.identitybe aType.Recordor the structuredIdentitytype from operations? The structured type matches the call protocol and storage schema but creates a dependency on@alkdev/operationstypes. Options: (a) importIdentityfrom operations (peer dep), (b) duplicate the type in flowgraph, (c) useType.Recordand accept weaker typing. -
How should conditional edge conditions be represented?
condition: Type.Unknown()is maximally flexible but provides no type safety. Options: (a)Type.Unknown()with documentation, (b)Type.Union([Type.String(), Type.Function(...)])for expression strings and function references, (c) a dedicatedConditionSchemathat flowgraph defines.
References
- Taskgraph schema patterns:
@alkdev/taskgraph_ts/docs/architecture/schemas.md - Call graph storage schema:
@alkdev/alkhub_ts/docs/architecture/storage/call-graph.md - Call event types:
@alkdev/operations/src/call.ts - Operation types:
@alkdev/operations/src/types.ts - ujsx schema:
@alkdev/ujsx/docs/architecture/schema.md