1#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq, thiserror::Error)]
8pub enum AphError {
9 #[error("APH_E001: invalid envelope signature")]
12 InvalidEnvelopeSignature,
13
14 #[error("APH_E002: invalid flow transition from `{from}` to `{to}`")]
17 InvalidFlowTransition {
18 from: String,
20 to: String,
22 },
23
24 #[error("APH_E003: mandate expired: {mandate_id}")]
26 MandateExpired {
27 mandate_id: String,
29 },
30
31 #[error("APH_E004: role `{role}` cannot perform `{operation}`")]
34 RoleViolation {
35 role: String,
37 operation: String,
39 },
40
41 #[error("APH_E005: channel `{channel}` not allowed by delegation mandate")]
44 ChannelNotAllowed {
45 channel: String,
47 },
48
49 #[error("APH_E006: notary service signature invalid")]
52 NotarySignatureInvalid,
53
54 #[error("APH_E007: human authentication required")]
57 HumanAuthenticationRequired,
58
59 #[error("APH_E008: notary service unreachable")]
70 NotaryServiceUnreachable,
71
72 #[error("APH_E009: envelope body hash mismatch (expected `{expected}`, got `{actual}`)")]
75 EnvelopeBodyHashMismatch {
76 expected: String,
78 actual: String,
80 },
81
82 #[error("APH_E010: unsupported signature algorithm: {alg}")]
85 UnsupportedAlgorithm {
86 alg: String,
88 },
89
90 #[error("APH_E011: principal signature invalid")]
96 PrincipalSignatureInvalid,
97
98 #[error("APH_E012: attestation mode refused: required `{required}`, envelope is `{actual}`")]
102 AttestationModeRefused {
103 required: String,
105 actual: String,
107 },
108
109 #[error("APH_E013: proof chain invalid: {reason}")]
113 ProofChainInvalid {
114 reason: String,
116 },
117
118 #[error("APH_E014: notary key not published: {surface}")]
127 NotaryKeyNotPublished {
128 surface: String,
130 },
131
132 #[error("APH_E015: delegation mandate revoked: {mandate_id}")]
145 MandateRevoked {
146 mandate_id: String,
148 },
149}
150
151impl AphError {
152 pub fn code(&self) -> &'static str {
156 match self {
157 Self::InvalidEnvelopeSignature => "APH_E001",
158 Self::InvalidFlowTransition { .. } => "APH_E002",
159 Self::MandateExpired { .. } => "APH_E003",
160 Self::RoleViolation { .. } => "APH_E004",
161 Self::ChannelNotAllowed { .. } => "APH_E005",
162 Self::NotarySignatureInvalid => "APH_E006",
163 Self::HumanAuthenticationRequired => "APH_E007",
164 Self::NotaryServiceUnreachable => "APH_E008",
165 Self::EnvelopeBodyHashMismatch { .. } => "APH_E009",
166 Self::UnsupportedAlgorithm { .. } => "APH_E010",
167 Self::PrincipalSignatureInvalid => "APH_E011",
168 Self::AttestationModeRefused { .. } => "APH_E012",
169 Self::ProofChainInvalid { .. } => "APH_E013",
170 Self::NotaryKeyNotPublished { .. } => "APH_E014",
171 Self::MandateRevoked { .. } => "APH_E015",
172 }
173 }
174
175 pub fn suggestion(&self) -> &'static str {
177 match self {
178 Self::InvalidEnvelopeSignature => "Verify the notary signing key and re-sign",
179 Self::InvalidFlowTransition { .. } => "Check the APH notarization flow state machine",
180 Self::MandateExpired { .. } => "Issue a fresh mandate with a future `validUntil`",
181 Self::RoleViolation { .. } => "Ensure the party holds the correct AphPartyRole",
182 Self::ChannelNotAllowed { .. } => "Grant the channel scope on the delegation mandate",
183 Self::NotarySignatureInvalid => "Verify the notary's JWK matches `verificationMethod`",
184 Self::HumanAuthenticationRequired => "Prompt the human for AskEveryTime confirmation",
185 Self::NotaryServiceUnreachable => "Check notary endpoint health + retry with backoff",
186 Self::EnvelopeBodyHashMismatch { .. } => "Re-hash the body and compare against `bodySha256`",
187 Self::UnsupportedAlgorithm { .. } => "Use one of `ES256` or `EdDSA`; reject `alg: none`",
188 Self::PrincipalSignatureInvalid => "Re-sign with the human's key; check it matches `humanPrincipalDid`",
189 Self::AttestationModeRefused { .. } => "Re-issue in `PrincipalSigned` mode, or relax the policy deliberately",
190 Self::ProofChainInvalid { .. } => "Emit principal proof then notary proof, linked by `previousProof`",
191 Self::NotaryKeyNotPublished { .. } => "Publish the key at this surface (§8.4.4/§8.4.5), or query one the notary publishes to",
192 Self::MandateRevoked { .. } => "Obtain a fresh delegation mandate from the human principal; a revoked mandate cannot be re-activated (§6.3.2)",
193 }
194 }
195
196 pub fn invalid_flow_transition(
200 from: impl std::convert::Into<String>,
201 to: impl std::convert::Into<String>,
202 ) -> Self {
203 Self::InvalidFlowTransition {
204 from: from.into(),
205 to: to.into(),
206 }
207 }
208
209 pub fn mandate_expired(mandate_id: impl std::convert::Into<String>) -> Self {
211 Self::MandateExpired {
212 mandate_id: mandate_id.into(),
213 }
214 }
215
216 pub fn notary_key_not_published(surface: impl std::convert::Into<String>) -> Self {
218 Self::NotaryKeyNotPublished {
219 surface: surface.into(),
220 }
221 }
222
223 pub fn role_violation(
225 role: impl std::convert::Into<String>,
226 operation: impl std::convert::Into<String>,
227 ) -> Self {
228 Self::RoleViolation {
229 role: role.into(),
230 operation: operation.into(),
231 }
232 }
233
234 pub fn channel_not_allowed(channel: impl std::convert::Into<String>) -> Self {
236 Self::ChannelNotAllowed {
237 channel: channel.into(),
238 }
239 }
240
241 pub fn envelope_body_hash_mismatch(
244 expected: impl std::convert::Into<String>,
245 actual: impl std::convert::Into<String>,
246 ) -> Self {
247 Self::EnvelopeBodyHashMismatch {
248 expected: expected.into(),
249 actual: actual.into(),
250 }
251 }
252
253 pub fn unsupported_algorithm(alg: impl std::convert::Into<String>) -> Self {
255 Self::UnsupportedAlgorithm { alg: alg.into() }
256 }
257
258 pub fn attestation_mode_refused(
261 required: impl std::convert::Into<String>,
262 actual: impl std::convert::Into<String>,
263 ) -> Self {
264 Self::AttestationModeRefused {
265 required: required.into(),
266 actual: actual.into(),
267 }
268 }
269
270 pub fn proof_chain_invalid(reason: impl std::convert::Into<String>) -> Self {
272 Self::ProofChainInvalid {
273 reason: reason.into(),
274 }
275 }
276
277 pub fn mandate_revoked(mandate_id: impl std::convert::Into<String>) -> Self {
279 Self::MandateRevoked {
280 mandate_id: mandate_id.into(),
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 fn all_variants() -> std::vec::Vec<super::AphError> {
288 std::vec![
289 super::AphError::InvalidEnvelopeSignature,
290 super::AphError::invalid_flow_transition("Drafted", "Delivered"),
291 super::AphError::mandate_expired("urn:uuid:00000000-0000-4000-8000-000000000001"),
292 super::AphError::role_violation("AgentSender", "Notarize"),
293 super::AphError::channel_not_allowed("slack"),
294 super::AphError::NotarySignatureInvalid,
295 super::AphError::HumanAuthenticationRequired,
296 super::AphError::NotaryServiceUnreachable,
297 super::AphError::envelope_body_hash_mismatch("abc", "def"),
298 super::AphError::unsupported_algorithm("HS256"),
299 super::AphError::PrincipalSignatureInvalid,
300 super::AphError::attestation_mode_refused("PrincipalSigned", "NotaryAttested"),
301 super::AphError::proof_chain_invalid("previousProof does not name a proof in this chain"),
302 super::AphError::notary_key_not_published("_aph._notary.example.com"),
303 super::AphError::mandate_revoked("urn:uuid:00000000-0000-4000-8000-000000000002"),
304 ]
305 }
306
307 #[test]
308 fn fifteen_codes_are_unique() {
309 let errors = all_variants();
321 std::assert_eq!(errors.len(), 15);
322 let codes: std::vec::Vec<&str> = errors.iter().map(|e| e.code()).collect();
323 let mut unique = codes.clone();
324 unique.sort();
325 unique.dedup();
326 std::assert_eq!(codes.len(), unique.len(), "error codes must be unique");
327 }
328
329 #[test]
330 fn the_three_signature_failures_have_three_distinct_codes() {
331 std::assert_eq!(super::AphError::InvalidEnvelopeSignature.code(), "APH_E001");
338 std::assert_eq!(super::AphError::NotarySignatureInvalid.code(), "APH_E006");
339 std::assert_eq!(super::AphError::PrincipalSignatureInvalid.code(), "APH_E011");
340 }
341
342 #[test]
343 fn attestation_mode_refusal_names_both_modes() {
344 let err = super::AphError::attestation_mode_refused("PrincipalSigned", "NotaryAttested");
349 let text = std::string::ToString::to_string(&err);
350 std::assert!(text.contains("PrincipalSigned"), "missing required mode: {}", text);
351 std::assert!(text.contains("NotaryAttested"), "missing actual mode: {}", text);
352 }
353
354 #[test]
355 fn proof_chain_invalid_carries_the_reason() {
356 let err = super::AphError::proof_chain_invalid("previousProof is dangling");
360 std::assert_eq!(err.code(), "APH_E013");
361 std::assert!(std::string::ToString::to_string(&err).contains("dangling"));
362 }
363
364 #[test]
365 fn codes_follow_aph_e_nnn_format() {
366 for err in all_variants() {
370 let code = err.code();
371 std::assert!(
372 code.starts_with("APH_E"),
373 "code `{}` lacks APH_E prefix",
374 code
375 );
376 std::assert_eq!(code.len(), 8, "code `{}` is not 8 chars", code);
377 }
378 }
379
380 #[test]
381 fn every_variant_has_non_empty_suggestion() {
382 for err in all_variants() {
386 std::assert!(
387 !err.suggestion().is_empty(),
388 "{} has empty suggestion",
389 err.code()
390 );
391 }
392 }
393
394 #[test]
395 fn display_includes_code_for_every_variant() {
396 for err in all_variants() {
400 let msg = format!("{err}");
401 std::assert!(
402 msg.contains(err.code()),
403 "Display `{}` does not include code `{}`",
404 msg,
405 err.code()
406 );
407 }
408 }
409
410 #[test]
411 fn invalid_flow_transition_constructor_preserves_strings() {
412 let err = super::AphError::invalid_flow_transition("Drafted", "EnvelopeIssued");
416 let msg = format!("{err}");
417 std::assert!(msg.contains("Drafted"));
418 std::assert!(msg.contains("EnvelopeIssued"));
419 std::assert_eq!(err.code(), "APH_E002");
420 }
421
422 #[test]
423 fn mandate_expired_constructor_preserves_id() {
424 let err = super::AphError::mandate_expired("urn:uuid:abc");
427 let msg = format!("{err}");
428 std::assert!(msg.contains("urn:uuid:abc"));
429 std::assert_eq!(err.code(), "APH_E003");
430 }
431
432 #[test]
433 fn role_violation_constructor_preserves_role_and_op() {
434 let err = super::AphError::role_violation("AgentSender", "Notarize");
438 let msg = format!("{err}");
439 std::assert!(msg.contains("AgentSender"));
440 std::assert!(msg.contains("Notarize"));
441 std::assert_eq!(err.code(), "APH_E004");
442 }
443
444 #[test]
445 fn channel_not_allowed_constructor_preserves_channel() {
446 let err = super::AphError::channel_not_allowed("discord");
449 let msg = format!("{err}");
450 std::assert!(msg.contains("discord"));
451 std::assert_eq!(err.code(), "APH_E005");
452 }
453
454 #[test]
455 fn envelope_body_hash_mismatch_constructor_preserves_hashes() {
456 let err = super::AphError::envelope_body_hash_mismatch("sha256:aaa", "sha256:bbb");
460 let msg = format!("{err}");
461 std::assert!(msg.contains("sha256:aaa"));
462 std::assert!(msg.contains("sha256:bbb"));
463 std::assert_eq!(err.code(), "APH_E009");
464 }
465
466 #[test]
467 fn revoked_is_not_expired_and_names_the_mandate() {
468 let revoked = super::AphError::mandate_revoked("urn:uuid:def");
476 let expired = super::AphError::mandate_expired("urn:uuid:def");
477 std::assert_eq!(revoked.code(), "APH_E015");
478 std::assert_eq!(expired.code(), "APH_E003");
479 std::assert_ne!(revoked, expired);
480 std::assert!(std::string::ToString::to_string(&revoked).contains("urn:uuid:def"));
481 }
482
483 #[test]
484 fn unsupported_algorithm_constructor_preserves_alg() {
485 let err = super::AphError::unsupported_algorithm("none");
490 let msg = format!("{err}");
491 std::assert!(msg.contains("none"));
492 std::assert_eq!(err.code(), "APH_E010");
493 }
494}