trust-tasks-rs 0.17.7

Reference Rust library for the Trust Tasks framework — transport-agnostic, JSON-based descriptions of verifiable work between parties.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
//! Ties a Rust struct to the *Trust Task specification* it represents.
//!
//! [`Payload`] is the integration seam between the framework crate and per-
//! spec types (whether generated by `trust-tasks-codegen` or hand-written).
//! Once a type implements [`Payload`], callers can build documents without
//! restating the Type URI:
//!
//! ```rust,ignore
//! use trust_tasks_rs::{Payload, TrustTask};
//!
//! let req = TrustTask::for_payload("req-1", AclGrant { ... });
//! assert_eq!(req.type_uri, AclGrant::type_uri());
//! ```
//!
//! The generated code emits one impl per request payload and, where the
//! specification defines a success response, a second impl on the response
//! type with the `#response` fragment in [`Payload::TYPE_URI`] — plus a
//! [`RequestPayload`] impl on the request pairing the two, so a transport can
//! infer the response type rather than be told it.

use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::error::TrustTaskCode;
use crate::type_uri::TypeUri;

/// A Rust type that corresponds to one variant (request or response) of a
/// versioned *Trust Task specification*.
///
/// The generated code emits one impl per (slug, version, variant). Hand-
/// written impls are equally valid; the only requirement is that
/// [`TYPE_URI`](Self::TYPE_URI) parses as a [`TypeUri`].
pub trait Payload: Serialize + DeserializeOwned {
    /// The canonical Type URI this payload targets, including the `#response`
    /// fragment for success-response payloads (SPEC.md §4.4.1).
    const TYPE_URI: &'static str;

    /// Whether the originating *Trust Task specification* is a *bearer
    /// specification* per SPEC.md §4.8.3 — that is, opts out of the §4.8.2
    /// audience-binding rule.
    ///
    /// Defaults to `false` (non-bearer). The codegen emits an explicit
    /// `const IS_BEARER: bool = true;` override only when the spec's front
    /// matter declares `bearer: true`.
    ///
    /// Consumers consult this constant via
    /// [`crate::TrustTask::enforce_audience_binding`] to apply SPEC.md §7.2
    /// item 8 without consulting the registry at runtime.
    ///
    /// The codegen emits this constant on both the request `Payload`
    /// impl and the response `Response` impl (when the spec defines
    /// one). The audience-binding check fires on request-side documents
    /// only, so the constant on the response impl is informational —
    /// downstream tooling that walks generated modules generically can
    /// read it without special-casing variants.
    const IS_BEARER: bool = false;

    /// Whether the originating *Trust Task specification* obliges a *consumer*
    /// to reject a document that arrives without a `proof`, per SPEC.md §7.3
    /// item 8 (`proofRequirement.requirement == "REQUIRED"`).
    ///
    /// Defaults to `false` (i.e. `OPTIONAL` or `RECOMMENDED` — the consumer
    /// is free to accept a proofless document). The codegen emits an explicit
    /// `const IS_PROOF_REQUIRED: bool = true;` override only when the spec's
    /// front matter declares `proofRequirement.requirement: REQUIRED`.
    ///
    /// Consumers consult this constant via [`crate::consume_inbound`] to
    /// apply SPEC.md §7.2 item 7 authoritatively per-spec, rather than as a
    /// consumer-wide policy toggle.
    ///
    /// Like [`IS_BEARER`](Self::IS_BEARER), this constant is emitted on
    /// both the request `Payload` impl and the response `Response` impl.
    /// `consume_inbound` consults it on the request side; a producer
    /// consuming a response would do the same check against the response
    /// impl if its trust posture requires it.
    const IS_PROOF_REQUIRED: bool = false;

