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}