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