    /// Whether the originating *Trust Task specification* obliges a *consumer*
    /// to reject a document that arrives without an in-band `recipient`, per
    /// SPEC.md §7.2 item 5 and §7.3 item 5 (the party filling the `recipient`
    /// member is declared `REQUIRED`).
    ///
    /// Defaults to `false`. The codegen emits an explicit
    /// `const IS_RECIPIENT_REQUIRED: bool = true;` override only when the
    /// spec's front matter declares the relevant party (the one carrying
    /// `member: recipient`) as `requirement: REQUIRED`. Because a response
    /// document swaps the parties, the `Response` impl's value tracks the
    /// *issuer* party's requirement instead.
    ///
    /// When `true`, a document whose in-band `recipient` is absent is rejected
    /// with `malformedRequest` — the audience must be carried in-band (not
    /// merely transport-derived) so the document is self-contained (§4.8).
    /// Consumers consult this via [`crate::consume_inbound`].
    const IS_RECIPIENT_REQUIRED: bool = false;

    /// Whether the originating *Trust Task specification* obliges a *consumer*
    /// to reject a document that arrives without an `issuedAt`, per SPEC.md
    /// §7.3 item 17 (`issuedAtRequirement` declared `REQUIRED`).
    ///
    /// Item 17 raises the framework's §4.2 **SHOULD** to a **MUST** for the
    /// documents of the specification that declares it, and obliges every
    /// specification defining a *consequential Trust Task* (§2) to declare it.
    /// The reason is §7.2 item 11: duplicate-execution protection is
    /// implementable only over a bounded window, and a document carrying
    /// neither `expiresAt` nor `issuedAt` cannot be placed in one — so a
    /// consumer would have to retain its record forever or refuse to execute.
    ///
    /// Defaults to `false`, so a hand-written [`Payload`] impl (the crate's own
    /// `trust-task-error`, or any downstream one) keeps compiling unchanged.
    /// The codegen emits an explicit
    /// `const IS_ISSUED_AT_REQUIRED: bool = true;` override only when the
    /// spec's front matter declares it, exactly as it does for
    /// [`IS_PROOF_REQUIRED`](Self::IS_PROOF_REQUIRED).
    ///
    /// # Not the same thing as [`crate::FreshnessPolicy::require_issued_at`]
    ///
    /// That field is the **consumer's** own posture, chosen at the call site
    /// and applied to every document it sees. This constant is the
    /// **specification's** requirement, published in the registry and true of
    /// the type regardless of which consumer holds it. A consumer running the
    /// permissive [`FreshnessPolicy::default`](crate::FreshnessPolicy::default)
    /// still rejects a document of a spec that declares this, because the
    /// obligation is not the consumer's to relax.
    ///
    /// When `true`, a document with no `issuedAt` is rejected with
    /// `malformedRequest` — §8.3 defines no dedicated code, and `expired`
    /// would misdescribe a document that was never acceptable. It is the same
    /// code §7.2 item 13 already uses for the freshness rejections.
    ///
    /// Like [`IS_BEARER`](Self::IS_BEARER), this constant is emitted on both
    /// the request `Payload` impl and the response `Response` impl.
    const IS_ISSUED_AT_REQUIRED: bool = false;

    /// Raw text of the `payload.schema.json` describing values of this type,
    /// or `None` where this build has no schema for it.
    ///
    /// This is the artifact SPEC.md §7.2 item 2 is performed against. It is
    /// emitted unconditionally — it is a `&'static str` and pulls in no
    /// dependency; only *evaluating* it needs a JSON Schema implementation,
    /// which the caller supplies (see [`crate::PayloadPolicy`]).
    ///
    /// **Most of item 2 has already happened by the time you hold a
    /// `Payload`.** Deserializing into these generated types enforces
    /// required members, member types, `additionalProperties: false`, and the
    /// string constraints typify expresses as validating newtypes (`pattern`,
    /// `minLength`). What survives deserialization is what typify cannot
    /// express — `minProperties`, `minItems` on an optional array,
    /// conditional subschemas — and that residue is what a schema check
    /// against this constant still catches.
    ///
    /// `None` for hand-modelled payloads outside the codegen's reach
    /// (`trust-task-error`, whose shape is carried by the Rust type system
    /// instead). A policy that validates treats `None` as nothing to check;
    /// it is not a silent failure, because the type it deserialized into is
    /// itself the constraint.
    const PAYLOAD_SCHEMA: Option<&'static str> = None;

