Skip to main content

aph_core/
errors.rs

1//! APH error taxonomy.
2//!
3//! Codes APH_E001 .. APH_E015. Each variant carries a `code() -> &'static str`
4//! and `suggestion() -> &'static str`.
5
6/// APH protocol error with structured codes and suggestions.
7#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq, thiserror::Error)]
8pub enum AphError {
9  /// `APH_E001` — the envelope's `proof.proofValue` did not verify against
10  /// the notary's public key over the canonical form.
11  #[error("APH_E001: invalid envelope signature")]
12  InvalidEnvelopeSignature,
13
14  /// `APH_E002` — a notarization flow was driven along an edge the state
15  /// machine does not define.
16  #[error("APH_E002: invalid flow transition from `{from}` to `{to}`")]
17  InvalidFlowTransition {
18    /// State the flow was in.
19    from: String,
20    /// State the caller attempted to move to.
21    to: String,
22  },
23
24  /// `APH_E003` — the referenced mandate is outside its validity window.
25  #[error("APH_E003: mandate expired: {mandate_id}")]
26  MandateExpired {
27    /// Identifier of the expired mandate.
28    mandate_id: String,
29  },
30
31  /// `APH_E004` — a party attempted an operation its role does not permit
32  /// under the §5 permission matrix.
33  #[error("APH_E004: role `{role}` cannot perform `{operation}`")]
34  RoleViolation {
35    /// Role of the party that made the attempt.
36    role: String,
37    /// Operation that was attempted.
38    operation: String,
39  },
40
41  /// `APH_E005` — the channel is not listed in the delegation mandate's
42  /// `allowedChannels`.
43  #[error("APH_E005: channel `{channel}` not allowed by delegation mandate")]
44  ChannelNotAllowed {
45    /// Channel kind that fell outside the granted scope.
46    channel: String,
47  },
48
49  /// `APH_E006` — a mandate's `notarySignature` did not verify (distinct
50  /// from `APH_E001`, which covers the envelope-level proof).
51  #[error("APH_E006: notary service signature invalid")]
52  NotarySignatureInvalid,
53
54  /// `APH_E007` — the operation requires a human decision that has not
55  /// been obtained.
56  #[error("APH_E007: human authentication required")]
57  HumanAuthenticationRequired,
58
59  /// `APH_E008` — a protocol-mandated fetch from a notary-hosted surface
60  /// did not succeed, so no decision could be made either way: the service
61  /// itself could not be reached, or a document it is contracted to serve
62  /// could not be reached, parsed, or validated — a DID Document (spec
63  /// §8.4.4) or a revocation status list credential (spec §6.3.3.4 case 2,
64  /// which folds TLS, parse, proof, issuer, purpose and freshness failures
65  /// into this one code because the verifier's action and the operator's
66  /// remedy are identical in all of them; log the specific cause, do not
67  /// mint a code for it). Distinct from `APH_E014`, which means the
68  /// surface ANSWERED and published nothing.
69  #[error("APH_E008: notary service unreachable")]
70  NotaryServiceUnreachable,
71
72  /// `APH_E009` — the delivered body does not hash to the value the
73  /// envelope attests, indicating tampering or a mismatched payload.
74  #[error("APH_E009: envelope body hash mismatch (expected `{expected}`, got `{actual}`)")]
75  EnvelopeBodyHashMismatch {
76    /// Hash recorded in `communication.bodySha256`.
77    expected: String,
78    /// Hash recomputed over the received body.
79    actual: String,
80  },
81
82  /// `APH_E010` — the declared algorithm is outside the allow-list
83  /// (`ES256`, `EdDSA`); `alg: none` is always rejected here.
84  #[error("APH_E010: unsupported signature algorithm: {alg}")]
85  UnsupportedAlgorithm {
86    /// The rejected algorithm identifier.
87    alg: String,
88  },
89
90  /// `APH_E011` — a signature made by the HUMAN's key did not verify:
91  /// the principal proof of a chain, or an embedded delegation mandate's
92  /// `principalSignature`. Deliberately distinct from `APH_E001` and
93  /// `APH_E006`, which are both NOTARY signatures — conflating them would
94  /// report a forged authorization as a notary misconfiguration.
95  #[error("APH_E011: principal signature invalid")]
96  PrincipalSignatureInvalid,
97
98  /// `APH_E012` — the verifier requires `PrincipalSigned` and the envelope
99  /// is `NotaryAttested`. Not a defect in the envelope: a refusal to accept
100  /// the weaker claim, which the spec forbids doing silently.
101  #[error("APH_E012: attestation mode refused: required `{required}`, envelope is `{actual}`")]
102  AttestationModeRefused {
103    /// The mode the verifier's policy demands.
104    required: String,
105    /// The mode the envelope actually declares.
106    actual: String,
107  },
108
109  /// `APH_E013` — the proof chain is malformed: wrong length, wrong
110  /// `proofPurpose` for a position, or a `previousProof` that is missing,
111  /// dangling, duplicated, or cyclic.
112  #[error("APH_E013: proof chain invalid: {reason}")]
113  ProofChainInvalid {
114    /// What specifically is wrong with the chain.
115    reason: String,
116  },
117
118  /// `APH_E014` — no notary key is published at the queried discovery
119  /// surface: the DNS TXT name carries no APH record (or none matching the
120  /// named `kid`), or a fetched DID Document names no key under the queried
121  /// fragment. Deliberately distinct from `APH_E008`, which means the
122  /// surface could not be REACHED: §8.4.6's no-downgrade rule turns on
123  /// exactly this distinction — absence advances the fallback sequence,
124  /// failure must stop it — and a taxonomy that flattens the two forces
125  /// every consumer's error surface to flatten them again.
126  #[error("APH_E014: notary key not published: {surface}")]
127  NotaryKeyNotPublished {
128    /// Which discovery surface answered "nothing is published here".
129    surface: String,
130  },
131
132  /// `APH_E015` — the parent delegation mandate's bit is SET in the
133  /// revocation status list its issuing notary publishes (spec §6.3.3):
134  /// the human withdrew the standing authority this envelope was issued
135  /// under. The signatures are still valid — this is a WITHDRAWN
136  /// authorization, not a forged one, so reporting it as a signature
137  /// failure would send an operator to inspect key material when the
138  /// answer is a human decision. Deliberately distinct from `APH_E003`,
139  /// which is authority that ran out on schedule rather than authority
140  /// that was pulled.
141  ///
142  /// Carries the mandate id alone, mirroring `APH_E003`: the revocation
143  /// timestamp lives in the notary's status state, not in the refusal.
144  #[error("APH_E015: delegation mandate revoked: {mandate_id}")]
145  MandateRevoked {
146    /// Identifier of the revoked delegation mandate.
147    mandate_id: String,
148  },
149}
150
151impl AphError {
152  // ── Code + suggestion ──────────────────────────────────────────────
153
154  /// Returns the APH error code string (e.g., `"APH_E001"`).
155  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  /// Returns a human-readable suggestion for resolving this error.
176  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  // ── Constructors ────────────────────────────────────────────────────
197
198  /// Builds an `APH_E002` naming the illegal edge that was attempted.
199  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  /// Builds an `APH_E003` naming the expired mandate.
210  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  /// Builds an `APH_E014` naming the surface that published nothing.
217  pub fn notary_key_not_published(surface: impl std::convert::Into<String>) -> Self {
218    Self::NotaryKeyNotPublished {
219      surface: surface.into(),
220    }
221  }
222
223  /// Builds an `APH_E004` naming the role and the denied operation.
224  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  /// Builds an `APH_E005` naming the out-of-scope channel.
235  pub fn channel_not_allowed(channel: impl std::convert::Into<String>) -> Self {
236    Self::ChannelNotAllowed {
237      channel: channel.into(),
238    }
239  }
240
241  /// Builds an `APH_E009` carrying both the attested and the recomputed
242  /// hash so an investigator can tell which side diverged.
243  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  /// Builds an `APH_E010` naming the rejected algorithm.
254  pub fn unsupported_algorithm(alg: impl std::convert::Into<String>) -> Self {
255    Self::UnsupportedAlgorithm { alg: alg.into() }
256  }
257
258  /// Builds an `APH_E012` recording both modes, so an operator can see what
259  /// was demanded and what arrived without re-parsing the envelope.
260  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  /// Builds an `APH_E013` naming what is wrong with the chain.
271  pub fn proof_chain_invalid(reason: impl std::convert::Into<String>) -> Self {
272    Self::ProofChainInvalid {
273      reason: reason.into(),
274    }
275  }
276
277  /// Builds an `APH_E015` naming the revoked delegation mandate.
278  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    // The spec (§11) fixes a CLOSED set of exactly fifteen codes, and
310    // other implementations branch on them. A duplicate would make two
311    // distinct failures indistinguishable to a remote verifier. The count
312    // is pinned so that adding a code without amending §11 fails here.
313    //
314    // The guarantee in that last sentence had already failed once: this
315    // list omitted `NotaryKeyNotPublished` from the day APH_E014 landed,
316    // so the count stayed at thirteen against a §11 that said fourteen and
317    // E014 was swept by NONE of the three all_variants() tests below. Both
318    // missing entries are restored here alongside APH_E015 — the sweep is
319    // only worth its comment if the list it walks is the whole enum.
320    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    // A verifier reports WHY a credential failed, and the three signature
332    // failures mean entirely different things: APH_E001 is the notary's
333    // envelope proof, APH_E006 the notary's mandate signature, APH_E011 the
334    // HUMAN's own signature. Only the last means the authorization itself
335    // is forged or corrupt. Collapsing them would report a forged
336    // authorization as a notary misconfiguration, which is the wrong alarm.
337    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    // Refusing a downgrade is a policy decision, not a malformed envelope,
345    // so the error has to say what was demanded AND what arrived — an
346    // operator reading a log must be able to tell a misconfigured verifier
347    // from a sender that never signed.
348    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    // A chain can be malformed in several distinct ways (length, purpose,
357    // dangling previousProof). One opaque code per family would leave an
358    // implementer guessing, so the reason travels with the error.
359    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    // Code strings are a cross-implementation contract; consumers pattern
367    // match on the APH_Ennn shape, so a stray format would not merely look
368    // wrong, it would fail their matchers.
369    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    // The spec says implementations SHOULD surface a suggested resolution;
383    // an empty one leaves an operator staring at a bare code with no path
384    // forward. Easy to forget when adding a variant, hence the sweep.
385    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    // Logs usually capture only the Display string, so the code must be
397    // embedded in it — otherwise a failure in production cannot be mapped
398    // back to its spec-defined cause.
399    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    // Constructors take context arguments that only reach an operator via
413    // the message; if from/to were dropped, an APH_E002 would say a
414    // transition was illegal without saying which one.
415    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    // The mandate id is what lets an operator find and re-issue the
425    // expired delegation; losing it makes APH_E003 unactionable.
426    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    // A permission-matrix denial is a security event: both the role and
435    // the attempted operation must survive into the message, or the audit
436    // trail cannot say who tried to do what.
437    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    // Names the channel that fell outside the delegation's scope — the
447    // detail a human needs to decide whether to widen the mandate.
448    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    // APH_E009 means the delivered body does not match what was notarized
457    // — possible tampering. BOTH hashes must appear so an investigator can
458    // tell which side changed rather than just that something did.
459    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    // Expiry and revocation are the two ways standing authority ends, and
469    // they call for opposite responses: an expired mandate is routine
470    // housekeeping, a revoked one is a human who changed their mind and
471    // whose decision must not be re-granted by reflex. Collapsing them
472    // into one code would hide that, and the mandate id is what lets an
473    // operator find WHICH grant was pulled — APH_E015 is unactionable
474    // without it, exactly as APH_E003 is.
475    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    // Uses "none" deliberately: alg:none is the classic JWS downgrade
486    // attack, and the spec requires rejecting it with APH_E010. The
487    // rejected algorithm name must reach the log to make the attempt
488    // visible rather than silently dropped.
489    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}