Files
correx/docs/decisions/adr-0011-transition-engine-contracts.md
T

3.6 KiB

name, description, depth, links
name description depth links
Adr 0011 Transition Engine Contracts Defines ownership of StageId and the no-match behavior of the transition resolver 2
../index.md
./adr-0001-event-sourcing.md
./adr-0009-replay-engine-projection-separation.md

ADR 0011: transition engine contracts — StageId ownership and no-match behavior

status: accepted
date: 15.05.2026 (May)
deciders: Kami


context

Two behaviors in the transition engine were undefined, creating silent failure modes:

  1. No-match behavior: DefaultTransitionResolver returned Stay when a stage had no outgoing transitions. This conflated two distinct cases: "conditions not yet met, keep waiting" (Stay) and "no transitions are defined for this stage" (structurally undefined). The latter would cause the orchestrator to loop indefinitely with no path forward.

  2. StageId origin: nothing explicitly prevented the transition engine from constructing StageId values internally. This would break the invariant that the orchestrator is the sole source of stage identity, making execution traces harder to reason about and replay.

decision

1. No-match is a typed failure

TransitionDecision gains a NoMatch case. The resolver returns NoMatch when the graph contains no outgoing edges for the current stage. This is distinct from:

  • Stay — outgoing edges exist but no condition evaluated to true; waiting is the correct response.
  • Blocked — a specific guard explicitly prevented the transition.
  • NoMatch — the graph has no definition for what to do next; this is a workflow authoring error or an unhandled terminal state.

The orchestrator is responsible for observing NoMatch and emitting a StageFailedEvent with reason "no matching transition". The resolver is pure: it classifies; it does not emit events.

2. StageId is always caller-generated

StageId values are created exclusively by the orchestrator (or workflow graph construction at startup). The transition engine — resolver, evaluator, and related types — must never construct a StageId. It receives StageId values through EvaluationContext and WorkflowGraph and returns them in TransitionDecision.Move. It does not originate them.

This rule preserves:

  • Replay determinism: stage identity is fixed at graph construction time, not derived during evaluation.
  • Single source of truth: the orchestrator owns execution flow; the transition engine advises it.

consequences

positive:

  • No-match is observable and testable; indefinite loops caused by missing transitions become explicit failures.
  • Stay retains its precise meaning: "wait for conditions", not "don't know what to do".
  • StageId origin is unambiguous; code review can statically verify compliance.

negative:

  • Callers of resolve() that previously exhaustively handled Move | Stay | Blocked must now handle NoMatch. This is intentional: forcing the call site to handle it prevents silent suppression.

alternatives considered

  • Return Blocked with a special reason string: rejected. Encoding structural failure as a Blocked with a magic string degrades type safety and makes exhaustive matching useless.
  • Throw an exception on no-match: rejected. Exceptions are not part of the resolver's contract. The orchestrator decides what a NoMatch means in context (fail, alert, retry on graph reload); that policy does not belong in the resolver.
  • Allow transition engine to generate StageId: rejected. Any engine-generated ID would be invisible to the event log before the StageFailedEvent, breaking the invariant that all state transitions are traceable.