    /// Parsed form of [`TYPE_URI`](Self::TYPE_URI).
    ///
    /// The default implementation calls [`str::parse`] and panics on a
    /// malformed value — which can only happen if a `Payload` impl supplies
    /// an invalid `TYPE_URI`, i.e. a static-string bug worth surfacing
    /// loudly.
    fn type_uri() -> TypeUri {
        Self::TYPE_URI
            .parse()
            .expect("TYPE_URI constant must be a valid Type URI")
    }

    /// Build an extended [`TrustTaskCode`] under this payload's slug, per
    /// SPEC.md §8.5.
    ///
    /// Equivalent to writing:
    ///
    /// ```rust,ignore
    /// TrustTaskCode::new_extended("acl/change-role", "last_authority_protected").unwrap()
    /// ```
    ///
    /// but sources the slug from [`TYPE_URI`](Self::TYPE_URI) so the slug
    /// literal cannot drift away from the type's identity. The §8.5
    /// namespace rule ("the slug of the spec being processed") is then
    /// enforced by construction.
    ///
    /// `local` is validated against `spec.meta.schema.json`'s
    /// `errorCodes[].code` grammar (the part after the colon: a lowercase
    /// letter, then letters of either case, digits, or underscores).
    /// Both casings are accepted so that framework 0.2 lowerCamelCase
    /// locals (`documentRevoked`) and frozen framework 0.1 snake_case
    /// locals (`document_revoked`) parse under one rule; SPEC §4.10 item 4
    /// **SHOULD**s lowerCamelCase for new specifications. Panics on
    /// an invalid `local` — this method is for static call-site usage;
    /// callers handling runtime input should use
    /// [`TrustTaskCode::new_extended`] and propagate the `Result`.
    ///
    /// Also panics under the same condition as
    /// [`type_uri`](Self::type_uri): when [`TYPE_URI`](Self::TYPE_URI)
    /// is not a valid Type URI, i.e. a static-string bug.
    fn extended_code(local: impl Into<String>) -> TrustTaskCode {
        let slug = Self::type_uri().slug().to_string();
        let local = local.into();
        TrustTaskCode::new_extended(&slug, &local).unwrap_or_else(|e| {
            panic!(
                "Payload::extended_code({:?}) on slug {:?} failed validation: {e}",
                local, slug
            )
        })
    }

    /// Build an extended [`TrustTaskCode`] under a *family namespace*, per
    /// SPEC.md §8.5 rule 2.
    ///
    /// A family namespace is a proper path prefix of this payload's slug, used
    /// for a condition whose meaning is defined once across a family rather
    /// than per specification — `did-management:unknownDomain` on
    /// `did-management/did/delete`, say, where every member of the family can
    /// reject a request naming a domain the *consumer* does not host and the
    /// rejection means the same thing in each.
    ///
    /// ```rust,ignore
    /// // On a `did-management/did/delete` handler:
    /// let code = Payload::family_code("did-management", "unknownDomain");
    /// assert_eq!(code.to_string(), "did-management:unknownDomain");
    /// ```
    ///
    /// Use [`extended_code`](Self::extended_code) for a code the specification
    /// defines for itself; that is the common case. Reach for this only when
    /// the code is genuinely shared, because a family namespace claims the
    /// condition means the same thing across every sibling.
    ///
    /// `namespace` is checked against the slug derived from
    /// [`TYPE_URI`](Self::TYPE_URI) rather than taken on trust, so the §8.5
    /// prefix rule holds by construction and a hand-written namespace cannot
    /// drift away from the type's identity — the same guarantee
    /// [`extended_code`](Self::extended_code) provides for the own-slug case.
    ///
    /// Panics when `namespace` is neither the slug nor a proper path prefix of
    /// it, or when `local` fails the `errorCodes[].code` grammar. Like
    /// [`extended_code`](Self::extended_code) this method is for static
    /// call-site usage; callers handling runtime input should use
    /// [`TrustTaskCode::new_extended`] and propagate the `Result`.
    fn family_code(namespace: &str, local: impl Into<String>) -> TrustTaskCode {
        let slug = Self::type_uri().slug().to_string();
        let local = local.into();

        // The slug itself plus each proper path prefix of it.
        let permitted = slug
            .match_indices('/')
            .map(|(i, _)| &slug[..i])
            .chain(std::iter::once(slug.as_str()));
        if !permitted.into_iter().any(|p| p == namespace) {
            panic!(
                "Payload::family_code({namespace:?}, {local:?}) on slug {slug:?}: \
                 namespace is neither the slug nor a path prefix of it \
                 (SPEC §8.5 rule 2)"
            );
        }

        TrustTaskCode::new_extended(namespace, &local).unwrap_or_else(|e| {
            panic!(
                "Payload::family_code({:?}, {:?}) failed validation: {e}",
                namespace, local
            )
        })
    }
}

