Skip to main content

aph_core/
errors.rs

1//! APH error taxonomy.
2//!
3//! Codes APH_E001 .. APH_E024 (E021-E024 declared by the v0.2 delta). 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  /// `APH_E021` — a sealed payload addressed to THIS verifier did not
242  /// open: wrong key, tampered ciphertext, or a seal staged under a
243  /// different context (envelope, reader, or suite relabeled) — the four
244  /// cases AEAD makes indistinguishable by design. Per RFC 0008 §4 this
245  /// refuses the ENVELOPE, never the seal alone: an unopenable seal
246  /// addressed to you is evidence, not an inconvenience.
247  ///
248  /// DECLARED BY spec/aph-0.2.md, absent from v0.1.0's closed
249  /// twenty; emitted only on wires that declare `sealedPayload`.
250  #[error("APH_E021: sealed payload addressed to this verifier did not open (envelope `{envelope_id}`)")]
251  SealUnopenable {
252    /// The envelope whose seal refused.
253    envelope_id: String,
254  },
255
256  /// `APH_E022` — the sealed payload names a ciphersuite this verifier
257  /// does not compile. One suite per draft, no negotiation; refused by
258  /// name before any key material is touched. Declared by
259  /// spec/aph-0.2.md.
260  #[error("APH_E022: sealed payload suite `{suite}` is not supported (this build seals only `{supported}`)")]
261  SealSuiteUnknown {
262    /// The suite the payload claimed.
263    suite: String,
264    /// The one suite this build compiles.
265    supported: String,
266  },
267
268  /// `APH_E023` — the seal's reader key could not be discovered: the
269  /// reader's DID document publishes no matching `keyAgreement` entry for
270  /// the named `kid`. Held distinct from `APH_E014` exactly as E014 is
271  /// held distinct from E008: WHICH surface came up empty is the repair.
272  /// Declared by spec/aph-0.2.md.
273  #[error("APH_E023: no `keyAgreement` key `{kid}` published for reader `{reader_id}`")]
274  SealReaderKeyUnpublished {
275    /// The reader DID whose document was consulted.
276    reader_id: String,
277    /// The kid the sealed payload named.
278    kid: String,
279  },
280
281  /// `APH_E024` — a rotation attestation (RFC 0001, v0.2) failed a
282  /// structural rule or its predecessor signature. The message carries the
283  /// SPECIFIC defect — which rule, which value — because "invalid" with no
284  /// reason teaches an operator nothing about a statement whose entire job
285  /// is auditability.
286  #[error("APH_E024: rotation attestation invalid: {reason}")]
287  RotationAttestationInvalid {
288    /// The specific rule that refused, with the offending values.
289    reason: String,
290  },
291}
292
293impl AphError {
294  // ── Code + suggestion ──────────────────────────────────────────────
295
296  /// Returns the APH error code string (e.g., `"APH_E001"`).
297  pub fn code(&self) -> &'static str {
298    match self {
299      Self::InvalidEnvelopeSignature => "APH_E001",
300      Self::InvalidFlowTransition { .. } => "APH_E002",
301      Self::MandateExpired { .. } => "APH_E003",
302      Self::RoleViolation { .. } => "APH_E004",
303      Self::ChannelNotAllowed { .. } => "APH_E005",
304      Self::NotarySignatureInvalid => "APH_E006",
305      Self::HumanAuthenticationRequired => "APH_E007",
306      Self::NotaryServiceUnreachable => "APH_E008",
307      Self::EnvelopeBodyHashMismatch { .. } => "APH_E009",
308      Self::UnsupportedAlgorithm { .. } => "APH_E010",
309      Self::PrincipalSignatureInvalid => "APH_E011",
310      Self::AttestationModeRefused { .. } => "APH_E012",
311      Self::ProofChainInvalid { .. } => "APH_E013",
312      Self::NotaryKeyNotPublished { .. } => "APH_E014",
313      Self::MandateRevoked { .. } => "APH_E015",
314      Self::MandateRequired { .. } => "APH_E016",
315      Self::AudienceMismatch { .. } => "APH_E017",
316      Self::EnvelopeAlreadySpent { .. } => "APH_E018",
317      Self::EnvelopeWindowInvalid { .. } => "APH_E019",
318      Self::RecipientClassNotAllowed { .. } => "APH_E020",
319      Self::SealUnopenable { .. } => "APH_E021",
320      Self::SealSuiteUnknown { .. } => "APH_E022",
321      Self::SealReaderKeyUnpublished { .. } => "APH_E023",
322      Self::RotationAttestationInvalid { .. } => "APH_E024",
323    }
324  }
325
326  /// Returns a human-readable suggestion for resolving this error.
327  pub fn suggestion(&self) -> &'static str {
328    match self {
329      Self::InvalidEnvelopeSignature => "Verify the notary signing key and re-sign",
330      Self::InvalidFlowTransition { .. } => "Check the APH notarization flow state machine",
331      Self::MandateExpired { .. } => "Issue a fresh mandate with a future `validUntil`",
332      Self::RoleViolation { .. } => "Ensure the party holds the correct AphPartyRole",
333      Self::ChannelNotAllowed { .. } => "Grant the channel scope on the delegation mandate",
334      Self::NotarySignatureInvalid => "Verify the notary's JWK matches `verificationMethod`",
335      Self::HumanAuthenticationRequired => "Prompt the human for AskEveryTime confirmation",
336      Self::NotaryServiceUnreachable => "Check notary endpoint health + retry with backoff",
337      Self::EnvelopeBodyHashMismatch { .. } => "Re-hash the body and compare against `bodySha256`",
338      Self::UnsupportedAlgorithm { .. } => "Use one of `ES256` or `EdDSA`; reject `alg: none`",
339      Self::PrincipalSignatureInvalid => "Re-sign with the human's key; check it matches `humanPrincipalDid`",
340      Self::AttestationModeRefused { .. } => "Re-issue in `PrincipalSigned` mode, or relax the policy deliberately",
341      Self::ProofChainInvalid { .. } => "Emit principal proof then notary proof, linked by `previousProof`",
342      Self::NotaryKeyNotPublished { .. } => "Publish the key at this surface (§8.4.4/§8.4.5), or query one the notary publishes to",
343      Self::MandateRevoked { .. } => "Obtain a fresh delegation mandate from the human principal; a revoked mandate cannot be re-activated (§6.3.2)",
344      Self::MandateRequired { .. } => "Issue a delegation mandate covering this channel and scope, or route the act through the human-present flow (§9.1)",
345      Self::AudienceMismatch { .. } => "Deliver the envelope to the endpoint it names, or re-issue naming the intended recipient",
346      Self::EnvelopeAlreadySpent { .. } => "Mint a fresh envelope for a new act; acceptance consumes an envelope and a replayed one is refused by design",
347      Self::EnvelopeWindowInvalid { .. } => "Mint a fresh envelope with a current window; single-act windows should be minutes, not hours",
348      Self::RecipientClassNotAllowed { .. } => "Declare the recipient class the act actually has, or ask the principal for a grant that covers it",
349      Self::SealUnopenable { .. } => "Refuse the envelope and tell the sender: re-seal to the current keyAgreement key under this envelope's own context",
350      Self::SealSuiteUnknown { .. } => "Re-seal with the one supported suite; suite agility arrives by amendment, not negotiation",
351      Self::SealReaderKeyUnpublished { .. } => "Publish a keyAgreement entry under that kid, or seal to a key the reader actually publishes",
352      Self::RotationAttestationInvalid { .. } => "Re-mint the attestation with the predecessor key it names, over the lone-proof base, with an ordered window",
353    }
354  }
355
356  // ── Constructors ────────────────────────────────────────────────────
357
358  /// Builds an `APH_E002` naming the illegal edge that was attempted.
359  pub fn invalid_flow_transition(
360    from: impl std::convert::Into<String>,
361    to: impl std::convert::Into<String>,
362  ) -> Self {
363    Self::InvalidFlowTransition {
364      from: from.into(),
365      to: to.into(),
366    }
367  }
368
369  /// Builds an `APH_E003` naming the expired mandate.
370  pub fn mandate_expired(mandate_id: impl std::convert::Into<String>) -> Self {
371    Self::MandateExpired {
372      mandate_id: mandate_id.into(),
373    }
374  }
375
376  /// Builds an `APH_E014` naming the surface that published nothing.
377  pub fn notary_key_not_published(surface: impl std::convert::Into<String>) -> Self {
378    Self::NotaryKeyNotPublished {
379      surface: surface.into(),
380    }
381  }
382
383  /// Builds an `APH_E004` naming the role and the denied operation.
384  pub fn role_violation(
385    role: impl std::convert::Into<String>,
386    operation: impl std::convert::Into<String>,
387  ) -> Self {
388    Self::RoleViolation {
389      role: role.into(),
390      operation: operation.into(),
391    }
392  }
393
394  /// Builds an `APH_E005` naming the out-of-scope channel.
395  pub fn channel_not_allowed(channel: impl std::convert::Into<String>) -> Self {
396    Self::ChannelNotAllowed {
397      channel: channel.into(),
398    }
399  }
400
401  /// Builds an `APH_E009` carrying both the attested and the recomputed
402  /// hash so an investigator can tell which side diverged.
403  pub fn envelope_body_hash_mismatch(
404    expected: impl std::convert::Into<String>,
405    actual: impl std::convert::Into<String>,
406  ) -> Self {
407    Self::EnvelopeBodyHashMismatch {
408      expected: expected.into(),
409      actual: actual.into(),
410    }
411  }
412
413  /// Builds an `APH_E010` naming the rejected algorithm.
414  pub fn unsupported_algorithm(alg: impl std::convert::Into<String>) -> Self {
415    Self::UnsupportedAlgorithm { alg: alg.into() }
416  }
417
418  /// Builds an `APH_E012` recording both modes, so an operator can see what
419  /// was demanded and what arrived without re-parsing the envelope.
420  pub fn attestation_mode_refused(
421    required: impl std::convert::Into<String>,
422    actual: impl std::convert::Into<String>,
423  ) -> Self {
424    Self::AttestationModeRefused {
425      required: required.into(),
426      actual: actual.into(),
427    }
428  }
429
430  /// Builds an `APH_E013` naming what is wrong with the chain.
431  pub fn proof_chain_invalid(reason: impl std::convert::Into<String>) -> Self {
432    Self::ProofChainInvalid {
433      reason: reason.into(),
434    }
435  }
436
437  /// Builds an `APH_E015` naming the revoked delegation mandate.
438  pub fn mandate_revoked(mandate_id: impl std::convert::Into<String>) -> Self {
439    Self::MandateRevoked {
440      mandate_id: mandate_id.into(),
441    }
442  }
443
444  /// Builds an `APH_E016` naming the agent and channel nothing authorized,
445  /// so the operator's first question — WHO tried WHAT — is answered by the
446  /// error itself rather than by log correlation.
447  pub fn mandate_required(
448    agent_did: impl std::convert::Into<String>,
449    channel_kind: impl std::convert::Into<String>,
450  ) -> Self {
451    Self::MandateRequired {
452      agent_did: agent_did.into(),
453      channel_kind: channel_kind.into(),
454    }
455  }
456
457  /// Builds an `APH_E017` naming both identities, so the operator's first
458  /// question — for WHOM was this envelope, and who am I — is answered by
459  /// the error itself.
460  pub fn audience_mismatch(
461    audience_id: impl std::convert::Into<String>,
462    verifier_id: impl std::convert::Into<String>,
463  ) -> Self {
464    Self::AudienceMismatch {
465      audience_id: audience_id.into(),
466      verifier_id: verifier_id.into(),
467    }
468  }
469
470  /// Builds an `APH_E018` naming the id whose second presentation was
471  /// refused, so an operator can find the first acceptance in their ledger.
472  pub fn envelope_already_spent(envelope_id: impl std::convert::Into<String>) -> Self {
473    Self::EnvelopeAlreadySpent { envelope_id: envelope_id.into() }
474  }
475
476  /// Builds an `APH_E019` carrying the window verbatim and the instant it
477  /// was judged at; without all three, "expired" is unactionable.
478  pub fn envelope_window_invalid(
479    valid_from: impl std::convert::Into<String>,
480    valid_until: impl std::convert::Into<String>,
481    observed_at: impl std::convert::Into<String>,
482  ) -> Self {
483    Self::EnvelopeWindowInvalid {
484      valid_from: valid_from.into(),
485      valid_until: valid_until.into(),
486      observed_at: observed_at.into(),
487    }
488  }
489
490  /// Builds an `APH_E020` naming what was declared (or `nothing`), which
491  /// mandate constrained it, and what that mandate allows.
492  pub fn recipient_class_not_allowed(
493    declared: impl std::convert::Into<String>,
494    mandate_id: impl std::convert::Into<String>,
495    allowed: impl std::convert::Into<String>,
496  ) -> Self {
497    Self::RecipientClassNotAllowed {
498      declared: declared.into(),
499      mandate_id: mandate_id.into(),
500      allowed: allowed.into(),
501    }
502  }
503
504  /// Builds an `APH_E021` naming the envelope whose seal refused.
505  pub fn seal_unopenable(envelope_id: impl std::convert::Into<String>) -> Self {
506    Self::SealUnopenable { envelope_id: envelope_id.into() }
507  }
508
509  /// Builds an `APH_E022` naming both the claimed and the supported suite.
510  pub fn seal_suite_unknown(
511    suite: impl std::convert::Into<String>,
512    supported: impl std::convert::Into<String>,
513  ) -> Self {
514    Self::SealSuiteUnknown { suite: suite.into(), supported: supported.into() }
515  }
516
517  /// Builds an `APH_E023` naming the reader and the missing kid.
518  pub fn seal_reader_key_unpublished(
519    reader_id: impl std::convert::Into<String>,
520    kid: impl std::convert::Into<String>,
521  ) -> Self {
522    Self::SealReaderKeyUnpublished { reader_id: reader_id.into(), kid: kid.into() }
523  }
524
525  /// Builds an `APH_E024` carrying the specific rule that refused.
526  pub fn rotation_attestation_invalid(reason: impl std::convert::Into<String>) -> Self {
527    Self::RotationAttestationInvalid { reason: reason.into() }
528  }
529}
530
531#[cfg(test)]
532mod tests {
533  fn all_variants() -> std::vec::Vec<super::AphError> {
534    std::vec![
535      super::AphError::InvalidEnvelopeSignature,
536      super::AphError::invalid_flow_transition("Drafted", "Delivered"),
537      super::AphError::mandate_expired("urn:uuid:00000000-0000-4000-8000-000000000001"),
538      super::AphError::role_violation("AgentSender", "Notarize"),
539      super::AphError::channel_not_allowed("slack"),
540      super::AphError::NotarySignatureInvalid,
541      super::AphError::HumanAuthenticationRequired,
542      super::AphError::NotaryServiceUnreachable,
543      super::AphError::envelope_body_hash_mismatch("abc", "def"),
544      super::AphError::unsupported_algorithm("HS256"),
545      super::AphError::PrincipalSignatureInvalid,
546      super::AphError::attestation_mode_refused("PrincipalSigned", "NotaryAttested"),
547      super::AphError::proof_chain_invalid("previousProof does not name a proof in this chain"),
548      super::AphError::notary_key_not_published("_aph._notary.example.com"),
549      super::AphError::mandate_revoked("urn:uuid:00000000-0000-4000-8000-000000000002"),
550      super::AphError::mandate_required("did:key:zTestAgent", "slack"),
551      super::AphError::audience_mismatch("did:web:other.example.com", "did:web:this.example.com"),
552      super::AphError::envelope_already_spent("urn:uuid:00000000-0000-4000-8000-000000000003"),
553      super::AphError::envelope_window_invalid(
554        "2026-05-21T12:00:00Z",
555        "2026-05-21T12:10:00Z",
556        "2026-05-21T13:00:00Z",
557      ),
558      super::AphError::recipient_class_not_allowed(
559        "agent",
560        "urn:uuid:00000000-0000-4000-8000-000000000004",
561        "human",
562      ),
563      super::AphError::seal_unopenable("urn:uuid:00000000-0000-4000-8000-000000000005"),
564      super::AphError::seal_suite_unknown("APH-SEAL-99", "APH-SEAL-1"),
565      super::AphError::seal_reader_key_unpublished("did:web:reader.example.com", "enc-1"),
566      super::AphError::rotation_attestation_invalid("illustrative census entry"),
567    ]
568  }
569
570  #[test]
571  fn every_code_in_the_closed_set_is_unique() {
572    // v0.1.0's §11 fixes a CLOSED set of exactly twenty codes, and
573    // the v0.2 delta declares four more (E021-E024) — so this
574    // build's enum holds twenty-four, and
575    // other implementations branch on them. A duplicate would make two
576    // distinct failures indistinguishable to a remote verifier. The count
577    // is pinned so that adding a code without amending §11 fails here.
578    //
579    // The guarantee in that last sentence had already failed once: this
580    // list omitted `NotaryKeyNotPublished` from the day APH_E014 landed,
581    // so the count stayed at thirteen against a §11 that said fourteen and
582    // E014 was swept by NONE of the three all_variants() tests below. Both
583    // missing entries are restored here alongside APH_E015 — the sweep is
584    // only worth its comment if the list it walks is the whole enum. And it
585    // failed a SECOND time the very day APH_E016 landed: the new variant's
586    // own pin test passed while this list still held fifteen, because a
587    // hand-maintained census cannot fail on what it does not contain. The
588    // count-free test name is deliberate (the old name carried the number
589    // and rotted); the pinned count below is the tripwire, kept in exactly
590    // one place.
591    let errors = all_variants();
592    std::assert_eq!(errors.len(), 24);
593    let codes: std::vec::Vec<&str> = errors.iter().map(|e| e.code()).collect();
594    let mut unique = codes.clone();
595    unique.sort();
596    unique.dedup();
597    std::assert_eq!(codes.len(), unique.len(), "error codes must be unique");
598  }
599
600  #[test]
601  fn the_three_signature_failures_have_three_distinct_codes() {
602    // A verifier reports WHY a credential failed, and the three signature
603    // failures mean entirely different things: APH_E001 is the notary's
604    // envelope proof, APH_E006 the notary's mandate signature, APH_E011 the
605    // HUMAN's own signature. Only the last means the authorization itself
606    // is forged or corrupt. Collapsing them would report a forged
607    // authorization as a notary misconfiguration, which is the wrong alarm.
608    std::assert_eq!(super::AphError::InvalidEnvelopeSignature.code(), "APH_E001");
609    std::assert_eq!(super::AphError::NotarySignatureInvalid.code(), "APH_E006");
610    std::assert_eq!(super::AphError::PrincipalSignatureInvalid.code(), "APH_E011");
611  }
612
613  #[test]
614  fn attestation_mode_refusal_names_both_modes() {
615    // Refusing a downgrade is a policy decision, not a malformed envelope,
616    // so the error has to say what was demanded AND what arrived — an
617    // operator reading a log must be able to tell a misconfigured verifier
618    // from a sender that never signed.
619    let err = super::AphError::attestation_mode_refused("PrincipalSigned", "NotaryAttested");
620    let text = std::string::ToString::to_string(&err);
621    std::assert!(text.contains("PrincipalSigned"), "missing required mode: {}", text);
622    std::assert!(text.contains("NotaryAttested"), "missing actual mode: {}", text);
623  }
624
625  #[test]
626  fn proof_chain_invalid_carries_the_reason() {
627    // A chain can be malformed in several distinct ways (length, purpose,
628    // dangling previousProof). One opaque code per family would leave an
629    // implementer guessing, so the reason travels with the error.
630    let err = super::AphError::proof_chain_invalid("previousProof is dangling");
631    std::assert_eq!(err.code(), "APH_E013");
632    std::assert!(std::string::ToString::to_string(&err).contains("dangling"));
633  }
634
635  #[test]
636  fn codes_follow_aph_e_nnn_format() {
637    // Code strings are a cross-implementation contract; consumers pattern
638    // match on the APH_Ennn shape, so a stray format would not merely look
639    // wrong, it would fail their matchers.
640    for err in all_variants() {
641      let code = err.code();
642      std::assert!(
643        code.starts_with("APH_E"),
644        "code `{}` lacks APH_E prefix",
645        code
646      );
647      std::assert_eq!(code.len(), 8, "code `{}` is not 8 chars", code);
648    }
649  }
650
651  #[test]
652  fn every_variant_has_non_empty_suggestion() {
653    // The spec says implementations SHOULD surface a suggested resolution;
654    // an empty one leaves an operator staring at a bare code with no path
655    // forward. Easy to forget when adding a variant, hence the sweep.
656    for err in all_variants() {
657      std::assert!(
658        !err.suggestion().is_empty(),
659        "{} has empty suggestion",
660        err.code()
661      );
662    }
663  }
664
665  #[test]
666  fn display_includes_code_for_every_variant() {
667    // Logs usually capture only the Display string, so the code must be
668    // embedded in it — otherwise a failure in production cannot be mapped
669    // back to its spec-defined cause.
670    for err in all_variants() {
671      let msg = format!("{err}");
672      std::assert!(
673        msg.contains(err.code()),
674        "Display `{}` does not include code `{}`",
675        msg,
676        err.code()
677      );
678    }
679  }
680
681  #[test]
682  fn invalid_flow_transition_constructor_preserves_strings() {
683    // Constructors take context arguments that only reach an operator via
684    // the message; if from/to were dropped, an APH_E002 would say a
685    // transition was illegal without saying which one.
686    let err = super::AphError::invalid_flow_transition("Drafted", "EnvelopeIssued");
687    let msg = format!("{err}");
688    std::assert!(msg.contains("Drafted"));
689    std::assert!(msg.contains("EnvelopeIssued"));
690    std::assert_eq!(err.code(), "APH_E002");
691  }
692
693  #[test]
694  fn mandate_expired_constructor_preserves_id() {
695    // The mandate id is what lets an operator find and re-issue the
696    // expired delegation; losing it makes APH_E003 unactionable.
697    let err = super::AphError::mandate_expired("urn:uuid:abc");
698    let msg = format!("{err}");
699    std::assert!(msg.contains("urn:uuid:abc"));
700    std::assert_eq!(err.code(), "APH_E003");
701  }
702
703  #[test]
704  fn role_violation_constructor_preserves_role_and_op() {
705    // A permission-matrix denial is a security event: both the role and
706    // the attempted operation must survive into the message, or the audit
707    // trail cannot say who tried to do what.
708    let err = super::AphError::role_violation("AgentSender", "Notarize");
709    let msg = format!("{err}");
710    std::assert!(msg.contains("AgentSender"));
711    std::assert!(msg.contains("Notarize"));
712    std::assert_eq!(err.code(), "APH_E004");
713  }
714
715  #[test]
716  fn channel_not_allowed_constructor_preserves_channel() {
717    // Names the channel that fell outside the delegation's scope — the
718    // detail a human needs to decide whether to widen the mandate.
719    let err = super::AphError::channel_not_allowed("discord");
720    let msg = format!("{err}");
721    std::assert!(msg.contains("discord"));
722    std::assert_eq!(err.code(), "APH_E005");
723  }
724
725  #[test]
726  fn envelope_body_hash_mismatch_constructor_preserves_hashes() {
727    // APH_E009 means the delivered body does not match what was notarized
728    // — possible tampering. BOTH hashes must appear so an investigator can
729    // tell which side changed rather than just that something did.
730    let err = super::AphError::envelope_body_hash_mismatch("sha256:aaa", "sha256:bbb");
731    let msg = format!("{err}");
732    std::assert!(msg.contains("sha256:aaa"));
733    std::assert!(msg.contains("sha256:bbb"));
734    std::assert_eq!(err.code(), "APH_E009");
735  }
736
737  #[test]
738  fn revoked_is_not_expired_and_names_the_mandate() {
739    // Expiry and revocation are the two ways standing authority ends, and
740    // they call for opposite responses: an expired mandate is routine
741    // housekeeping, a revoked one is a human who changed their mind and
742    // whose decision must not be re-granted by reflex. Collapsing them
743    // into one code would hide that, and the mandate id is what lets an
744    // operator find WHICH grant was pulled — APH_E015 is unactionable
745    // without it, exactly as APH_E003 is.
746    let revoked = super::AphError::mandate_revoked("urn:uuid:def");
747    let expired = super::AphError::mandate_expired("urn:uuid:def");
748    std::assert_eq!(revoked.code(), "APH_E015");
749    std::assert_eq!(expired.code(), "APH_E003");
750    std::assert_ne!(revoked, expired);
751    std::assert!(std::string::ToString::to_string(&revoked).contains("urn:uuid:def"));
752  }
753
754  /// WHY THIS TEST EXISTS: the first production consumer of the human-not-
755  /// present flow had to reuse an invalid-mandate code for the no-mandate
756  /// case, because §11 had no code for unrooted authority — absence reported
757  /// as failure, the conflation this crate exists to keep structural.
758  /// WHAT IT PINS: `APH_E016` is its own code, distinct from its neighbours
759  /// (absent ≠ invalid ≠ revoked ≠ human-unreachable), and the message names
760  /// the agent and channel so the refusal is actionable without log
761  /// correlation.
762  #[test]
763  fn an_absent_mandate_is_its_own_refusal_not_an_invalid_one() {
764    let required =
765      super::AphError::mandate_required("did:key:zAgent", "slack");
766    std::assert_eq!(required.code(), "APH_E016");
767    std::assert_ne!(required.code(), super::AphError::mandate_revoked("x").code());
768    let msg = std::string::ToString::to_string(&required);
769    std::assert!(msg.contains("did:key:zAgent"));
770    std::assert!(msg.contains("slack"));
771    std::assert!(
772      required.suggestion().contains("§9.1"),
773      "the remedy must point at the human-present flow, not at mandate repair"
774    );
775  }
776
777  #[test]
778  fn unsupported_algorithm_constructor_preserves_alg() {
779    // Uses "none" deliberately: alg:none is the classic JWS downgrade
780    // attack, and the spec requires rejecting it with APH_E010. The
781    // rejected algorithm name must reach the log to make the attempt
782    // visible rather than silently dropped.
783    let err = super::AphError::unsupported_algorithm("none");
784    let msg = format!("{err}");
785    std::assert!(msg.contains("none"));
786    std::assert_eq!(err.code(), "APH_E010");
787  }
788}