61 lines
3.6 KiB
Markdown
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.
|