Skip to main content

aph_core/
errors.rs

1//! APH error taxonomy.
2//!
3//! Codes APH_E001 .. APH_E020. 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  /// `APH_E016` — a human-not-present notarization (§9.2) was attempted with
151  /// no matching, unexpired delegation mandate: nothing authorized this act.
152  /// Held distinct from THREE neighbours because the remedies differ
153  /// completely — `APH_E007` is *nobody was asked* (reach the human),
154  /// `APH_E011` is *the authorization presented is invalid* (inspect it),
155  /// `APH_E015` is *the authorization was withdrawn* (obtain a new one).
156  /// Reporting an ABSENT mandate under any of those conflates absence with
157  /// failure — the distinction this crate makes structural everywhere else
158  /// (`discovery::DiscoveryOutcome`). Added 2026-08-23 from an implementer
159  /// field report: the first production consumer had to misuse an
160  /// invalid-mandate code for exactly this case.
161  #[error("APH_E016: no delegation mandate authorizes this act (agent `{agent_did}`, channel `{channel_kind}`)")]
162  MandateRequired {
163    /// DID of the agent that attempted the act.
164    agent_did: String,
165    /// Channel kind the act targeted.
166    channel_kind: String,
167  },
168
169  /// `APH_E017` — the envelope names an audience that is not this verifier:
170  /// its `audience.id` differs from the verifier's own identity, or a
171  /// present `channelBinding` member differs from the delivery coordinates
172  /// of the act being performed.
173  ///
174  /// REGISTERED AHEAD OF ITS RFC. The `audience` field is RFC 0003 (Draft)
175  /// and no type in this crate carries it yet — the code exists now, per the
176  /// specification's additive-codes rule, so an implementation exercising
177  /// the draft ahead of ratification emits a stable code instead of minting
178  /// a private one. That is the same reasoning that added `APH_E016` from a
179  /// field report: the first consumer to need a code should not have to
180  /// misuse a neighbour. An envelope with no `audience` member never
181  /// produces this code — absence is the producer's decision to issue a
182  /// bearer credential, and RFC 0003 is explicit that a verifier that cannot
183  /// determine its OWN identity rejects rather than skips.
184  #[error("APH_E017: audience mismatch: envelope names `{audience_id}`, this verifier is `{verifier_id}`")]
185  AudienceMismatch {
186    /// The audience the envelope names (`credentialSubject.audience.id`).
187    audience_id: String,
188    /// The verifier's own identity, as it knows itself.
189    verifier_id: String,
190  },
191
192  /// `APH_E018` — this envelope was already accepted once. RFC 0003's
193  /// single-use step: a verifier records `id` at the moment it commits to
194  /// the act (acceptance in the RFC 5321 §6.1 sense) and refuses every
195  /// later presentation of the same `id`. The record is the RECIPIENT'S
196  /// ledger obligation — two independent verifiers each accept once, which
197  /// is why audience binding (`APH_E017`) and single-use are load-bearing
198  /// together and neither is sufficient alone.
199  #[error("APH_E018: envelope already spent: `{envelope_id}` was accepted before")]
200  EnvelopeAlreadySpent {
201    /// The `id` that was presented again after acceptance.
202    envelope_id: String,
203  },
204
205  /// `APH_E019` — the envelope's OWN validity window fails against the
206  /// verifier's clock: `validFrom` is in the future or `validUntil` is in
207  /// the past. Deliberately distinct from `APH_E003`, which is a *mandate*
208  /// consulted past its expiry; before this code existed an implementer
209  /// refusing an expired envelope had to invent a refusal or miscite
210  /// `APH_E003`, and this crate's own tests nearly did.
211  #[error("APH_E019: envelope window invalid: [{valid_from} .. {valid_until}] judged at {observed_at}")]
212  EnvelopeWindowInvalid {
213    /// The envelope's `validFrom`, verbatim.
214    valid_from: String,
215    /// The envelope's `validUntil`, verbatim.
216    valid_until: String,
217    /// The instant the verifier judged the window at, RFC 3339.
218    observed_at: String,
219  },
220
221  /// `APH_E020` — the mandate constrains WHO may consume (RFC 0005's
222  /// `allowedRecipientClasses`) and this envelope either declares a class
223  /// outside the grant or declares none at all. The second half is
224  /// deliberate: a constraint the envelope lets nobody check would be
225  /// escapable by omission, and the threat here is one's own
226  /// honest-but-over-broad agent — exactly the party the refusal teaches.
227  /// Deliberately distinct from `APH_E005`, which is the MEDIUM being out
228  /// of scope; this is the CONSUMER being out of scope on an allowed
229  /// medium.
230  #[error("APH_E020: recipient class not allowed: envelope declares `{declared}`, mandate `{mandate_id}` allows {{{allowed}}}")]
231  RecipientClassNotAllowed {
232    /// What the envelope's channel declared — or the literal word
233    /// `nothing`, when it declared no class at all.
234    declared: String,
235    /// The constraining mandate's `id`.
236    mandate_id: String,
237    /// The granted classes, comma-joined, so the refusal teaches the fix.
238    allowed: String,
239  },
240}
241
242impl AphError {
243  // ── Code + suggestion ──────────────────────────────────────────────
244
245  /// Returns the APH error code string (e.g., `"APH_E001"`).
246  pub fn code(&self) -> &'static str {
247    match self {
248      Self::InvalidEnvelopeSignature => "APH_E001",
249      Self::InvalidFlowTransition { .. } => "APH_E002",
250      Self::MandateExpired { .. } => "APH_E003",
251      Self::RoleViolation { .. } => "APH_E004",
252      Self::ChannelNotAllowed { .. } => "APH_E005",
253      Self::NotarySignatureInvalid => "APH_E006",
254      Self::HumanAuthenticationRequired => "APH_E007",
255      Self::NotaryServiceUnreachable => "APH_E008",
256      Self::EnvelopeBodyHashMismatch { .. } => "APH_E009",
257      Self::UnsupportedAlgorithm { .. } => "APH_E010",
258      Self::PrincipalSignatureInvalid => "APH_E011",
259      Self::AttestationModeRefused { .. } => "APH_E012",
260      Self::ProofChainInvalid { .. } => "APH_E013",
261      Self::NotaryKeyNotPublished { .. } => "APH_E014",
262      Self::MandateRevoked { .. } => "APH_E015",
263      Self::MandateRequired { .. } => "APH_E016",
264      Self::AudienceMismatch { .. } => "APH_E017",
265      Self::EnvelopeAlreadySpent { .. } => "APH_E018",
266      Self::EnvelopeWindowInvalid { .. } => "APH_E019",
267      Self::RecipientClassNotAllowed { .. } => "APH_E020",
268    }
269  }
270
271  /// Returns a human-readable suggestion for resolving this error.
272  pub fn suggestion(&self) -> &'static str {
273    match self {
274      Self::InvalidEnvelopeSignature => "Verify the notary signing key and re-sign",
275      Self::InvalidFlowTransition { .. } => "Check the APH notarization flow state machine",
276      Self::MandateExpired { .. } => "Issue a fresh mandate with a future `validUntil`",
277      Self::RoleViolation { .. } => "Ensure the party holds the correct AphPartyRole",
278      Self::ChannelNotAllowed { .. } => "Grant the channel scope on the delegation mandate",
279      Self::NotarySignatureInvalid => "Verify the notary's JWK matches `verificationMethod`",
280      Self::HumanAuthenticationRequired => "Prompt the human for AskEveryTime confirmation",
281      Self::NotaryServiceUnreachable => "Check notary endpoint health + retry with backoff",
282      Self::EnvelopeBodyHashMismatch { .. } => "Re-hash the body and compare against `bodySha256`",
283      Self::UnsupportedAlgorithm { .. } => "Use one of `ES256` or `EdDSA`; reject `alg: none`",
284      Self::PrincipalSignatureInvalid => "Re-sign with the human's key; check it matches `humanPrincipalDid`",
285      Self::AttestationModeRefused { .. } => "Re-issue in `PrincipalSigned` mode, or relax the policy deliberately",
286      Self::ProofChainInvalid { .. } => "Emit principal proof then notary proof, linked by `previousProof`",
287      Self::NotaryKeyNotPublished { .. } => "Publish the key at this surface (§8.4.4/§8.4.5), or query one the notary publishes to",
288      Self::MandateRevoked { .. } => "Obtain a fresh delegation mandate from the human principal; a revoked mandate cannot be re-activated (§6.3.2)",
289      Self::MandateRequired { .. } => "Issue a delegation mandate covering this channel and scope, or route the act through the human-present flow (§9.1)",
290      Self::AudienceMismatch { .. } => "Deliver the envelope to the endpoint it names, or re-issue naming the intended recipient",
291      Self::EnvelopeAlreadySpent { .. } => "Mint a fresh envelope for a new act; acceptance consumes an envelope and a replayed one is refused by design",
292      Self::EnvelopeWindowInvalid { .. } => "Mint a fresh envelope with a current window; single-act windows should be minutes, not hours",
293      Self::RecipientClassNotAllowed { .. } => "Declare the recipient class the act actually has, or ask the principal for a grant that covers it",
294    }
295  }
296
297  // ── Constructors ────────────────────────────────────────────────────
298
299  /// Builds an `APH_E002` naming the illegal edge that was attempted.
300  pub fn invalid_flow_transition(
301    from: impl std::convert::Into<String>,
302    to: impl std::convert::Into<String>,
303  ) -> Self {
304    Self::InvalidFlowTransition {
305      from: from.into(),
306      to: to.into(),
307    }
308  }
309
310  /// Builds an `APH_E003` naming the expired mandate.
311  pub fn mandate_expired(mandate_id: impl std::convert::Into<String>) -> Self {
312    Self::MandateExpired {
313      mandate_id: mandate_id.into(),
314    }
315  }
316
317  /// Builds an `APH_E014` naming the surface that published nothing.
318  pub fn notary_key_not_published(surface: impl std::convert::Into<String>) -> Self {
319    Self::NotaryKeyNotPublished {
320      surface: surface.into(),
321    }
322  }
323
324  /// Builds an `APH_E004` naming the role and the denied operation.
325  pub fn role_violation(
326    role: impl std::convert::Into<String>,
327    operation: impl std::convert::Into<String>,
328  ) -> Self {
329    Self::RoleViolation {
330      role: role.into(),
331      operation: operation.into(),
332    }
333  }
334
335  /// Builds an `APH_E005` naming the out-of-scope channel.
336  pub fn channel_not_allowed(channel: impl std::convert::Into<String>) -> Self {
337    Self::ChannelNotAllowed {
338      channel: channel.into(),
339    }
340  }
341
342  /// Builds an `APH_E009` carrying both the attested and the recomputed
343  /// hash so an investigator can tell which side diverged.
344  pub fn envelope_body_hash_mismatch(
345    expected: impl std::convert::Into<String>,
346    actual: impl std::convert::Into<String>,
347  ) -> Self {
348    Self::EnvelopeBodyHashMismatch {
349      expected: expected.into(),
350      actual: actual.into(),
351    }
352  }
353
354  /// Builds an `APH_E010` naming the rejected algorithm.
355  pub fn unsupported_algorithm(alg: impl std::convert::Into<String>) -> Self {
356    Self::UnsupportedAlgorithm { alg: alg.into() }
357  }
358
359  /// Builds an `APH_E012` recording both modes, so an operator can see what
360  /// was demanded and what arrived without re-parsing the envelope.
361  pub fn attestation_mode_refused(
362    required: impl std::convert::Into<String>,
363    actual: impl std::convert::Into<String>,
364  ) -> Self {
365    Self::AttestationModeRefused {
366      required: required.into(),
367      actual: actual.into(),
368    }
369  }
370
371  /// Builds an `APH_E013` naming what is wrong with the chain.
372  pub fn proof_chain_invalid(reason: impl std::convert::Into<String>) -> Self {
373    Self::ProofChainInvalid {
374      reason: reason.into(),
375    }
376  }
377
378  /// Builds an `APH_E015` naming the revoked delegation mandate.
379  pub fn mandate_revoked(mandate_id: impl std::convert::Into<String>) -> Self {
380    Self::MandateRevoked {
381      mandate_id: mandate_id.into(),
382    }
383  }
384
385  /// Builds an `APH_E016` naming the agent and channel nothing authorized,
386  /// so the operator's first question — WHO tried WHAT — is answered by the
387  /// error itself rather than by log correlation.
388  pub fn mandate_required(
389    agent_did: impl std::convert::Into<String>,
390    channel_kind: impl std::convert::Into<String>,
391  ) -> Self {
392    Self::MandateRequired {
393      agent_did: agent_did.into(),
394      channel_kind: channel_kind.into(),
395    }
396  }
397
398  /// Builds an `APH_E017` naming both identities, so the operator's first
399  /// question — for WHOM was this envelope, and who am I — is answered by
400  /// the error itself.
401  pub fn audience_mismatch(
402    audience_id: impl std::convert::Into<String>,
403    verifier_id: impl std::convert::Into<String>,
404  ) -> Self {
405    Self::AudienceMismatch {
406      audience_id: audience_id.into(),
407      verifier_id: verifier_id.into(),
408    }
409  }
410
411  /// Builds an `APH_E018` naming the id whose second presentation was
412  /// refused, so an operator can find the first acceptance in their ledger.
413  pub fn envelope_already_spent(envelope_id: impl std::convert::Into<String>) -> Self {
414    Self::EnvelopeAlreadySpent { envelope_id: envelope_id.into() }
415  }
416
417  /// Builds an `APH_E019` carrying the window verbatim and the instant it
418  /// was judged at; without all three, "expired" is unactionable.
419  pub fn envelope_window_invalid(
420    valid_from: impl std::convert::Into<String>,
421    valid_until: impl std::convert::Into<String>,
422    observed_at: impl std::convert::Into<String>,
423  ) -> Self {
424    Self::EnvelopeWindowInvalid {
425      valid_from: valid_from.into(),
426      valid_until: valid_until.into(),
427      observed_at: observed_at.into(),
428    }
429  }
430
431  /// Builds an `APH_E020` naming what was declared (or `nothing`), which
432  /// mandate constrained it, and what that mandate allows.
433  pub fn recipient_class_not_allowed(
434    declared: impl std::convert::Into<String>,
435    mandate_id: impl std::convert::Into<String>,
436    allowed: impl std::convert::Into<String>,
437  ) -> Self {
438    Self::RecipientClassNotAllowed {
439      declared: declared.into(),
440      mandate_id: mandate_id.into(),
441      allowed: allowed.into(),
442    }
443  }
444}
445
446#[cfg(test)]
447mod tests {
448  fn all_variants() -> std::vec::Vec<super::AphError> {
449    std::vec![
450      super::AphError::InvalidEnvelopeSignature,
451      super::AphError::invalid_flow_transition("Drafted", "Delivered"),
452      super::AphError::mandate_expired("urn:uuid:00000000-0000-4000-8000-000000000001"),
453      super::AphError::role_violation("AgentSender", "Notarize"),
454      super::AphError::channel_not_allowed("slack"),
455      super::AphError::NotarySignatureInvalid,
456      super::AphError::HumanAuthenticationRequired,
457      super::AphError::NotaryServiceUnreachable,
458      super::AphError::envelope_body_hash_mismatch("abc", "def"),
459      super::AphError::unsupported_algorithm("HS256"),
460      super::AphError::PrincipalSignatureInvalid,
461      super::AphError::attestation_mode_refused("PrincipalSigned", "NotaryAttested"),
462      super::AphError::proof_chain_invalid("previousProof does not name a proof in this chain"),
463      super::AphError::notary_key_not_published("_aph._notary.example.com"),
464      super::AphError::mandate_revoked("urn:uuid:00000000-0000-4000-8000-000000000002"),
465      super::AphError::mandate_required("did:key:zTestAgent", "slack"),
466      super::AphError::audience_mismatch("did:web:other.example.com", "did:web:this.example.com"),
467      super::AphError::envelope_already_spent("urn:uuid:00000000-0000-4000-8000-000000000003"),
468      super::AphError::envelope_window_invalid(
469        "2026-05-21T12:00:00Z",
470        "2026-05-21T12:10:00Z",
471        "2026-05-21T13:00:00Z",
472      ),
473      super::AphError::recipient_class_not_allowed(
474        "agent",
475        "urn:uuid:00000000-0000-4000-8000-000000000004",
476        "human",
477      ),
478    ]
479  }
480
481  #[test]
482  fn every_code_in_the_closed_set_is_unique() {
483    // The spec (§11) fixes a CLOSED set of exactly twenty codes, and
484    // other implementations branch on them. A duplicate would make two
485    // distinct failures indistinguishable to a remote verifier. The count
486    // is pinned so that adding a code without amending §11 fails here.
487    //
488    // The guarantee in that last sentence had already failed once: this
489    // list omitted `NotaryKeyNotPublished` from the day APH_E014 landed,
490    // so the count stayed at thirteen against a §11 that said fourteen and
491    // E014 was swept by NONE of the three all_variants() tests below. Both
492    // missing entries are restored here alongside APH_E015 — the sweep is
493    // only worth its comment if the list it walks is the whole enum. And it
494    // failed a SECOND time the very day APH_E016 landed: the new variant's
495    // own pin test passed while this list still held fifteen, because a
496    // hand-maintained census cannot fail on what it does not contain. The
497    // count-free test name is deliberate (the old name carried the number
498    // and rotted); the pinned count below is the tripwire, kept in exactly
499    // one place.
500    let errors = all_variants();
501    std::assert_eq!(errors.len(), 20);
502    let codes: std::vec::Vec<&str> = errors.iter().map(|e| e.code()).collect();
503    let mut unique = codes.clone();
504    unique.sort();
505    unique.dedup();
506    std::assert_eq!(codes.len(), unique.len(), "error codes must be unique");
507  }
508
509  #[test]
510  fn the_three_signature_failures_have_three_distinct_codes() {
511    // A verifier reports WHY a credential failed, and the three signature
512    // failures mean entirely different things: APH_E001 is the notary's
513    // envelope proof, APH_E006 the notary's mandate signature, APH_E011 the
514    // HUMAN's own signature. Only the last means the authorization itself
515    // is forged or corrupt. Collapsing them would report a forged
516    // authorization as a notary misconfiguration, which is the wrong alarm.
517    std::assert_eq!(super::AphError::InvalidEnvelopeSignature.code(), "APH_E001");
518    std::assert_eq!(super::AphError::NotarySignatureInvalid.code(), "APH_E006");
519    std::assert_eq!(super::AphError::PrincipalSignatureInvalid.code(), "APH_E011");
520  }
521
522  #[test]
523  fn attestation_mode_refusal_names_both_modes() {
524    // Refusing a downgrade is a policy decision, not a malformed envelope,
525    // so the error has to say what was demanded AND what arrived — an
526    // operator reading a log must be able to tell a misconfigured verifier
527    // from a sender that never signed.
528    let err = super::AphError::attestation_mode_refused("PrincipalSigned", "NotaryAttested");
529    let text = std::string::ToString::to_string(&err);
530    std::assert!(text.contains("PrincipalSigned"), "missing required mode: {}", text);
531    std::assert!(text.contains("NotaryAttested"), "missing actual mode: {}", text);
532  }
533
534  #[test]
535  fn proof_chain_invalid_carries_the_reason() {
536    // A chain can be malformed in several distinct ways (length, purpose,
537    // dangling previousProof). One opaque code per family would leave an
538    // implementer guessing, so the reason travels with the error.
539    let err = super::AphError::proof_chain_invalid("previousProof is dangling");
540    std::assert_eq!(err.code(), "APH_E013");
541    std::assert!(std::string::ToString::to_string(&err).contains("dangling"));
542  }
543
544  #[test]
545  fn codes_follow_aph_e_nnn_format() {
546    // Code strings are a cross-implementation contract; consumers pattern
547    // match on the APH_Ennn shape, so a stray format would not merely look
548    // wrong, it would fail their matchers.
549    for err in all_variants() {
550      let code = err.code();
551      std::assert!(
552        code.starts_with("APH_E"),
553        "code `{}` lacks APH_E prefix",
554        code
555      );
556      std::assert_eq!(code.len(), 8, "code `{}` is not 8 chars", code);
557    }
558  }
559
560  #[test]
561  fn every_variant_has_non_empty_suggestion() {
562    // The spec says implementations SHOULD surface a suggested resolution;
563    // an empty one leaves an operator staring at a bare code with no path
564    // forward. Easy to forget when adding a variant, hence the sweep.
565    for err in all_variants() {
566      std::assert!(
567        !err.suggestion().is_empty(),
568        "{} has empty suggestion",
569        err.code()
570      );
571    }
572  }
573
574  #[test]
575  fn display_includes_code_for_every_variant() {
576    // Logs usually capture only the Display string, so the code must be
577    // embedded in it — otherwise a failure in production cannot be mapped
578    // back to its spec-defined cause.
579    for err in all_variants() {
580      let msg = format!("{err}");
581      std::assert!(
582        msg.contains(err.code()),
583        "Display `{}` does not include code `{}`",
584        msg,
585        err.code()
586      );
587    }
588  }
589
590  #[test]
591  fn invalid_flow_transition_constructor_preserves_strings() {
592    // Constructors take context arguments that only reach an operator via
593    // the message; if from/to were dropped, an APH_E002 would say a
594    // transition was illegal without saying which one.
595    let err = super::AphError::invalid_flow_transition("Drafted", "EnvelopeIssued");
596    let msg = format!("{err}");
597    std::assert!(msg.contains("Drafted"));
598    std::assert!(msg.contains("EnvelopeIssued"));
599    std::assert_eq!(err.code(), "APH_E002");
600  }
601
602  #[test]
603  fn mandate_expired_constructor_preserves_id() {
604    // The mandate id is what lets an operator find and re-issue the
605    // expired delegation; losing it makes APH_E003 unactionable.
606    let err = super::AphError::mandate_expired("urn:uuid:abc");
607    let msg = format!("{err}");
608    std::assert!(msg.contains("urn:uuid:abc"));
609    std::assert_eq!(err.code(), "APH_E003");
610  }
611
612  #[test]
613  fn role_violation_constructor_preserves_role_and_op() {
614    // A permission-matrix denial is a security event: both the role and
615    // the attempted operation must survive into the message, or the audit
616    // trail cannot say who tried to do what.
617    let err = super::AphError::role_violation("AgentSender", "Notarize");
618    let msg = format!("{err}");
619    std::assert!(msg.contains("AgentSender"));
620    std::assert!(msg.contains("Notarize"));
621    std::assert_eq!(err.code(), "APH_E004");
622  }
623
624  #[test]
625  fn channel_not_allowed_constructor_preserves_channel() {
626    // Names the channel that fell outside the delegation's scope — the
627    // detail a human needs to decide whether to widen the mandate.
628    let err = super::AphError::channel_not_allowed("discord");
629    let msg = format!("{err}");
630    std::assert!(msg.contains("discord"));
631    std::assert_eq!(err.code(), "APH_E005");
632  }
633
634  #[test]
635  fn envelope_body_hash_mismatch_constructor_preserves_hashes() {
636    // APH_E009 means the delivered body does not match what was notarized
637    // — possible tampering. BOTH hashes must appear so an investigator can
638    // tell which side changed rather than just that something did.
639    let err = super::AphError::envelope_body_hash_mismatch("sha256:aaa", "sha256:bbb");
640    let msg = format!("{err}");
641    std::assert!(msg.contains("sha256:aaa"));
642    std::assert!(msg.contains("sha256:bbb"));
643    std::assert_eq!(err.code(), "APH_E009");
644  }
645
646  #[test]
647  fn revoked_is_not_expired_and_names_the_mandate() {
648    // Expiry and revocation are the two ways standing authority ends, and
649    // they call for opposite responses: an expired mandate is routine
650    // housekeeping, a revoked one is a human who changed their mind and
651    // whose decision must not be re-granted by reflex. Collapsing them
652    // into one code would hide that, and the mandate id is what lets an
653    // operator find WHICH grant was pulled — APH_E015 is unactionable
654    // without it, exactly as APH_E003 is.
655    let revoked = super::AphError::mandate_revoked("urn:uuid:def");
656    let expired = super::AphError::mandate_expired("urn:uuid:def");
657    std::assert_eq!(revoked.code(), "APH_E015");
658    std::assert_eq!(expired.code(), "APH_E003");
659    std::assert_ne!(revoked, expired);
660    std::assert!(std::string::ToString::to_string(&revoked).contains("urn:uuid:def"));
661  }
662
663  /// WHY THIS TEST EXISTS: the first production consumer of the human-not-
664  /// present flow had to reuse an invalid-mandate code for the no-mandate
665  /// case, because §11 had no code for unrooted authority — absence reported
666  /// as failure, the conflation this crate exists to keep structural.
667  /// WHAT IT PINS: `APH_E016` is its own code, distinct from its neighbours
668  /// (absent ≠ invalid ≠ revoked ≠ human-unreachable), and the message names
669  /// the agent and channel so the refusal is actionable without log
670  /// correlation.
671  #[test]
672  fn an_absent_mandate_is_its_own_refusal_not_an_invalid_one() {
673    let required =
674      super::AphError::mandate_required("did:key:zAgent", "slack");
675    std::assert_eq!(required.code(), "APH_E016");
676    std::assert_ne!(required.code(), super::AphError::mandate_revoked("x").code());
677    let msg = std::string::ToString::to_string(&required);
678    std::assert!(msg.contains("did:key:zAgent"));
679    std::assert!(msg.contains("slack"));
680    std::assert!(
681      required.suggestion().contains("§9.1"),
682      "the remedy must point at the human-present flow, not at mandate repair"
683    );
684  }
685
686  #[test]
687  fn unsupported_algorithm_constructor_preserves_alg() {
688    // Uses "none" deliberately: alg:none is the classic JWS downgrade
689    // attack, and the spec requires rejecting it with APH_E010. The
690    // rejected algorithm name must reach the log to make the attempt
691    // visible rather than silently dropped.
692    let err = super::AphError::unsupported_algorithm("none");
693    let msg = format!("{err}");
694    std::assert!(msg.contains("none"));
695    std::assert_eq!(err.code(), "APH_E010");
696  }
697}