Skip to main content

aph_core/
communication_mandate.rs

1//! CommunicationMandate — per-message authorization. References a parent
2//! `DelegationMandate` and binds to a specific outbound payload by hash.
3
4/// Single-use, per-message authorization derived from a standing
5/// `DelegationMandate` (or issued directly for a one-shot AskEveryTime
6/// decision). Signed by the notary.
7#[derive(
8  std::fmt::Debug,
9  std::clone::Clone,
10  std::cmp::PartialEq,
11  std::cmp::Eq,
12  serde::Serialize,
13  serde::Deserialize,
14)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct CommunicationMandate {
17  /// `urn:uuid:...` mandate identifier.
18  pub id: String,
19  /// Parent `DelegationMandate.id` (or `None` for one-shot AskEveryTime flow).
20  #[serde(default)]
21  pub delegation_mandate_id: std::option::Option<String>,
22  /// DID of the human principal (re-stated for tamper-detect).
23  pub human_principal_did: String,
24  /// DID of the agent sender (re-stated).
25  pub agent_did: String,
26  /// Channel kind, drawn from the closed set (§7.1.5).
27  ///
28  /// Typed to match the envelope's field for the same reason as
29  /// `content_class` below: §6.2 requires this value EQUAL the value
30  /// the resulting envelope carries, and that equality is only
31  /// checkable by the compiler if both sides are the same type.
32  pub channel_kind: crate::envelope::ChannelKind,
33  /// Recipient addressing (channel-shaped JSON; opaque to APH core).
34  pub recipient_addressing: serde_json::Value,
35  /// Content classification, drawn from the closed set (§7.1.6).
36  ///
37  /// Typed to match the envelope's field: §6.2 requires the mandate value
38  /// EQUAL the value the resulting envelope carries, and an equality between
39  /// two `String`s that are meant to be the same closed vocabulary is an
40  /// equality the compiler cannot help with.
41  pub content_class: crate::envelope::ContentClass,
42  /// SHA-256 hex of the outbound message body bytes (lowercase, no `0x`).
43  pub body_sha256: String,
44  /// Body size in bytes.
45  pub body_size: u64,
46  /// Policy decision the human made (`AlwaysAllow`, `AskEveryTime`, `NeverAllow`).
47  pub policy_decision: crate::envelope::PolicyDecision,
48  /// RFC 3339 issuance timestamp.
49  pub issued_at: String,
50  /// RFC 3339 expiry timestamp.
51  pub expires_at: String,
52  /// Notary service signature over the canonical JCS form MINUS `notary_signature`.
53  pub notary_signature: String,
54}
55
56#[cfg(test)]
57mod tests {
58  fn sample() -> super::CommunicationMandate {
59    super::CommunicationMandate {
60      id: String::from("urn:uuid:00000000-0000-4000-8000-000000000002"),
61      delegation_mandate_id: std::option::Option::Some(String::from(
62        "urn:uuid:00000000-0000-4000-8000-000000000001",
63      )),
64      human_principal_did: String::from("did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy"),
65      agent_did: String::from("did:web:agent.squillo.com"),
66      channel_kind: crate::envelope::ChannelKind::Slack,
67      recipient_addressing: serde_json::json!({
68        "teamId": "T01234567",
69        "channelId": "C01234567",
70        "parentTs": "1716249600.000100"
71      }),
72      content_class: crate::envelope::ContentClass::Reply,
73      // 64-char lowercase hex SHA-256 (the empty-string SHA-256).
74      body_sha256: String::from("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
75      body_size: 1842,
76      policy_decision: crate::envelope::PolicyDecision::AskEveryTime,
77      issued_at: String::from("2026-05-21T00:00:00Z"),
78      expires_at: String::from("2026-05-21T01:00:00Z"),
79      notary_signature: String::from("z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZW"),
80    }
81  }
82
83  #[test]
84  fn serde_round_trip_preserves_equality() {
85    // A CommunicationMandate is single-use and signed; losing a field on
86    // reload would mean authorizing a send whose terms differ from the
87    // ones the notary actually certified.
88    let m = sample();
89    let json = serde_json::to_string(&m).unwrap();
90    let back: super::CommunicationMandate = serde_json::from_str(&json).unwrap();
91    std::assert_eq!(m, back);
92  }
93
94  #[test]
95  fn serde_uses_camel_case_field_names() {
96    // Spec §6.2 fixes these key names, and notarySignature covers the
97    // canonical form built from them — renaming one both breaks interop
98    // and invalidates existing signatures.
99    let m = sample();
100    let json = serde_json::to_string(&m).unwrap();
101    std::assert!(json.contains("\"delegationMandateId\""));
102    std::assert!(json.contains("\"humanPrincipalDid\""));
103    std::assert!(json.contains("\"agentDid\""));
104    std::assert!(json.contains("\"channelKind\""));
105    std::assert!(json.contains("\"recipientAddressing\""));
106    std::assert!(json.contains("\"contentClass\""));
107    std::assert!(json.contains("\"bodySha256\""));
108    std::assert!(json.contains("\"bodySize\""));
109    std::assert!(json.contains("\"policyDecision\""));
110    std::assert!(json.contains("\"issuedAt\""));
111    std::assert!(json.contains("\"expiresAt\""));
112    std::assert!(json.contains("\"notarySignature\""));
113  }
114
115  #[test]
116  fn delegation_mandate_id_defaults_to_none_when_omitted() {
117    // One-shot AskEveryTime mandates have no parent delegation, so the id
118    // must be omissible — otherwise the human-present flow could not
119    // produce a parseable mandate at all.
120    let json = r#"{
121      "id": "urn:uuid:2",
122      "humanPrincipalDid": "did:key:h",
123      "agentDid": "did:web:a",
124      "channelKind": "slack",
125      "recipientAddressing": {"userId": "U1"},
126      "contentClass": "DM",
127      "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
128      "bodySize": 12,
129      "policyDecision": "AskEveryTime",
130      "issuedAt": "2026-05-21T00:00:00Z",
131      "expiresAt": "2026-05-21T01:00:00Z",
132      "notarySignature": "zsig"
133    }"#;
134    let m: super::CommunicationMandate = serde_json::from_str(json).unwrap();
135    std::assert_eq!(m.delegation_mandate_id, std::option::Option::None);
136  }
137
138  #[test]
139  fn body_sha256_is_64_hex_chars() {
140    // Pins the hash encoding the spec requires (64 lowercase hex, §7.1.6).
141    // A different encoding — uppercase, base64, or a "sha256:" prefix —
142    // would silently fail every recipient's body-hash comparison.
143    let m = sample();
144    std::assert_eq!(
145      m.body_sha256.len(),
146      64,
147      "SHA-256 hex must be exactly 64 chars"
148    );
149    std::assert!(
150      m.body_sha256.chars().all(|c| c.is_ascii_hexdigit()),
151      "body_sha256 must be lowercase hex"
152    );
153    std::assert!(
154      m.body_sha256.chars().all(|c| !c.is_ascii_uppercase()),
155      "body_sha256 must be lowercase"
156    );
157  }
158
159  #[test]
160  fn deny_unknown_fields_rejects_extra_key() {
161    // Strict parsing must reject unknown keys rather than dropping them,
162    // so a mandate cannot carry a restriction the verifier never sees.
163    let json = r#"{
164      "id": "urn:uuid:2",
165      "humanPrincipalDid": "did:key:h",
166      "agentDid": "did:web:a",
167      "channelKind": "slack",
168      "recipientAddressing": {"userId": "U1"},
169      "contentClass": "DM",
170      "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
171      "bodySize": 12,
172      "policyDecision": "AskEveryTime",
173      "issuedAt": "2026-05-21T00:00:00Z",
174      "expiresAt": "2026-05-21T01:00:00Z",
175      "notarySignature": "zsig",
176      "extraneousField": "should-fail"
177    }"#;
178    let result: std::result::Result<super::CommunicationMandate, _> = serde_json::from_str(json);
179    std::assert!(
180      result.is_err(),
181      "deny_unknown_fields must reject unknown keys"
182    );
183  }
184
185  #[test]
186  fn recipient_addressing_carries_channel_shaped_json() {
187    // recipientAddressing is deliberately opaque (§7.4) so new channels
188    // need no type changes; this pins that arbitrary channel-shaped JSON
189    // survives intact and stays reachable, rather than being flattened.
190    let m = sample();
191    let team_id = m
192      .recipient_addressing
193      .get("teamId")
194      .and_then(|v| v.as_str())
195      .unwrap_or("");
196    std::assert_eq!(team_id, "T01234567");
197  }
198}