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

61 lines
3.6 KiB
Markdown

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