Files
flowgraph/docs/architecture/schema.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

31 KiB

status, last_updated
status last_updated
reviewed 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:

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 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:

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. Following the TypeBox-as-single-source-of-truth principle, CallResult is defined as a TypeBox schema with the corresponding type derived via Static:

const CallResultSchema = Type.Object({
  status: NodeStatusEnum,              // Status of the call (completed, failed, aborted, skipped)
  output: Type.Unknown(),               // Call output (if completed)
  error: Type.Optional(Type.Object({    // Call error (if failed)
    code: Type.String(),
    message: Type.String(),
    details: Type.Optional(Type.Unknown()),
  })),
});
type CallResult = Static<typeof CallResultSchema>;

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. The output field uses Type.Unknown() because call outputs are arbitrary data; the error field mirrors the CallNodeAttrs.error structure.

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 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:

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 (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)

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 (flowgraph) for the full decision record.

// 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 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)

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.

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 key convention), 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 that participate in graph attributes, serialization, or runtime validation. All such types are derived via Static<typeof Schema>. Exception: analysis result types returned by validation and compatibility functions (e.g., ValidationError, GraphValidationError, TypeIncompatError) are plain interfaces because they are ephemeral result objects, not serialized graph data. They don't need TypeBox schemas because they are never persisted or transmitted — they are consumed locally and discarded.
  • Edge keys are deterministic${source}->${target} format, following taskgraph's ADR-006 (edge key convention).
  • No parallel edgesmulti: false in graphology. At most one edge per (source, target) pair.
  • No self-loopsallowSelfLoops: 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 (flowgraph).

  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