Skip to main content

aph_core/
roles.rs

1//! APH party roles + permitted operations.
2//!
3//! 5 roles: HumanPrincipal, AgentSender, NotaryService, ChannelAdapter,
4//! RecipientEndpoint.
5
6/// One of the five APH protocol roles.
7#[derive(
8  std::fmt::Debug,
9  std::clone::Clone,
10  std::marker::Copy,
11  std::cmp::PartialEq,
12  std::cmp::Eq,
13  std::hash::Hash,
14  serde::Serialize,
15  serde::Deserialize,
16)]
17#[serde(rename_all = "camelCase")]
18pub enum AphPartyRole {
19  /// The natural person on whose behalf an outbound communication is sent.
20  HumanPrincipal,
21  /// The agent (LLM-backed software) drafting the message.
22  AgentSender,
23  /// The notary service issuing the verifiable credential.
24  NotaryService,
25  /// The channel adapter (Slack/Email/Discord/...) transporting the message.
26  ChannelAdapter,
27  /// The far-end recipient endpoint verifying the credential.
28  RecipientEndpoint,
29}
30
31/// An APH operation that a role may perform.
32#[derive(
33  std::fmt::Debug,
34  std::clone::Clone,
35  std::marker::Copy,
36  std::cmp::PartialEq,
37  std::cmp::Eq,
38  std::hash::Hash,
39  serde::Serialize,
40  serde::Deserialize,
41)]
42#[serde(rename_all = "camelCase")]
43pub enum AphOperation {
44  /// Issue a `DelegationMandate` granting an agent ongoing authority.
45  IssueDelegationMandate,
46  /// Issue a `CommunicationMandate` for a single outbound message.
47  IssueCommunicationMandate,
48  /// Sign + emit a `NotarizationEnvelope`.
49  Notarize,
50  /// Carry an envelope across a transport channel.
51  Transport,
52  /// Verify an inbound envelope's signature + policy.
53  Verify,
54  /// Reject an envelope per local policy.
55  Reject,
56}
57
58impl AphPartyRole {
59  /// Returns the operations this role is allowed to perform.
60  pub fn allowed_operations(&self) -> &'static [AphOperation] {
61    match self {
62      Self::HumanPrincipal => &[
63        AphOperation::IssueDelegationMandate,
64        AphOperation::IssueCommunicationMandate,
65      ],
66      Self::AgentSender => &[AphOperation::IssueCommunicationMandate],
67      Self::NotaryService => &[AphOperation::Notarize, AphOperation::Reject],
68      Self::ChannelAdapter => &[AphOperation::Transport],
69      Self::RecipientEndpoint => &[AphOperation::Verify],
70    }
71  }
72
73  /// Returns `true` if this role is permitted to perform `op`.
74  pub fn can_perform(&self, op: AphOperation) -> bool {
75    self.allowed_operations().contains(&op)
76  }
77
78  /// All role variants for iteration.
79  pub fn all() -> &'static [AphPartyRole] {
80    &[
81      Self::HumanPrincipal,
82      Self::AgentSender,
83      Self::NotaryService,
84      Self::ChannelAdapter,
85      Self::RecipientEndpoint,
86    ]
87  }
88
89  /// Human-readable label for this role.
90  pub fn label(&self) -> &'static str {
91    match self {
92      Self::HumanPrincipal => "Human Principal",
93      Self::AgentSender => "Agent Sender",
94      Self::NotaryService => "Notary Service",
95      Self::ChannelAdapter => "Channel Adapter",
96      Self::RecipientEndpoint => "Recipient Endpoint",
97    }
98  }
99}
100
101impl AphOperation {
102  /// All operation variants for iteration.
103  pub fn all() -> &'static [AphOperation] {
104    &[
105      Self::IssueDelegationMandate,
106      Self::IssueCommunicationMandate,
107      Self::Notarize,
108      Self::Transport,
109      Self::Verify,
110      Self::Reject,
111    ]
112  }
113}
114
115#[cfg(test)]
116mod tests {
117  #[test]
118  fn role_serde_roundtrip_all_variants() {
119    // Iterates all() rather than a fixed list so a newly added role is
120    // covered automatically — a variant that serialized but failed to
121    // deserialize would break any persisted or transmitted role.
122    for role in super::AphPartyRole::all() {
123      let json = serde_json::to_string(role).unwrap();
124      let back: super::AphPartyRole = serde_json::from_str(&json).unwrap();
125      std::assert_eq!(*role, back);
126    }
127  }
128
129  #[test]
130  fn operation_serde_roundtrip_all_variants() {
131    // Same exhaustive sweep for operations: these name the actions a role
132    // is permitted to take, so an unparseable variant would strand an
133    // audit record no one can interpret.
134    for op in super::AphOperation::all() {
135      let json = serde_json::to_string(op).unwrap();
136      let back: super::AphOperation = serde_json::from_str(&json).unwrap();
137      std::assert_eq!(*op, back);
138    }
139  }
140
141  #[test]
142  fn role_serde_uses_camel_case() {
143    // Pins the exact wire spelling (spec §5.1), checking both a two-word
144    // and a longest-name variant since rename_all is where casing bugs
145    // show up first. Other implementations match on these strings.
146    let json = serde_json::to_string(&super::AphPartyRole::HumanPrincipal).unwrap();
147    std::assert_eq!(json, "\"humanPrincipal\"");
148    let json = serde_json::to_string(&super::AphPartyRole::RecipientEndpoint).unwrap();
149    std::assert_eq!(json, "\"recipientEndpoint\"");
150  }
151
152  #[test]
153  fn operation_serde_uses_camel_case() {
154    // Same casing pin for operations (spec §5.2), covering both a
155    // multi-word and a single-word variant.
156    let json = serde_json::to_string(&super::AphOperation::IssueDelegationMandate).unwrap();
157    std::assert_eq!(json, "\"issueDelegationMandate\"");
158    let json = serde_json::to_string(&super::AphOperation::Notarize).unwrap();
159    std::assert_eq!(json, "\"notarize\"");
160  }
161
162  #[test]
163  fn every_operation_assigned_to_at_least_one_role() {
164    // Completeness guard on the permission matrix: an operation no role
165    // can perform is dead protocol surface — either the matrix has a gap
166    // or the operation should not exist.
167    for op in super::AphOperation::all() {
168      let assigned = super::AphPartyRole::all()
169        .iter()
170        .any(|r| r.can_perform(*op));
171      std::assert!(assigned, "{:?} is not assigned to any role", op);
172    }
173  }
174
175  #[test]
176  fn human_principal_can_issue_mandates() {
177    // Separation of duties: the human issues authority but must NOT be
178    // able to notarize. Collapsing those would let the authorizing party
179    // also vouch for itself, destroying third-party verifiability.
180    let role = super::AphPartyRole::HumanPrincipal;
181    std::assert!(role.can_perform(super::AphOperation::IssueDelegationMandate));
182    std::assert!(role.can_perform(super::AphOperation::IssueCommunicationMandate));
183    std::assert!(!role.can_perform(super::AphOperation::Notarize));
184    std::assert!(!role.can_perform(super::AphOperation::Transport));
185  }
186
187  #[test]
188  fn notary_service_can_notarize_and_reject_only() {
189    // The other half of separation of duties: a notary attests decisions
190    // but must not issue mandates, or it could manufacture the very
191    // authority it then certifies.
192    let role = super::AphPartyRole::NotaryService;
193    std::assert!(role.can_perform(super::AphOperation::Notarize));
194    std::assert!(role.can_perform(super::AphOperation::Reject));
195    std::assert!(!role.can_perform(super::AphOperation::IssueDelegationMandate));
196    std::assert!(!role.can_perform(super::AphOperation::Transport));
197    std::assert!(!role.can_perform(super::AphOperation::Verify));
198  }
199
200  #[test]
201  fn channel_adapter_can_only_transport() {
202    // The adapter is deliberately the least-privileged role: it moves
203    // bytes and nothing else. Granting it Notarize or Verify would let the
204    // delivery layer certify or accept its own traffic.
205    let role = super::AphPartyRole::ChannelAdapter;
206    std::assert!(role.can_perform(super::AphOperation::Transport));
207    std::assert!(!role.can_perform(super::AphOperation::Notarize));
208    std::assert!(!role.can_perform(super::AphOperation::Verify));
209    std::assert!(!role.can_perform(super::AphOperation::IssueCommunicationMandate));
210  }
211
212  #[test]
213  fn label_non_empty_for_all_roles() {
214    // Labels surface in consent prompts and audit logs; an empty one would
215    // render a blank role in a UI where the human is deciding whether to
216    // authorize an action.
217    for role in super::AphPartyRole::all() {
218      std::assert!(!role.label().is_empty(), "{:?} has empty label", role);
219    }
220  }
221
222  #[test]
223  fn all_returns_expected_counts() {
224    // all() is hand-maintained, so a variant added to either enum without
225    // being appended here would silently vanish from every exhaustive
226    // sweep above. The counts (5 roles, 6 operations) are spec-fixed.
227    std::assert_eq!(super::AphPartyRole::all().len(), 5);
228    std::assert_eq!(super::AphOperation::all().len(), 6);
229  }
230}