Skip to main content

aph_core/
human_not_present_flow.rs

1//! Human-not-present notarization flow state machine.
2//!
3//! Models the path when the human has issued a standing
4//! `DelegationMandate` and is NOT at the device. 5 states. The notary
5//! auto-decides against the standing mandate's scope.
6//!
7//! State diagram:
8//! ```text
9//! Drafted -> MandateIssued -> EnvelopeIssued -> Delivered (terminal)
10//!         -> Denied (terminal)
11//! ```
12
13/// State in the APH human-not-present notarization flow.
14#[derive(
15  std::fmt::Debug,
16  std::clone::Clone,
17  std::marker::Copy,
18  std::cmp::PartialEq,
19  std::cmp::Eq,
20  std::hash::Hash,
21  serde::Serialize,
22  serde::Deserialize,
23)]
24#[serde(rename_all = "camelCase")]
25pub enum HumanNotPresentNotarizationState {
26  /// Agent drafted; notary will auto-decide against the standing mandate.
27  Drafted,
28  /// Notary issued the `CommunicationMandate` against `DelegationMandate`.
29  MandateIssued,
30  /// Notary signed and emitted the envelope.
31  EnvelopeIssued,
32  /// Terminal: delivered to channel.
33  Delivered,
34  /// Terminal: standing mandate denied (scope mismatch, expired, NeverAllow).
35  Denied,
36}
37
38impl HumanNotPresentNotarizationState {
39  /// Returns `true` if this state is terminal (no further transitions).
40  pub fn is_terminal(&self) -> bool {
41    matches!(self, Self::Delivered | Self::Denied)
42  }
43
44  /// Returns the set of states this state may transition to.
45  pub fn valid_transitions(&self) -> &'static [Self] {
46    match self {
47      Self::Drafted => &[Self::MandateIssued, Self::Denied],
48      Self::MandateIssued => &[Self::EnvelopeIssued],
49      Self::EnvelopeIssued => &[Self::Delivered],
50      Self::Delivered => &[],
51      Self::Denied => &[],
52    }
53  }
54
55  /// Returns `true` if `next` is a legal successor of `self`.
56  pub fn can_transition_to(&self, next: Self) -> bool {
57    self.valid_transitions().contains(&next)
58  }
59}
60
61/// A human-not-present APH notarization flow wrapping state + mandate
62/// references.
63#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq, serde::Serialize, serde::Deserialize)]
64pub struct HumanNotPresentNotarizationFlow {
65  state: HumanNotPresentNotarizationState,
66  delegation_mandate_id: String,
67  communication_mandate_id: std::option::Option<String>,
68  envelope_id: std::option::Option<String>,
69}
70
71impl HumanNotPresentNotarizationFlow {
72  /// Constructs a new flow in the `Drafted` state bound to the given
73  /// `DelegationMandate.id`.
74  pub fn new(delegation_mandate_id: impl std::convert::Into<String>) -> Self {
75    Self {
76      state: HumanNotPresentNotarizationState::Drafted,
77      delegation_mandate_id: delegation_mandate_id.into(),
78      communication_mandate_id: std::option::Option::None,
79      envelope_id: std::option::Option::None,
80    }
81  }
82
83  /// Current state of the flow.
84  pub fn state(&self) -> HumanNotPresentNotarizationState {
85    self.state
86  }
87
88  /// Bound `DelegationMandate.id`.
89  pub fn delegation_mandate_id(&self) -> &str {
90    &self.delegation_mandate_id
91  }
92
93  /// Optionally-bound `CommunicationMandate.id` (set after `MandateIssued`).
94  pub fn communication_mandate_id(&self) -> std::option::Option<&str> {
95    self.communication_mandate_id.as_deref()
96  }
97
98  /// Optionally-bound `NotarizationEnvelope.id` (set after `EnvelopeIssued`).
99  pub fn envelope_id(&self) -> std::option::Option<&str> {
100    self.envelope_id.as_deref()
101  }
102
103  /// Binds the per-message communication-mandate identifier.
104  pub fn set_communication_mandate_id(&mut self, id: impl std::convert::Into<String>) {
105    self.communication_mandate_id = std::option::Option::Some(id.into());
106  }
107
108  /// Binds the envelope identifier.
109  pub fn set_envelope_id(&mut self, id: impl std::convert::Into<String>) {
110    self.envelope_id = std::option::Option::Some(id.into());
111  }
112
113  /// Attempts to transition to `next`. Returns `APH_E002` on illegal
114  /// transitions; leaves `self.state` unchanged on error.
115  pub fn transition_to(
116    &mut self,
117    next: HumanNotPresentNotarizationState,
118  ) -> std::result::Result<(), super::errors::AphError> {
119    if self.state.can_transition_to(next) {
120      self.state = next;
121      std::result::Result::Ok(())
122    } else {
123      std::result::Result::Err(super::errors::AphError::invalid_flow_transition(
124        format!("{:?}", self.state),
125        format!("{:?}", next),
126      ))
127    }
128  }
129}
130
131#[cfg(test)]
132mod tests {
133  #[test]
134  fn terminal_states() {
135    // Same absorbing-state rule as the human-present flow (spec §9.2),
136    // pinned separately because this machine has its own 5-state table
137    // that could drift from its sibling.
138    std::assert!(super::HumanNotPresentNotarizationState::Delivered.is_terminal());
139    std::assert!(super::HumanNotPresentNotarizationState::Denied.is_terminal());
140    std::assert!(!super::HumanNotPresentNotarizationState::Drafted.is_terminal());
141    std::assert!(!super::HumanNotPresentNotarizationState::MandateIssued.is_terminal());
142    std::assert!(!super::HumanNotPresentNotarizationState::EnvelopeIssued.is_terminal());
143  }
144
145  #[test]
146  fn full_transition_walk_delivered_path() {
147    // The standing-delegation path: no PendingDecision state, because
148    // authority was granted in advance. Walking it end to end pins that
149    // the shorter machine still reaches Delivered.
150    let mut flow = super::HumanNotPresentNotarizationFlow::new("dm-1");
151    std::assert_eq!(
152      flow.state(),
153      super::HumanNotPresentNotarizationState::Drafted
154    );
155    std::assert_eq!(flow.delegation_mandate_id(), "dm-1");
156
157    flow
158      .transition_to(super::HumanNotPresentNotarizationState::MandateIssued)
159      .unwrap();
160    flow
161      .transition_to(super::HumanNotPresentNotarizationState::EnvelopeIssued)
162      .unwrap();
163    flow
164      .transition_to(super::HumanNotPresentNotarizationState::Delivered)
165      .unwrap();
166
167    std::assert_eq!(
168      flow.state(),
169      super::HumanNotPresentNotarizationState::Delivered
170    );
171    std::assert!(flow.state().is_terminal());
172  }
173
174  #[test]
175  fn denial_path() {
176    // Even with the human absent, the notary must be able to refuse
177    // (expired or out-of-scope delegation) — so Drafted → Denied has to
178    // exist without any human interaction.
179    let mut flow = super::HumanNotPresentNotarizationFlow::new("dm-deny");
180    flow
181      .transition_to(super::HumanNotPresentNotarizationState::Denied)
182      .unwrap();
183    std::assert_eq!(
184      flow.state(),
185      super::HumanNotPresentNotarizationState::Denied
186    );
187    std::assert!(flow.state().is_terminal());
188  }
189
190  #[test]
191  fn terminal_no_transitions() {
192    // Terminality enforced by the table, not merely reported by the flag.
193    std::assert!(
194      super::HumanNotPresentNotarizationState::Delivered
195        .valid_transitions()
196        .is_empty()
197    );
198    std::assert!(
199      super::HumanNotPresentNotarizationState::Denied
200        .valid_transitions()
201        .is_empty()
202    );
203  }
204
205  #[test]
206  fn invalid_transition_returns_error() {
207    // Illegal moves must fail with APH_E002 and leave state untouched.
208    // With no human in the loop, this table IS the only gate on how an
209    // unattended notarization may proceed.
210    let mut flow = super::HumanNotPresentNotarizationFlow::new("dm-bad");
211    let err = flow
212      .transition_to(super::HumanNotPresentNotarizationState::EnvelopeIssued)
213      .unwrap_err();
214    std::assert_eq!(err.code(), "APH_E002");
215    std::assert_eq!(
216      flow.state(),
217      super::HumanNotPresentNotarizationState::Drafted
218    );
219  }
220
221  #[test]
222  fn set_communication_mandate_id_works() {
223    // Binds the per-message mandate to the flow once issued; without it an
224    // audit could not tie the delivered message to the authority used.
225    let mut flow = super::HumanNotPresentNotarizationFlow::new("dm-cm");
226    std::assert!(flow.communication_mandate_id().is_none());
227    flow.set_communication_mandate_id("cm-7");
228    std::assert_eq!(
229      flow.communication_mandate_id(),
230      std::option::Option::Some("cm-7")
231    );
232  }
233
234  #[test]
235  fn set_envelope_id_works() {
236    // Completes the audit chain delegation → mandate → envelope; the id
237    // starts unset and must be bindable exactly once the envelope exists.
238    let mut flow = super::HumanNotPresentNotarizationFlow::new("dm-env");
239    std::assert!(flow.envelope_id().is_none());
240    flow.set_envelope_id("env-9");
241    std::assert_eq!(flow.envelope_id(), std::option::Option::Some("env-9"));
242  }
243
244  #[test]
245  fn state_serde_roundtrip() {
246    // Unattended flows are persisted and resumed by background workers, so
247    // a state that failed to round-trip would lose in-flight sends.
248    let variants = [
249      super::HumanNotPresentNotarizationState::Drafted,
250      super::HumanNotPresentNotarizationState::MandateIssued,
251      super::HumanNotPresentNotarizationState::EnvelopeIssued,
252      super::HumanNotPresentNotarizationState::Delivered,
253      super::HumanNotPresentNotarizationState::Denied,
254    ];
255    for v in &variants {
256      let json = serde_json::to_string(v).unwrap();
257      let back: super::HumanNotPresentNotarizationState = serde_json::from_str(&json).unwrap();
258      std::assert_eq!(*v, back, "round-trip mismatch for {:?}", v);
259    }
260  }
261}