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}