/// A [`Payload`] that names the response payload it is answered with.
///
/// This is the type-level half of the request/response pairing SPEC.md
/// §4.4.1 describes at the URI level: a response document's `type` is the
/// request's Type URI with a `#response` fragment, and
/// [`RequestPayload::Response`] is the Rust type carrying that payload.
///
/// It exists so a transport can *infer* the response type instead of being
/// told it. Before this trait, sending a request meant naming both halves —
///
/// ```rust,ignore
/// let resp = client.send::<grant::Payload, grant::Response>(req).await?;
/// ```
///
/// — and nothing stopped the two halves from belonging to different
/// specifications. `send::<grant::Payload, revoke::Response>(req)` compiled,
/// and the mistake surfaced as a decode failure against a live server.
/// With the pairing on the type, the second half is derived:
///
/// ```rust,ignore
/// let resp = client.send::<grant::Payload>(req).await?; // -> TrustTask<grant::Response>
/// ```
///
/// # Why this is a separate trait rather than an associated type on `Payload`
///
/// An associated type with a default (`type Response = ();`) would have
/// allowed [`Payload`] to carry the pairing without disturbing the impls
/// that have no response to name. Associated type defaults are still
/// unstable in Rust ([rust-lang/rust#29661]), so on stable the choice is
/// between a required associated type — which breaks every hand-written
/// [`Payload`] impl, including the crate's own hand-modelled
/// `trust-task-error` payload, and every impl downstream — and a second
/// trait. A second trait is used, so that implementing [`Payload`] stays
/// exactly as cheap as it was.
///
/// # Fire-and-forget specifications
///
/// A specification that defines no `$defs.Response` gets **no
/// `RequestPayload` impl**. There is no response document to name, and the
/// absence of the impl says so: `client.send::<chat::message::Payload>(req)`
/// does not compile, which is the correct answer for an exchange the
/// specification defines no reply to.
///
/// Two stand-ins were considered and rejected:
///
/// - **`()`** — not a [`Payload`], has no Type URI, and would make
///   `send()` compile for an exchange that yields no document, trading a
///   compile-time error for a runtime one (the HTTPS binding answers such a
///   request with `204` and no body).
/// - **`trust_task_ok::v0_1::Payload`** — a real payload, but the wrong one
///   to bake in: framework 0.5.0 deprecates `trust-task-ok` in favour of a
///   specification declaring an empty `#response` of its own. Pairing every
///   response-less specification with a type that is being retired would
///   need undoing at exactly the moment the framework made the pairing
///   expressible properly.
///
/// The second point is also why nothing is lost by waiting: when a
/// specification adopts framework 0.5.0's empty `#response`, the codegen
/// sees a `$defs.Response`, emits the type, and emits this impl alongside
/// it. No change here is needed to pick that up.
///
/// [rust-lang/rust#29661]: https://github.com/rust-lang/rust/issues/29661
pub trait RequestPayload: Payload {
    /// The payload of the success response to this request, per SPEC.md
    /// §4.4.1.
    ///
    /// Its [`Payload::TYPE_URI`] is this type's with a `#response` fragment
    /// appended; the codegen emits both from the one schema, so the two
    /// cannot drift.
    type Response: Payload;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::specs::acl::change_role::v0_1 as change_role;
    use crate::specs::acl::grant::v0_1 as grant;
    use crate::specs::trust_task_discovery::v0_1 as discovery;

