Skip to main content

aph_core/
human_present_flow.rs

1//! Human-present notarization flow state machine.
2//!
3//! Models the path when a human is present at the device to make an
4//! AskEveryTime decision. 7 states.
5//!
6//! State diagram:
7//! ```text
8//! Drafted -> PendingDecision -> Approved -> MandateIssued
9//!                            -> Denied (terminal)
10//! Approved -> MandateIssued -> EnvelopeIssued -> Delivered (terminal)
11//! ```
12
13/// State in the APH human-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 HumanPresentNotarizationState {
26  /// Agent has drafted a message and requested notarization.
27  Drafted,
28  /// Notary has shown the approval modal to the human.
29  PendingDecision,
30  /// Human approved (AlwaysAllow scope match OR AskEveryTime accept).
31  Approved,
32  /// Notary issued the `CommunicationMandate`.
33  MandateIssued,
34  /// Notary signed and emitted the `NotarizationEnvelope`.
35  EnvelopeIssued,
36  /// Terminal: envelope delivered to channel adapter.
37  Delivered,
38  /// Terminal: human denied or NeverAllow scope match.
39  Denied,
40}
41
42impl HumanPresentNotarizationState {
43  /// Returns `true` if this state is terminal (no further transitions).
44  pub fn is_terminal(&self) -> bool {
45    matches!(self, Self::Delivered | Self::Denied)
46  }
47
48  /// Returns the set of states this state may transition to.
49  pub fn valid_transitions(&self) -> &'static [Self] {
50    match self {
51      Self::Drafted => &[Self::PendingDecision],
52      Self::PendingDecision => &[Self::Approved, Self::Denied],
53      Self::Approved => &[Self::MandateIssued],
54      Self::MandateIssued => &[Self::EnvelopeIssued],
55      Self::EnvelopeIssued => &[Self::Delivered],
56      Self::Delivered => &[],
57      Self::Denied => &[],
58    }
59  }
60
61  /// Returns `true` if `next` is a legal successor of `self`.
62  pub fn can_transition_to(&self, next: Self) -> bool {
63    self.valid_transitions().contains(&next)
64  }
65}
66
67/// A human-present APH notarization flow wrapping state + mandate references.
68#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq, serde::Serialize, serde::Deserialize)]
69pub struct HumanPresentNotarizationFlow {
70  state: HumanPresentNotarizationState,
71  communication_mandate_id: String,
72  envelope_id: std::option::Option<String>,
73}
74
75impl HumanPresentNotarizationFlow {
76  /// Constructs a new flow in the `Drafted` state bound to the given
77  /// `CommunicationMandate.id`.
78  pub fn new(communication_mandate_id: impl std::convert::Into<String>) -> Self {
79    Self {
80      state: HumanPresentNotarizationState::Drafted,
81      communication_mandate_id: communication_mandate_id.into(),
82      envelope_id: std::option::Option::None,
83    }
84  }
85
86  /// Current state of the flow.
87  pub fn state(&self) -> HumanPresentNotarizationState {
88    self.state
89  }
90
91  /// Bound `CommunicationMandate.id`.
92  pub fn communication_mandate_id(&self) -> &str {
93    &self.communication_mandate_id
94  }
95
96  /// Optionally-bound `NotarizationEnvelope.id` (set after `MandateIssued`).
97  pub fn envelope_id(&self) -> std::option::Option<&str> {
98    self.envelope_id.as_deref()
99  }
100
101  /// Binds the envelope identifier to this flow.
102  pub fn set_envelope_id(&mut self, id: impl std::convert::Into<String>) {
103    self.envelope_id = std::option::Option::Some(id.into());
104  }
105
106  /// Attempts to transition to `next`. Returns `APH_E002` on illegal
107  /// transitions; leaves `self.state` unchanged on error.
108  pub fn transition_to(
109    &mut self,
110    next: HumanPresentNotarizationState,
111  ) -> std::result::Result<(), super::errors::AphError> {
112    if self.state.can_transition_to(next) {
113      self.state = next;
114      std::result::Result::Ok(())
115    } else {
116      std::result::Result::Err(super::errors::AphError::invalid_flow_transition(
117        format!("{:?}", self.state),
118        format!("{:?}", next),
119      ))
120    }
121  }
122}
123
124#[cfg(test)]
125mod tests {
126  #[test]
127  fn terminal_states() {
128    // Delivered and Denied are the only absorbing states (spec §9.1). A
129    // non-terminal Denied would let a refused request be resurrected into
130    // an approval; a terminal Drafted would strand every new request.
131    std::assert!(super::HumanPresentNotarizationState::Delivered.is_terminal());
132    std::assert!(super::HumanPresentNotarizationState::Denied.is_terminal());
133    std::assert!(!super::HumanPresentNotarizationState::Drafted.is_terminal());
134    std::assert!(!super::HumanPresentNotarizationState::PendingDecision.is_terminal());
135    std::assert!(!super::HumanPresentNotarizationState::Approved.is_terminal());
136    std::assert!(!super::HumanPresentNotarizationState::MandateIssued.is_terminal());
137    std::assert!(!super::HumanPresentNotarizationState::EnvelopeIssued.is_terminal());
138  }
139
140  #[test]
141  fn full_transition_walk_approved_path() {
142    // Walks the entire happy path Drafted → … → Delivered in one test, so
143    // the states are proven reachable IN ORDER — pairwise checks could all
144    // pass while the end-to-end path is broken.
145    let mut flow = super::HumanPresentNotarizationFlow::new("cm-1");
146    std::assert_eq!(flow.state(), super::HumanPresentNotarizationState::Drafted);
147
148    flow
149      .transition_to(super::HumanPresentNotarizationState::PendingDecision)
150      .unwrap();
151    flow
152      .transition_to(super::HumanPresentNotarizationState::Approved)
153      .unwrap();
154    flow
155      .transition_to(super::HumanPresentNotarizationState::MandateIssued)
156      .unwrap();
157    flow
158      .transition_to(super::HumanPresentNotarizationState::EnvelopeIssued)
159      .unwrap();
160    flow
161      .transition_to(super::HumanPresentNotarizationState::Delivered)
162      .unwrap();
163
164    std::assert_eq!(
165      flow.state(),
166      super::HumanPresentNotarizationState::Delivered
167    );
168    std::assert!(flow.state().is_terminal());
169    std::assert_eq!(flow.communication_mandate_id(), "cm-1");
170  }
171
172  #[test]
173  fn denial_path() {
174    // The branch that encodes a human saying no. If PendingDecision could
175    // not reach Denied, a refusal would have nowhere to land — the single
176    // most important outcome for a consent protocol to represent.
177    let mut flow = super::HumanPresentNotarizationFlow::new("cm-deny");
178    flow
179      .transition_to(super::HumanPresentNotarizationState::PendingDecision)
180      .unwrap();
181    flow
182      .transition_to(super::HumanPresentNotarizationState::Denied)
183      .unwrap();
184
185    std::assert_eq!(flow.state(), super::HumanPresentNotarizationState::Denied);
186    std::assert!(flow.state().is_terminal());
187  }
188
189  #[test]
190  fn terminal_no_transitions() {
191    // Terminality must be enforced by the transition table itself, not
192    // just reported by is_terminal() — an outgoing edge from a terminal
193    // state would make the flag a lie.
194    std::assert!(
195      super::HumanPresentNotarizationState::Delivered
196        .valid_transitions()
197        .is_empty()
198    );
199    std::assert!(
200      super::HumanPresentNotarizationState::Denied
201        .valid_transitions()
202        .is_empty()
203    );
204  }
205
206  #[test]
207  fn invalid_transition_returns_error() {
208    // An illegal move must return APH_E002 and leave the flow unchanged,
209    // never panic or half-apply — the state machine is fed by remote input.
210    let mut flow = super::HumanPresentNotarizationFlow::new("cm-bad");
211    let err = flow
212      .transition_to(super::HumanPresentNotarizationState::Delivered)
213      .unwrap_err();
214    std::assert_eq!(err.code(), "APH_E002");
215  }
216
217  #[test]
218  fn skip_state_rejected() {
219    // The security core of the human-present flow: jumping Drafted →
220    // MandateIssued would mint authority WITHOUT passing through
221    // PendingDecision — i.e. without the human ever approving.
222    let mut flow = super::HumanPresentNotarizationFlow::new("cm-skip");
223    let err = flow
224      .transition_to(super::HumanPresentNotarizationState::MandateIssued)
225      .unwrap_err();
226    std::assert_eq!(err.code(), "APH_E002");
227    std::assert_eq!(flow.state(), super::HumanPresentNotarizationState::Drafted);
228  }
229
230  #[test]
231  fn backward_transition_rejected() {
232    // The flow is one-way. Rewinding would allow re-deciding an already
233    // decided request — a replay path around the human's answer.
234    let mut flow = super::HumanPresentNotarizationFlow::new("cm-back");
235    flow
236      .transition_to(super::HumanPresentNotarizationState::PendingDecision)
237      .unwrap();
238    flow
239      .transition_to(super::HumanPresentNotarizationState::Approved)
240      .unwrap();
241    flow
242      .transition_to(super::HumanPresentNotarizationState::MandateIssued)
243      .unwrap();
244    let err = flow
245      .transition_to(super::HumanPresentNotarizationState::Drafted)
246      .unwrap_err();
247    std::assert_eq!(err.code(), "APH_E002");
248    std::assert_eq!(
249      flow.state(),
250      super::HumanPresentNotarizationState::MandateIssued
251    );
252  }
253
254  #[test]
255  fn set_envelope_id_works() {
256    // The envelope id starts unset and is bound once the envelope is
257    // issued; this link is what ties a completed flow to the credential it
258    // produced for later audit.
259    let mut flow = super::HumanPresentNotarizationFlow::new("cm-env");
260    std::assert!(flow.envelope_id().is_none());
261    flow.set_envelope_id("env-42");
262    std::assert_eq!(flow.envelope_id(), std::option::Option::Some("env-42"));
263  }
264
265  #[test]
266  fn state_serde_roundtrip() {
267    // Flow state is persisted across process restarts (a human may take
268    // minutes to answer), so a state that failed to round-trip would
269    // orphan an in-flight consent request.
270    let variants = [
271      super::HumanPresentNotarizationState::Drafted,
272      super::HumanPresentNotarizationState::PendingDecision,
273      super::HumanPresentNotarizationState::Approved,
274      super::HumanPresentNotarizationState::MandateIssued,
275      super::HumanPresentNotarizationState::EnvelopeIssued,
276      super::HumanPresentNotarizationState::Delivered,
277      super::HumanPresentNotarizationState::Denied,
278    ];
279    for v in &variants {
280      let json = serde_json::to_string(v).unwrap();
281      let back: super::HumanPresentNotarizationState = serde_json::from_str(&json).unwrap();
282      std::assert_eq!(*v, back, "round-trip mismatch for {:?}", v);
283    }
284  }
285}