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 |
|
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:
-
No-match behavior:
DefaultTransitionResolverreturnedStaywhen 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. -
StageIdorigin: nothing explicitly prevented the transition engine from constructingStageIdvalues 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.
Stayretains its precise meaning: "wait for conditions", not "don't know what to do".StageIdorigin is unambiguous; code review can statically verify compliance.
negative:
- Callers of
resolve()that previously exhaustively handledMove | Stay | Blockedmust now handleNoMatch. This is intentional: forcing the call site to handle it prevents silent suppression.
alternatives considered
- Return
Blockedwith a special reason string: rejected. Encoding structural failure as aBlockedwith 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
NoMatchmeans 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 theStageFailedEvent, breaking the invariant that all state transitions are traceable.