--- name: "Adr 0011 Transition Engine Contracts" description: "Defines ownership of StageId and the no-match behavior of the transition resolver" depth: 2 links: ["../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.