Skip to main content

gwk_domain/
transition.rs

1//! The one transition algorithm every writer goes through.
2//!
3//! `apply` is pure: given the aggregate's current cursor and a request, it
4//! returns what happened as a value — [`TransitionResult`] — never a panic and
5//! never a silent mutation. Storage layers persist an `applied` result
6//! atomically with its receipt/outcome side rows; everything else is a typed
7//! refusal the caller branches on.
8
9use crate::envelope::Actor;
10use crate::fsm::{AttemptState, CommandState, MessageState, StateMachine, TaskState};
11use crate::ids::{IdempotencyKey, ReceiptId};
12
13/// The actor `kind` of the ONE legal writer of the `running` ⇄ `blocked`
14/// flip — the liveness-producer flip rule. Every flip also requires a receipt.
15pub const LIVENESS_PRODUCER_KIND: &str = "liveness_producer";
16
17/// An aggregate's current position: state, CAS version, and the idempotency
18/// key of the last applied transition (what makes retries stable).
19///
20/// This is an in-memory value passed to [`apply`], NOT a wire/bindings type
21/// (no `serde`/`specta` derive), so it can carry the identity that recorded the
22/// last applied transition without touching the generated contract.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Cursor<S> {
25    pub state: S,
26    pub version: u32,
27    pub applied_idempotency_key: Option<IdempotencyKey>,
28    /// The actor that RECORDED the transition named by
29    /// `applied_idempotency_key`. A keyed replay must present this same actor
30    /// identity to receive the stable idempotent echo; a harvested key replayed
31    /// by any other actor falls through to the honest refusal instead of being
32    /// handed the current state+version. `None` when no attributed transition
33    /// has been applied yet.
34    pub applied_by: Option<Actor>,
35}
36
37/// One requested transition.
38#[derive(Debug, Clone, Copy)]
39pub struct TransitionRequest<'a, S> {
40    pub to: S,
41    /// CAS precondition: must equal the cursor's current version; the applied
42    /// result carries `expected_version + 1`.
43    pub expected_version: u32,
44    pub actor: &'a Actor,
45    pub idempotency_key: Option<&'a IdempotencyKey>,
46    /// The receipt accompanying guarded edges (the blocked-flip rule REQUIRES one).
47    pub receipt_id: Option<&'a ReceiptId>,
48}
49
50/// What `apply` decided, as a tagged wire value.
51#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum TransitionResult<S> {
54    Applied { state: S, version: u32 },
55    IllegalEdge { from: S, to: S },
56    StaleVersion { actual: u32, expected: u32 },
57    UnauthorizedActor { reason: String },
58}
59
60/// Why a guard refused an otherwise-legal edge.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum GuardViolation {
63    WrongActor { required_kind: &'static str },
64    MissingReceipt,
65}
66
67impl std::fmt::Display for GuardViolation {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::WrongActor { required_kind } => {
71                write!(f, "edge requires actor kind {required_kind}")
72            }
73            Self::MissingReceipt => f.write_str("edge requires a receipt"),
74        }
75    }
76}
77
78/// What a guard sees about the request.
79#[derive(Debug, Clone, Copy)]
80pub struct GuardCtx<'a> {
81    pub actor: &'a Actor,
82    pub receipt_id: Option<&'a ReceiptId>,
83}
84
85/// Per-machine actor rules layered over edge legality. Default: any actor.
86pub trait TransitionGuard: StateMachine {
87    fn guard(_from: Self, _to: Self, _ctx: &GuardCtx<'_>) -> Result<(), GuardViolation> {
88        Ok(())
89    }
90}
91
92impl TransitionGuard for TaskState {}
93impl TransitionGuard for MessageState {}
94impl TransitionGuard for CommandState {}
95
96impl TransitionGuard for AttemptState {
97    /// The liveness-producer flip rule: `running` ⇄ `blocked` has exactly one
98    /// legal writer — the liveness producer — and every flip is receipted.
99    /// All other edges are unguarded here (authority beyond the flip is the
100    /// kernel's policy layer).
101    fn guard(from: Self, to: Self, ctx: &GuardCtx<'_>) -> Result<(), GuardViolation> {
102        let is_flip = matches!(
103            (from, to),
104            (Self::Running, Self::Blocked) | (Self::Blocked, Self::Running)
105        );
106        if is_flip {
107            if ctx.actor.kind != LIVENESS_PRODUCER_KIND {
108                return Err(GuardViolation::WrongActor {
109                    required_kind: LIVENESS_PRODUCER_KIND,
110                });
111            }
112            // A present-but-empty receipt ("") is missing, not a receipt —
113            // align with the certifier, which rejects it (gwk-cert `check.rs`).
114            if ctx.receipt_id.is_none_or(|r| r.as_str().is_empty()) {
115                return Err(GuardViolation::MissingReceipt);
116            }
117        }
118        Ok(())
119    }
120}
121
122/// Decide one transition. Check order:
123///
124/// 1. **Actor guard** ([`TransitionGuard::guard`], e.g. the liveness-producer
125///    flip rule) — FIRST, ahead of the idempotency short-circuit. Guard-first
126///    RE-AUTHORIZES a guarded edge on EVERY request, replay included: an
127///    unauthorized replay of a guarded flip is refused, not answered, so the
128///    short-circuit can never become a probe of a guarded edge. A legitimate
129///    retry resends the same complete request (same actor, same receipt),
130///    passes the guard again, and stays stable. Unguarded edges are untouched
131///    by the guard.
132/// 2. **Idempotent retry** — a request whose key equals the last APPLIED key,
133///    whose target state the cursor already holds, AND whose actor equals the
134///    one that recorded that transition (`applied_by`) returns the current
135///    cursor unchanged (stable even after the version advanced). A matching
136///    key from a DIFFERENT actor, or aimed at any OTHER state, falls through to
137///    the honest refusal below — a harvested key never converts a wrong or
138///    unauthorized request into an apply.
139/// 3. **Edge legality** against [`StateMachine::EDGES`].
140/// 4. **CAS version** — `expected_version` must equal the cursor's version;
141///    the applied result is `version + 1`, and a transition at the u32 ceiling
142///    is refused (as `StaleVersion`) rather than overflowed.
143///
144/// This order is NOT a blanket non-disclosure claim. On an UNGUARDED edge the
145/// refusals deliberately carry state and version: `IllegalEdge { from }`
146/// discloses the current state and `StaleVersion { actual }` the current
147/// version, because `actual` is load-bearing for a legitimate CAS retrier — it
148/// must learn the true version to retry, so it cannot be withheld. That
149/// disclosure is inherent to the CAS-retry contract and acceptable here because
150/// the event log is world-readable in this deployment. What guard-first buys is
151/// narrower and real: a GUARDED edge is re-authorized on every request, so an
152/// unauthorized replay of a guarded flip never reaches the state/version-bearing
153/// arms at all.
154pub fn apply<S: TransitionGuard>(
155    cursor: &Cursor<S>,
156    req: &TransitionRequest<'_, S>,
157) -> TransitionResult<S> {
158    let ctx = GuardCtx {
159        actor: req.actor,
160        receipt_id: req.receipt_id,
161    };
162    if let Err(violation) = S::guard(cursor.state, req.to, &ctx) {
163        return TransitionResult::UnauthorizedActor {
164            reason: violation.to_string(),
165        };
166    }
167    if let (Some(key), Some(applied)) =
168        (req.idempotency_key, cursor.applied_idempotency_key.as_ref())
169        && key == applied
170        && cursor.state == req.to
171        && cursor.applied_by.as_ref() == Some(req.actor)
172    {
173        return TransitionResult::Applied {
174            state: cursor.state,
175            version: cursor.version,
176        };
177    }
178    if !S::can_transition(cursor.state, req.to) {
179        return TransitionResult::IllegalEdge {
180            from: cursor.state,
181            to: req.to,
182        };
183    }
184    if cursor.version != req.expected_version {
185        return TransitionResult::StaleVersion {
186            actual: cursor.version,
187            expected: req.expected_version,
188        };
189    }
190    // A transition at the u32 version ceiling is a refusal, not a panic (debug)
191    // or a silent wrap to 0 (release, a version REWIND — the other thing this
192    // module forbids). Reuse the StaleVersion refusal the caller already knows.
193    let Some(next_version) = req.expected_version.checked_add(1) else {
194        return TransitionResult::StaleVersion {
195            actual: cursor.version,
196            expected: req.expected_version,
197        };
198    };
199    TransitionResult::Applied {
200        state: req.to,
201        version: next_version,
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn kernel() -> Actor {
210        Actor {
211            kind: "kernel".into(),
212            id: None,
213        }
214    }
215
216    fn liveness() -> Actor {
217        Actor {
218            kind: LIVENESS_PRODUCER_KIND.into(),
219            id: Some("lp-1".into()),
220        }
221    }
222
223    fn cursor(state: AttemptState, version: u32) -> Cursor<AttemptState> {
224        Cursor {
225            state,
226            version,
227            applied_idempotency_key: None,
228            applied_by: None,
229        }
230    }
231
232    #[test]
233    fn applied_bumps_version_by_exactly_one() {
234        let actor = kernel();
235        let result = apply(
236            &cursor(AttemptState::Queued, 3),
237            &TransitionRequest {
238                to: AttemptState::Leased,
239                expected_version: 3,
240                actor: &actor,
241                idempotency_key: None,
242                receipt_id: None,
243            },
244        );
245        assert_eq!(
246            result,
247            TransitionResult::Applied {
248                state: AttemptState::Leased,
249                version: 4
250            }
251        );
252    }
253
254    #[test]
255    fn illegal_edge_is_refused_before_version() {
256        let actor = kernel();
257        // Pins the refusal order guard -> idempotency -> edge -> CAS on an
258        // UNGUARDED edge: wrong edge AND wrong version, the edge refusal wins.
259        // (Guard precedence has its own pin below.)
260        let result = apply(
261            &cursor(AttemptState::Queued, 3),
262            &TransitionRequest {
263                to: AttemptState::Succeeded,
264                expected_version: 99,
265                actor: &actor,
266                idempotency_key: None,
267                receipt_id: None,
268            },
269        );
270        assert_eq!(
271            result,
272            TransitionResult::IllegalEdge {
273                from: AttemptState::Queued,
274                to: AttemptState::Succeeded
275            }
276        );
277    }
278
279    #[test]
280    fn stale_version_reports_actual() {
281        let actor = kernel();
282        let result = apply(
283            &cursor(AttemptState::Running, 7),
284            &TransitionRequest {
285                to: AttemptState::Succeeded,
286                expected_version: 6,
287                actor: &actor,
288                idempotency_key: None,
289                receipt_id: None,
290            },
291        );
292        assert_eq!(
293            result,
294            TransitionResult::StaleVersion {
295                actual: 7,
296                expected: 6
297            }
298        );
299    }
300
301    #[test]
302    fn version_ceiling_is_refused_not_overflowed() {
303        // At version u32::MAX the "+1" would panic in debug and silently wrap
304        // to 0 (a version rewind) in release. A legal, CAS-matching request at
305        // the ceiling must instead get the honest StaleVersion refusal.
306        let actor = kernel();
307        let result = apply(
308            &Cursor {
309                state: TaskState::Submitted,
310                version: u32::MAX,
311                applied_idempotency_key: None,
312                applied_by: None,
313            },
314            &TransitionRequest {
315                to: TaskState::Working,
316                expected_version: u32::MAX,
317                actor: &actor,
318                idempotency_key: None,
319                receipt_id: None,
320            },
321        );
322        assert_eq!(
323            result,
324            TransitionResult::StaleVersion {
325                actual: u32::MAX,
326                expected: u32::MAX,
327            }
328        );
329    }
330
331    #[test]
332    fn blocked_flip_demands_the_liveness_producer() {
333        let wrong = kernel();
334        let result = apply(
335            &cursor(AttemptState::Running, 2),
336            &TransitionRequest {
337                to: AttemptState::Blocked,
338                expected_version: 2,
339                actor: &wrong,
340                idempotency_key: None,
341                receipt_id: None,
342            },
343        );
344        assert!(matches!(result, TransitionResult::UnauthorizedActor { .. }));
345    }
346
347    #[test]
348    fn blocked_flip_demands_a_receipt() {
349        let actor = liveness();
350        let no_receipt = apply(
351            &cursor(AttemptState::Blocked, 5),
352            &TransitionRequest {
353                to: AttemptState::Running,
354                expected_version: 5,
355                actor: &actor,
356                idempotency_key: None,
357                receipt_id: None,
358            },
359        );
360        assert!(matches!(
361            no_receipt,
362            TransitionResult::UnauthorizedActor { .. }
363        ));
364
365        let receipt = ReceiptId::new("r-1");
366        let with_receipt = apply(
367            &cursor(AttemptState::Blocked, 5),
368            &TransitionRequest {
369                to: AttemptState::Running,
370                expected_version: 5,
371                actor: &actor,
372                idempotency_key: None,
373                receipt_id: Some(&receipt),
374            },
375        );
376        assert_eq!(
377            with_receipt,
378            TransitionResult::Applied {
379                state: AttemptState::Running,
380                version: 6
381            }
382        );
383    }
384
385    #[test]
386    fn blocked_flip_rejects_a_present_but_empty_receipt() {
387        // The certifier rejects an empty receipt_id; the domain guard must
388        // agree, or a present-but-empty "" would slip a flip past the receipt
389        // requirement the certifier later refuses.
390        let actor = liveness();
391        let empty = ReceiptId::new("");
392        let result = apply(
393            &cursor(AttemptState::Blocked, 5),
394            &TransitionRequest {
395                to: AttemptState::Running,
396                expected_version: 5,
397                actor: &actor,
398                idempotency_key: None,
399                receipt_id: Some(&empty),
400            },
401        );
402        assert!(matches!(result, TransitionResult::UnauthorizedActor { .. }));
403    }
404
405    #[test]
406    fn blocked_terminal_paths_need_no_liveness_actor() {
407        // blocked -> canceling/failed/unknown are escape edges, not flips.
408        let actor = kernel();
409        for to in [
410            AttemptState::Canceling,
411            AttemptState::Failed,
412            AttemptState::Unknown,
413        ] {
414            let result = apply(
415                &cursor(AttemptState::Blocked, 1),
416                &TransitionRequest {
417                    to,
418                    expected_version: 1,
419                    actor: &actor,
420                    idempotency_key: None,
421                    receipt_id: None,
422                },
423            );
424            assert_eq!(
425                result,
426                TransitionResult::Applied {
427                    state: to,
428                    version: 2
429                },
430                "blocked -> {to:?} must not demand the liveness producer"
431            );
432        }
433    }
434
435    #[test]
436    fn same_key_retry_is_stable_even_after_version_advanced() {
437        let actor = kernel();
438        let key = IdempotencyKey::new("once");
439        let already_applied = Cursor {
440            state: TaskState::Working,
441            version: 2,
442            applied_idempotency_key: Some(key.clone()),
443            applied_by: Some(kernel()),
444        };
445        // The caller retries with its original expected_version (1) — stale by
446        // now — and the SAME key: stable Applied, no double-apply, no conflict.
447        let result = apply(
448            &already_applied,
449            &TransitionRequest {
450                to: TaskState::Working,
451                expected_version: 1,
452                actor: &actor,
453                idempotency_key: Some(&key),
454                receipt_id: None,
455            },
456        );
457        assert_eq!(
458            result,
459            TransitionResult::Applied {
460                state: TaskState::Working,
461                version: 2
462            }
463        );
464
465        // A DIFFERENT key gets the honest stale-version refusal.
466        let other = IdempotencyKey::new("twice");
467        let result = apply(
468            &already_applied,
469            &TransitionRequest {
470                to: TaskState::Completed,
471                expected_version: 1,
472                actor: &actor,
473                idempotency_key: Some(&other),
474                receipt_id: None,
475            },
476        );
477        assert_eq!(
478            result,
479            TransitionResult::StaleVersion {
480                actual: 2,
481                expected: 1
482            }
483        );
484    }
485
486    #[test]
487    fn guarded_flip_refuses_the_wrong_actor_before_disclosing_the_version() {
488        // A wrong-actor request on the running -> blocked flip, carrying a
489        // harvested idempotency key AND a stale expected_version, gets the
490        // actor refusal — never StaleVersion { actual } (a version probe) and
491        // never a keyed Applied echo.
492        let engine = Actor {
493            kind: "engine".into(),
494            id: Some("e-1".into()),
495        };
496        let key = IdempotencyKey::new("flip-once");
497        let receipt = ReceiptId::new("r-forged");
498        let result = apply(
499            &Cursor {
500                state: AttemptState::Running,
501                version: 5,
502                applied_idempotency_key: Some(key.clone()),
503                applied_by: None,
504            },
505            &TransitionRequest {
506                to: AttemptState::Blocked,
507                expected_version: 999,
508                actor: &engine,
509                idempotency_key: Some(&key),
510                receipt_id: Some(&receipt),
511            },
512        );
513        assert!(matches!(result, TransitionResult::UnauthorizedActor { .. }));
514    }
515
516    #[test]
517    fn keyed_replay_requires_the_recording_actor_not_just_the_key() {
518        // After a guarded flip that the liveness producer applied, the cursor
519        // remembers WHO applied it. The (blocked, blocked) self-edge is not a
520        // flip, so the guard does not fire on a replay to the current state —
521        // the idempotency arm decides. Binding that arm to the recording actor
522        // is what stops a harvested key from becoming a state+version probe.
523        let lp = liveness();
524        let key = IdempotencyKey::new("flip-once");
525        let cur = Cursor {
526            state: AttemptState::Blocked,
527            version: 6,
528            applied_idempotency_key: Some(key.clone()),
529            applied_by: Some(lp.clone()),
530        };
531
532        // The recording actor replaying the key on the current state still gets
533        // the stable idempotent echo — retries stay stable.
534        let stable = apply(
535            &cur,
536            &TransitionRequest {
537                to: AttemptState::Blocked,
538                expected_version: 6,
539                actor: &lp,
540                idempotency_key: Some(&key),
541                receipt_id: None,
542            },
543        );
544        assert_eq!(
545            stable,
546            TransitionResult::Applied {
547                state: AttemptState::Blocked,
548                version: 6
549            }
550        );
551
552        // A DIFFERENT actor replaying the harvested key falls through to the
553        // honest refusal — never a keyed Applied { .., 6 } echo. The version
554        // stays undisclosed; the residual `from` in IllegalEdge is the
555        // CAS-retry contract's acknowledged, world-readable channel.
556        let engine = Actor {
557            kind: "engine".into(),
558            id: Some("e-1".into()),
559        };
560        let probe = apply(
561            &cur,
562            &TransitionRequest {
563                to: AttemptState::Blocked,
564                expected_version: 6,
565                actor: &engine,
566                idempotency_key: Some(&key),
567                receipt_id: None,
568            },
569        );
570        assert_eq!(
571            probe,
572            TransitionResult::IllegalEdge {
573                from: AttemptState::Blocked,
574                to: AttemptState::Blocked
575            }
576        );
577    }
578
579    /// A test-only machine whose guard covers EVERY requested transition, so
580    /// the guard's precedence over the idempotency short-circuit, the edge
581    /// check, and the CAS check is observable in one request. (The real
582    /// attempt flip pairs are all legal edges, so they cannot exercise the
583    /// guard-vs-IllegalEdge ordering.)
584    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
585    enum Gated {
586        Shut,
587    }
588
589    impl StateMachine for Gated {
590        const STATES: &'static [Self] = &[Self::Shut];
591        const EDGES: &'static [(Self, Self)] = &[];
592        const ESCAPE: &'static [Self] = &[];
593    }
594
595    impl TransitionGuard for Gated {
596        fn guard(_from: Self, _to: Self, ctx: &GuardCtx<'_>) -> Result<(), GuardViolation> {
597            if ctx.actor.kind != "gatekeeper" {
598                return Err(GuardViolation::WrongActor {
599                    required_kind: "gatekeeper",
600                });
601            }
602            Ok(())
603        }
604    }
605
606    #[test]
607    fn guard_refusal_precedes_idempotency_edge_and_version() {
608        // Everything else would fire: the key matches the last applied key
609        // AND the cursor holds the requested state (idempotency would echo
610        // Applied), the edge is not in the table (IllegalEdge would disclose
611        // `from`), and the version is stale (StaleVersion would disclose
612        // `actual`). The guard answers first; the caller learns nothing.
613        let intruder = kernel();
614        let key = IdempotencyKey::new("harvested");
615        let result = apply(
616            &Cursor {
617                state: Gated::Shut,
618                version: 4,
619                applied_idempotency_key: Some(key.clone()),
620                applied_by: None,
621            },
622            &TransitionRequest {
623                to: Gated::Shut,
624                expected_version: 999,
625                actor: &intruder,
626                idempotency_key: Some(&key),
627                receipt_id: None,
628            },
629        );
630        assert!(matches!(result, TransitionResult::UnauthorizedActor { .. }));
631    }
632
633    #[test]
634    fn replayed_key_with_a_different_target_state_falls_through_to_refusal() {
635        // A key replay only claims Applied when the cursor already holds the
636        // requested state. The same key aimed anywhere else — here combined
637        // with a wrong actor and a stale expected_version — must reach its
638        // honest refusal, never Applied.
639        let engine = Actor {
640            kind: "engine".into(),
641            id: Some("e-1".into()),
642        };
643        let key = IdempotencyKey::new("flip-once");
644        let cur = Cursor {
645            state: AttemptState::Running,
646            version: 5,
647            applied_idempotency_key: Some(key.clone()),
648            applied_by: None,
649        };
650        // Legal edge, stale version: the CAS refusal wins.
651        let result = apply(
652            &cur,
653            &TransitionRequest {
654                to: AttemptState::Succeeded,
655                expected_version: 999,
656                actor: &engine,
657                idempotency_key: Some(&key),
658                receipt_id: None,
659            },
660        );
661        assert_eq!(
662            result,
663            TransitionResult::StaleVersion {
664                actual: 5,
665                expected: 999
666            }
667        );
668        // An edge not in the table at all: the edge refusal wins.
669        let result = apply(
670            &cur,
671            &TransitionRequest {
672                to: AttemptState::Leased,
673                expected_version: 999,
674                actor: &engine,
675                idempotency_key: Some(&key),
676                receipt_id: None,
677            },
678        );
679        assert_eq!(
680            result,
681            TransitionResult::IllegalEdge {
682                from: AttemptState::Running,
683                to: AttemptState::Leased
684            }
685        );
686    }
687
688    #[test]
689    fn transition_result_wire_shape_is_tagged_snake_case() {
690        let applied: TransitionResult<TaskState> = TransitionResult::Applied {
691            state: TaskState::Working,
692            version: 2,
693        };
694        assert_eq!(
695            serde_json::to_value(&applied).expect("serialize"),
696            serde_json::json!({ "kind": "applied", "state": "working", "version": 2 })
697        );
698        let refused: TransitionResult<TaskState> = TransitionResult::UnauthorizedActor {
699            reason: "edge requires a receipt".into(),
700        };
701        assert_eq!(
702            serde_json::to_value(&refused).expect("serialize"),
703            serde_json::json!({ "kind": "unauthorized_actor", "reason": "edge requires a receipt" })
704        );
705    }
706}