    #[test]
    fn extended_code_sources_slug_from_type_uri() {
        let code = grant::Payload::extended_code("role_not_recognized");
        match code {
            TrustTaskCode::Extended { slug, local } => {
                assert_eq!(slug, "acl/grant");
                assert_eq!(local, "role_not_recognized");
            }
            other => panic!("expected Extended, got {other:?}"),
        }

        // Hierarchical slug — drift would be especially easy to hit by hand.
        let code = change_role::Payload::extended_code("last_authority_protected");
        assert_eq!(code.to_string(), "acl/change-role:last_authority_protected");
    }

    #[test]
    fn extended_code_works_for_single_segment_slug() {
        // Single-segment slug — no `/` in the namespace.
        let code = discovery::Payload::extended_code("filter_unsupported");
        assert_eq!(code.to_string(), "trust-task-discovery:filter_unsupported");
    }

    #[test]
    #[should_panic(expected = "failed validation")]
    fn extended_code_panics_on_invalid_local() {
        // A *leading* capital violates the `errorCodes[].code` grammar —
        // the resulting Extended would fail to round-trip through FromStr.
        // (Interior capitals are fine: lowerCamelCase locals are the
        // SPEC §4.10 preference. It is only the first character that must
        // be lowercase.) The trait method panics so a static-string bug
        // fails loudly instead of silently producing a code that fails
        // parsing later.
        let _ = grant::Payload::extended_code("BadLocal");
    }

    /// SPEC §8.5 rule 2 — a proper path prefix of the emitting slug is a
    /// legal namespace. This is the `did-management:unknownDomain` shape:
    /// 26 specifications in the registry declare it, and before `family_code`
    /// existed the only drift-safe helper derived the namespace from
    /// `TYPE_URI` and so could not mint the code the registry advertises.
    #[test]
    fn family_code_accepts_each_path_prefix_of_the_slug() {
        // Two-segment slug — the one available prefix.
        let code = change_role::Payload::family_code("acl", "permissionDenied");
        assert_eq!(code.to_string(), "acl:permissionDenied");

        // The full slug is permitted too, making family_code a superset of
        // extended_code rather than a disjoint alternative.
        let code = change_role::Payload::family_code("acl/change-role", "lastAuthorityProtected");
        assert_eq!(code.to_string(), "acl/change-role:lastAuthorityProtected");
    }

    /// A sibling's slug shares a prefix but is not itself a prefix, which is
    /// exactly the confusion §8.5 forbids ("never that of a related or
    /// referenced specification"). Rule 2 must not open a door to it.
    #[test]
    #[should_panic(expected = "neither the slug nor a path prefix")]
    fn family_code_rejects_a_sibling_slug() {
        let _ = grant::Payload::family_code("acl/revoke", "borrowedCode");
    }

    /// An unrelated namespace with no relationship to the slug at all.
    #[test]
    #[should_panic(expected = "neither the slug nor a path prefix")]
    fn family_code_rejects_an_unrelated_namespace() {
        let _ = grant::Payload::family_code("vault", "somethingElse");
    }

    /// A prefix must end on a segment boundary — `ac` is a string prefix of
    /// `acl/grant` but names nothing.
    #[test]
    #[should_panic(expected = "neither the slug nor a path prefix")]
    fn family_code_rejects_a_partial_segment() {
        let _ = grant::Payload::family_code("ac", "somethingElse");
    }

