Resolve all 19 remaining open questions across the architecture. Every question now has a documented resolution with rationale: - OQ-004/OQ-029: edgeType is a universal required attribute on all edges, single graph per FlowGraph instance (ADR-006) - OQ-011: No OR preconditions for v1; preconditionMode as v2 extension - OQ-012: maxConcurrency enforced via reactive counting semaphore - OQ-014: Unknown operationId creates node with pending status - OQ-017: Expose common graphology traversal methods on FlowGraph (80/20) - OQ-020: condition as Type.Unknown() with string/function documentation - OQ-022: Identity imported from @alkdev/operations peer dep - All other questions resolved with documented rationale Fix three critical issues found by architecture review: 1. edgeType serialization/validation gap: document two-step validation 2. CallEdgeAttrs runtime discrimination: edgeType as runtime discriminant, depends_on edges clarified as observability-only (not execution) 3. ADR-005 signal mutation inconsistency: explicitly distinguish call-level statuses (event-log-driven) from workflow-derived statuses (signal-mutation) Additional clarifications: - dataFlow inference uses conservative strategy (defaults false) - Conditional.test string resolution: operationName → status === completed - Add negated field to TemplateEdgeAttrs for else-branch conditions - Document edge key priority convention for composite keys - Add maxConcurrency semaphore design to reactive-execution.md
479 lines
30 KiB
Markdown
479 lines
30 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-05-22
|
|
---
|
|
|
|
# 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:
|
|
|
|
1. **Static TypeScript types** via `Static<typeof Schema>` — every schema constant has a corresponding `type X = Static<typeof X>` alias
|
|
2. **Runtime validation** via `Value.Check()` / `Value.Errors()` — structured field-level error reporting
|
|
3. **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:
|
|
|
|
```typescript
|
|
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`.
|
|
|
|
```typescript
|
|
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 executing
|
|
- `running → completed`: `call.responded` + `call.completed` received
|
|
- `running → failed`: `call.error` received
|
|
- `pending → aborted`: `call.aborted` received before handler started (e.g., deadline exceeded)
|
|
- `running → aborted`: `call.aborted` received 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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 `typed` edges (`OperationEdgeAttrs`)
|
|
- **Call graphs** use `triggered` and `depends_on` edges (`CallEdgeAttrs`)
|
|
- **Template DAGs** use `sequential` and `conditional` edges (`TemplateEdgeAttrs`)
|
|
|
|
## Node Attribute Schemas
|
|
|
|
### OperationNodeAttrs
|
|
|
|
Attributes for nodes in the operation graph. Derived from `OperationSpec` but carrying only graph-relevant data:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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 (OQ-022: imported from @alkdev/operations peer dep)
|
|
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
|
|
|
|
### Edge Attribute Schemas
|
|
|
|
**Important**: `edgeType` is a universal required attribute stored on every edge in graphology, alongside (not inside) the mode-specific attribute schemas. This means the stored edge attributes are always `{ edgeType, ...modeSpecificAttrs }`. The TypeBox schemas below define only the mode-specific attributes; `edgeType` is added separately during edge creation and validated separately during deserialization.
|
|
|
|
When validating serialized graphs, the validation is a two-step process:
|
|
1. Check that `edgeType` is present and matches the expected value for the graph mode
|
|
2. Validate the remaining attributes against the mode-specific schema (`OperationEdgeAttrs`, `CallEdgeAttrs`, etc.)
|
|
|
|
This separation keeps the mode-specific schemas clean (they define only what's unique to each mode) while ensuring `edgeType` is always present at the storage level.
|
|
|
|
### OperationEdgeAttrs (Operation Graph)
|
|
|
|
```typescript
|
|
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 (OQ-004)**: `edgeType` is a required universal attribute stored on every edge, regardless of graph mode. This applies uniformly: operation graph edges have `edgeType: "typed"`, call graph edges have `edgeType: "triggered"` or `"depends_on"`, and template edges have `edgeType: "sequential"` or `"conditional"`. The `edgeType` field is stored alongside mode-specific attributes in graphology, not inside the mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, etc.). This ensures consistent serialization/deserialization, uniform graphology queries, and straightforward edge-type filtering. See ADR-006 for the full decision record.
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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 `edgeType` attribute.
|
|
|
|
**Runtime discrimination**: Since `TriggeredEdgeAttrs` and `DependencyEdgeAttrs` are both empty objects, the union cannot be discriminated by TypeBox at the schema level. Instead, `edgeType` serves as the runtime discriminant. When validating serialized call graph edges, the two-step validation process applies:
|
|
1. Read `edgeType` to determine which variant applies (`"triggered"` → `TriggeredEdgeAttrs`, `"depends_on"` → `DependencyEdgeAttrs`)
|
|
2. Validate the remaining attributes against the corresponding schema
|
|
|
|
At the code level, `edgeType` is used in a switch/if statement to determine which type of call edge is being processed. The `addCall` method automatically sets `edgeType: "triggered"` when creating a triggered edge, and `addDependency` sets `edgeType: "depends_on"`.
|
|
|
|
**`depends_on` edge status (ADR-005)**: While `depends_on` edges are not auto-populated by the call protocol (ADR-005 resolves OQ-008: data dependencies flow through the result projection), they remain in the API for **observability and visualization**. A hub coordinator or external tool may add `depends_on` edges to annotate observed data flow between calls for debugging or monitoring purposes. They do NOT affect execution — the reactive engine derives data flow from the result projection, not from `depends_on` edges.
|
|
|
|
### TemplateEdgeAttrs (Workflow Templates)
|
|
|
|
```typescript
|
|
const TemplateEdgeAttrs = Type.Object({
|
|
edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]),
|
|
condition: Type.Optional(Type.Unknown({ description: "For conditional edges: a function ((results: Record<string, CallResult>) => boolean) or a string referencing an operation name. Function values are not JSON-serializable; use string form for persistence." })),
|
|
negated: Type.Optional(Type.Boolean({ description: "True if this edge represents the negated condition of a Conditional's else branch" })),
|
|
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.
|
|
|
|
**`condition` representation (OQ-020)**: The `condition` field uses `Type.Unknown()` at the schema level for maximum flexibility, with two runtime representations:
|
|
|
|
1. **String form** (`string`): A serializable reference to an operation name whose result determines the branch. Example: `"fetch-data"` means "check the result of the operation named 'fetch-data'". String conditions survive JSON round-trips and are resolved by the HostConfig at render time using the operation registry.
|
|
|
|
2. **Function form** (`(results: Record<string, CallResult>) => boolean`): A runtime-evaluated predicate that receives predecessor results and returns `true` (then-branch) or `false` (else-branch). Function conditions do NOT survive JSON serialization. They are evaluated by the reactive engine against the result projection (per ADR-005).
|
|
|
|
The `Type.Unknown()` schema representation is intentional — it matches the reality that conditions can be either strings or functions, and neither TypeBox's `Type.String()` alone nor `Type.Function()` alone captures both forms. `@alkdev/typebox`'s `Type.Function()` defines input/output schemas for serializable function shapes, but the `Conditional.test` predicate is a runtime closure, not a serializable function schema. If a future need arises for schema-level condition descriptions (e.g., for template interchange), a dedicated `ConditionSchema` can be introduced — but for v1, `Type.Unknown()` with documentation is the pragmatic choice.
|
|
|
|
**`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 via `Conditional.test`, `Map.over`, or `Operation.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. For v1, the inference uses a **conservative strategy**: an edge gets `dataFlow: true` when any of the following conditions are detected, and `dataFlow: false` (the default) otherwise:
|
|
|
|
1. A `Conditional` edge always gets `dataFlow: true` (conditions always read a predecessor's result).
|
|
2. A `Sequential` edge where the downstream node's `input` function references `results[...]` gets `dataFlow: true`.
|
|
3. A `Sequential` edge where a `Map.over` function references `results[...]` on the predecessor gets `dataFlow: true`.
|
|
|
|
Edges where `dataFlow` cannot be determined (e.g., `Operation.input` is an opaque function that can't be statically analyzed) default to `dataFlow: false`. Template authors can override this by explicitly providing `dataFlow: true` as an edge attribute if they know the downstream node reads upstream output.
|
|
|
|
Over-marking `dataFlow: true` is safe (it just causes an unnecessary type compatibility check), while under-marking is safe (it skips a check that would have passed anyway, but could let a type-incompatible connection through). The conservative strategy errs on the side of under-marking.
|
|
|
|
The `dataFlow` attribute is **inferred** by the `GraphologyHostConfig` during template rendering, not manually specified by template authors:
|
|
|
|
- A `Sequential` edge where the downstream node references `results["upstreamNode"]` in any expression gets `dataFlow: true`
|
|
- A `Sequential` edge where no such reference exists gets `dataFlow: false` (the default)
|
|
- A `Conditional` edge always gets `dataFlow: 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.
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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.
|
|
|
|
**Key priority convention**: When multiple edge types exist between the same (source, target) pair, the "primary" edge type gets the simple `${source}->${target}` key format. For call graphs, `triggered` edges are primary (a parent always triggers its child before any data dependency is established), so `triggered` edges use the simple format. For operation graphs and template DAGs, there is only one edge type per (source, target) pair, so the simple format always applies.
|
|
|
|
**`depends_on` edge key format**: `depends_on` edges always use the composite format `${source}->${target}:depends_on`, even if no `triggered` edge exists between the same pair. This ensures key consistency regardless of edge ordering.
|
|
|
|
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 `interface` or `type` definitions for data shapes. All types are derived via `Static<typeof Schema>`.
|
|
- **Edge keys are deterministic** — `${source}->${target}` format, following ADR-006 in taskgraph.
|
|
- **No parallel edges** — `multi: false` in 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.
|
|
- **`inputSchema` and `outputSchema` on 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 the `typeCompat` analysis 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
|
|
|
|
1. ~~**Should `edgeType` be a required field on ALL edges, or only on call graph and template edges?**~~ **Resolved (OQ-004)**: `edgeType` is required on all edges, stored as a universal attribute alongside mode-specific attributes. The mode-specific attribute schemas (`OperationEdgeAttrs`, `TriggeredEdgeAttrs`, `DependencyEdgeAttrs`) do NOT include `edgeType` — it's stored separately in graphology at the same level as the mode-specific attributes. This ensures consistent serialization/deserialization, uniform graphology queries, and straightforward edge-type filtering across all graph modes. See ADR-006.
|
|
|
|
2. ~~**Should `CallNodeAttrs.identity` be a `Type.Record` or the structured `Identity` type from operations?**~~ **Resolved (OQ-022)**: Import the `Identity` type structure from `@alkdev/operations` (peer dependency). Since `@alkdev/operations` is already a peer dependency (for `CallEventMapValue`), adding this type import creates minimal additional coupling. The `CallNodeAttrs.identity` field mirrors the `Identity` interface: `{ id, scopes, resources? }`. Version alignment is handled by semver ranges. The TypeBox schema for `identity` is defined inline in `CallNodeAttrs` to match the shape (not imported as a TypeBox schema, since `@alkdev/operations` defines `Identity` as a TypeScript interface), but the field semantics match exactly.
|
|
|
|
3. ~~**How should conditional edge conditions be represented?**~~ **Resolved (OQ-020)**: `condition: Type.Optional(Type.Unknown())` with documentation describing the two runtime forms: string (serializable operation reference) and function (`(results) => boolean`, not serializable). `@alkdev/typebox`'s `Type.Function()` defines serializable function input/output schemas, but `Conditional.test` predicates are runtime closures — they can't be represented as serializable function schemas. `Type.Unknown()` is the pragmatic choice for v1, accepting that JSON serialization only preserves the string form. A dedicated `ConditionSchema` can be introduced in v2 if template interchange needs schema-level condition descriptions.
|
|
|
|
## 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` |