1use crate::envelope::Actor;
10use crate::fsm::{AttemptState, CommandState, MessageState, StateMachine, TaskState};
11use crate::ids::{IdempotencyKey, ReceiptId};
12
13pub const LIVENESS_PRODUCER_KIND: &str = "liveness_producer";
16
17#[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 pub applied_by: Option<Actor>,
35}
36
37#[derive(Debug, Clone, Copy)]
39pub struct TransitionRequest<'a, S> {
40 pub to: S,
41 pub expected_version: u32,
44 pub actor: &'a Actor,
45 pub idempotency_key: Option<&'a IdempotencyKey>,
46 pub receipt_id: Option<&'a ReceiptId>,
48}
49
50#[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#[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#[derive(Debug, Clone, Copy)]
80pub struct GuardCtx<'a> {
81 pub actor: &'a Actor,
82 pub receipt_id: Option<&'a ReceiptId>,
83}
84
85pub 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 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 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
122pub 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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}