    /// Response payloads carry `#response` in TYPE_URI; the prefix check must
    /// run against the bare slug, as `extended_code` does.
    #[test]
    fn family_code_strips_response_fragment_before_checking() {
        let code = grant::Response::family_code("acl", "permissionDenied");
        assert_eq!(code.to_string(), "acl:permissionDenied");
    }

    #[test]
    fn extended_code_strips_response_fragment_from_slug() {
        // Response payloads carry `#response` in their TYPE_URI. The
        // helper MUST source the slug via `TypeUri::slug()`, which
        // drops the fragment — otherwise an error code minted from a
        // Response handler would name the wrong namespace.
        let code = grant::Response::extended_code("role_not_recognized");
        match code {
            TrustTaskCode::Extended { slug, .. } => {
                assert_eq!(slug, "acl/grant", "response variant must yield bare slug");
            }
            other => panic!("expected Extended, got {other:?}"),
        }
    }
}

/// The flag-driven consumer policy a *Trust Task specification* declares, read
/// off a generated [`Payload`] impl.
///
/// [`TrustTask::enforce_spec_policy`](crate::TrustTask::enforce_spec_policy) is
/// the typed entry point and stays the one most consumers want. This type
/// exists for the other shape: a consumer that dispatches on the Type URI and
/// holds a `TrustTask<serde_json::Value>`, so it has no `P` to read the
/// constants from. [`schema_index::spec_policy_for`](crate::schema_index::spec_policy_for)
/// hands it one keyed by URI.
///
/// Both paths run [`SpecPolicy::enforce`], so they cannot diverge on the check
/// set as new flag-driven rules are added — which is the whole reason this is a
/// value rather than four public constants for a caller to re-apply by hand.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpecPolicy {
    /// [`Payload::IS_BEARER`] — the spec opts out of §4.8.2 audience binding.
    pub is_bearer: bool,
    /// [`Payload::IS_PROOF_REQUIRED`] — §7.3 item 8.
    pub is_proof_required: bool,
    /// [`Payload::IS_RECIPIENT_REQUIRED`] — §7.2 item 5b.
    pub is_recipient_required: bool,
    /// [`Payload::IS_ISSUED_AT_REQUIRED`] — §7.3 item 17.
    pub is_issued_at_required: bool,
}

impl SpecPolicy {
    /// Read the policy off a generated payload type.
    pub const fn of<P: Payload>() -> Self {
        Self {
            is_bearer: P::IS_BEARER,
            is_proof_required: P::IS_PROOF_REQUIRED,
            is_recipient_required: P::IS_RECIPIENT_REQUIRED,
            is_issued_at_required: P::IS_ISSUED_AT_REQUIRED,
        }
    }

    /// Apply the checks to a document's envelope.
    ///
    /// Generic over the payload type and never reads it: every rule here is
    /// about `recipient`, `proof` and `issuedAt`. That is what lets a
    /// URI-dispatching consumer run the full check set against a
    /// `TrustTask<serde_json::Value>` without first deserializing into the
    /// generated type.
    pub fn enforce<P>(&self, doc: &crate::TrustTask<P>) -> Result<(), crate::RejectReason> {
        if doc.recipient.is_none() && self.is_recipient_required {
            return Err(crate::RejectReason::MalformedRequest {
                reason: "specification declares recipient REQUIRED but the document \
                         carries no in-band recipient"
                    .to_string(),
            });
        }
        if doc.proof.is_none() && self.is_proof_required {
            return Err(crate::RejectReason::ProofRequired);
        }
        if doc.issued_at.is_none() && self.is_issued_at_required {
            return Err(crate::RejectReason::MalformedRequest {
                reason: crate::freshness::ISSUED_AT_REQUIRED_BY_SPEC.to_string(),
            });
        }
        if doc.proof.is_some() && doc.recipient.is_none() && !self.is_bearer {
            return Err(crate::RejectReason::MalformedRequest {
                reason: "proof present with no in-band recipient on a non-bearer specification \
                         (SPEC §4.8.2 audience binding)"
                    .to_string(),
            });
        }
        Ok(())
    }
}