Skip to main content

dtg_credentials/
statement.rs

1//! Verifiable Statement Credentials (VSC): one type, many predicates.
2//!
3//! A VSC is the DTG's general-purpose claim — *I witnessed this party issue this
4//! credential*, *I endorse this party's skill*, *I vetted this party's identity*. Each is a
5//! statement a verifier reads and either believes or does not. Rather than give each
6//! predicate a credential type of its own, DTG Core Credentials defines one type,
7//! `StatementCredential`, and lets a **predicate profile** fix the constraints of each
8//! predicate.
9//!
10//! ```text
11//! credentialSubject: { id, predicate, object: { id | digestMultibase | value }, ...profile members }
12//! ```
13//!
14//! # A predicate is an identifier, matched as one
15//!
16//! `predicate` is an absolute IRI in Unicode Normalization Form C, compared byte for byte.
17//! A compact form — a CURIE such as `dtg:witnessed`, a bare JSON-LD term — is malformed,
18//! not unknown: no expansion is performed, so matching never depends on a JSON-LD context.
19//! [check_predicate_iri] is the check.
20//!
21//! Recognizing a predicate is the verifier's configuration, never the credential's: see
22//! [crate::PredicateAcceptList], which fails closed.
23//!
24//! # The core profiles
25//!
26//! | Constant | `object` | `taskContext` | minimum `issuerScope` | Constructor |
27//! |---|---|---|---|---|
28//! | [ENDORSES_V1] | `value` | OPTIONAL | — | [DTGCredential::new_endorses_vsc] |
29//! | [WITNESSED_V1] | `digestMultibase` | REQUIRED | `directed` | [DTGCredential::new_witnessed_vsc] |
30//! | [VETTED_V1] | `value` | REQUIRED | `directed` | [DTGCredential::new_vetted_vsc] |
31//! | [PRESENTED_V1] | `digestMultibase` | REQUIRED | `directed` | [DTGCredential::new_presented_vsc] |
32//!
33//! A statement under one of these is checked against its profile when it is parsed,
34//! built, validated and signed. A statement under any other predicate is only checked for
35//! shape; whether it means anything is the verifier's accept-list to say.
36//!
37//! # A statement attests; it never establishes
38//!
39//! Whatever its predicate says, a VSC does not confer representation, authority,
40//! membership, admission or personhood, and is not proof that a trust task completed. A
41//! predicate named `mayActFor` is a string.
42
43use chrono::{DateTime, Utc};
44use serde::{Deserialize, Serialize};
45use serde_json::Value;
46
47use crate::create::{check_window, check_witness_session, issuer_of};
48use crate::{
49    CredentialSubject, DTGCommon, DTGCredential, DTGCredentialError, DTGCredentialType,
50    IssuerScope, WitnessContext,
51};
52
53/// `dtg:endorses` — the issuer asserts something favourable about the subject: a skill, a
54/// standing, a reputation. A VSC under this profile is a verifiable endorsement credential
55/// (VEC).
56///
57/// `object` is a `value` whose schema the governing community's endorsement vocabulary
58/// defines. `taskContext` is OPTIONAL and the issuer's scope unconstrained.
59pub const ENDORSES_V1: &str = "https://registry.trustoverip.org/dtg/vsc/endorses/1";
60
61/// `dtg:witnessed` — the issuer attests that it observed the subject **issue** the
62/// credential `object.digestMultibase` names, under the conditions of the trust task
63/// exchange `taskContext` names. A VSC under this profile is a verifiable witness
64/// credential (VWC).
65///
66/// `credentialSubject.id` MUST be that credential's `issuer`; `taskContext` and
67/// `taskDigestMultibase` are REQUIRED; `issuerScope` is `directed` at minimum. It MAY carry
68/// a `witnessContext`.
69pub const WITNESSED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/witnessed/1";
70
71/// `dtg:vetted` — the issuer attests that it checked the subject's claimed identity in a
72/// vetting session, by the method and against the document classes `object.value` states.
73///
74/// The profile of DTG Core Credentials' identity-vetting worked example. `taskContext` and
75/// `taskDigestMultibase` are REQUIRED — the vetting exchange a dispute would examine — and
76/// `issuerScope` is `directed` at minimum. This library is generic about the payload: it
77/// is `object.value`, and its schema lives with the registry definition.
78pub const VETTED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/vetted/1";
79
80/// `dtg:presented` — the issuer attests that it observed the subject **present** the
81/// credential `object.digestMultibase` names, in the exchange `taskContext` names.
82///
83/// The counterpart [WITNESSED_V1] points to: there the subject is the referenced
84/// credential's issuer, here it is its **subject** — `credentialSubject.id` MUST be the
85/// `credentialSubject.id` of the credential the digest names. `taskContext` and
86/// `taskDigestMultibase` are REQUIRED; `issuerScope` is `directed` at minimum.
87pub const PRESENTED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/presented/1";
88
89/// Which of the three `object` members a statement carries.
90#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[serde(rename_all = "camelCase")]
92pub enum ObjectKind {
93    /// `object.id` — a DID or other IRI, when the object is a party or a named thing.
94    Id,
95    /// `object.digestMultibase` — when the object is another credential.
96    DigestMultibase,
97    /// `object.value` — a literal or structured payload whose schema the profile states.
98    Value,
99}
100
101impl std::fmt::Display for ObjectKind {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(match self {
104            ObjectKind::Id => "id",
105            ObjectKind::DigestMultibase => "digestMultibase",
106            ObjectKind::Value => "value",
107        })
108    }
109}
110
111/// What a statement says about its subject: **exactly one** of `id`, `digestMultibase` or
112/// `value`.
113///
114/// An object carrying two of them, or none, or any other member, is refused at parse.
115#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
116#[serde(rename_all = "camelCase")]
117pub enum StatementObject {
118    /// A DID or other IRI naming a party or a thing.
119    Id(String),
120
121    /// The digest of another credential, as [crate::digest_multibase_json] computes it
122    /// over that credential excluding its top-level `proof`.
123    DigestMultibase(String),
124
125    /// A literal or structured payload. Any JSON, including `null`.
126    ///
127    /// Defined in the DTG context as `@json`, so it is an opaque JSON literal canonicalized
128    /// by JCS rather than a nested graph.
129    Value(Value),
130}
131
132impl StatementObject {
133    /// Which of the three members this is.
134    pub fn kind(&self) -> ObjectKind {
135        match self {
136            StatementObject::Id(_) => ObjectKind::Id,
137            StatementObject::DigestMultibase(_) => ObjectKind::DigestMultibase,
138            StatementObject::Value(_) => ObjectKind::Value,
139        }
140    }
141
142    /// `object.id`, if that is what this is.
143    pub fn id(&self) -> Option<&str> {
144        match self {
145            StatementObject::Id(id) => Some(id),
146            _ => None,
147        }
148    }
149
150    /// `object.digestMultibase`, if that is what this is.
151    pub fn digest_multibase(&self) -> Option<&str> {
152        match self {
153            StatementObject::DigestMultibase(digest) => Some(digest),
154            _ => None,
155        }
156    }
157
158    /// `object.value`, if that is what this is.
159    pub fn value(&self) -> Option<&Value> {
160        match self {
161            StatementObject::Value(value) => Some(value),
162            _ => None,
163        }
164    }
165}
166
167/// Verifiable Statement Credential subject.
168#[derive(Serialize, Deserialize, Debug, Clone)]
169#[serde(rename_all = "camelCase")]
170pub struct CredentialSubjectStatement {
171    /// DID of the node the statement is about.
172    pub id: String,
173
174    /// The absolute IRI fixing the statement's meaning. See [check_predicate_iri].
175    pub predicate: String,
176
177    /// What the statement says about the subject.
178    pub object: StatementObject,
179
180    /// Context of the witnessing event — the OPTIONAL additional member of [WITNESSED_V1].
181    ///
182    /// Modelled because the v1 context defines it and the core profile names it. Carried
183    /// through a round trip on any statement, and meaningful under `witnessed/1` only.
184    #[serde(skip_serializing_if = "Option::is_none", default)]
185    pub witness_context: Option<WitnessContext>,
186
187    /// Members a profile adds that this library does not model, preserved verbatim.
188    ///
189    /// A verifier MUST ignore additional members a profile does not define, so that a
190    /// profile can add optional ones without invalidating credentials for older verifiers.
191    /// They are kept rather than dropped so that a digest over a parsed statement agrees
192    /// with the one over its wire form.
193    #[serde(flatten)]
194    pub extra: serde_json::Map<String, Value>,
195}
196
197/// The machine-checkable constraints of one predicate profile.
198///
199/// The four core profiles are [PredicateProfile::core]. A community profile is expressed
200/// through [crate::PredicateAcceptList], whose registry entries carry the same constraints.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct PredicateProfile {
203    /// The predicate IRI.
204    pub iri: &'static str,
205    /// The `object` kinds the profile permits.
206    pub object_kinds: &'static [ObjectKind],
207    /// Whether `taskContext` — and with it `taskDigestMultibase` — is REQUIRED.
208    pub task_context_required: bool,
209    /// The narrowest `issuerScope` the issuer can truthfully declare, if the profile sets
210    /// one.
211    pub minimum_issuer_scope: Option<IssuerScope>,
212}
213
214const CORE_PROFILES: [PredicateProfile; 4] = [
215    PredicateProfile {
216        iri: ENDORSES_V1,
217        object_kinds: &[ObjectKind::Value],
218        task_context_required: false,
219        minimum_issuer_scope: None,
220    },
221    PredicateProfile {
222        iri: WITNESSED_V1,
223        object_kinds: &[ObjectKind::DigestMultibase],
224        task_context_required: true,
225        minimum_issuer_scope: Some(IssuerScope::Directed),
226    },
227    PredicateProfile {
228        iri: VETTED_V1,
229        object_kinds: &[ObjectKind::Value],
230        task_context_required: true,
231        minimum_issuer_scope: Some(IssuerScope::Directed),
232    },
233    PredicateProfile {
234        iri: PRESENTED_V1,
235        object_kinds: &[ObjectKind::DigestMultibase],
236        task_context_required: true,
237        minimum_issuer_scope: Some(IssuerScope::Directed),
238    },
239];
240
241impl PredicateProfile {
242    /// The core profile for `iri` — [ENDORSES_V1], [WITNESSED_V1], [VETTED_V1] or
243    /// [PRESENTED_V1] — matched byte for byte.
244    pub fn core(iri: &str) -> Option<&'static PredicateProfile> {
245        CORE_PROFILES.iter().find(|profile| profile.iri == iri)
246    }
247
248    /// Every core profile this library implements.
249    pub fn all_core() -> &'static [PredicateProfile] {
250        &CORE_PROFILES
251    }
252
253    /// Checks a statement against this profile's constraints.
254    ///
255    /// Does not check the subject–object relationship, which needs the credential the
256    /// object names: see [DTGCredential::witnesses_issuance_of] and
257    /// [DTGCredential::witnesses_presentation_of].
258    pub(crate) fn check(
259        &self,
260        common: &DTGCommon,
261        subject: &CredentialSubjectStatement,
262    ) -> Result<(), DTGCredentialError> {
263        check_constraints(
264            self.object_kinds,
265            self.task_context_required,
266            self.minimum_issuer_scope,
267            common,
268            subject,
269        )
270    }
271}
272
273/// The checks a profile or an accept-list entry applies, whichever holds them.
274pub(crate) fn check_constraints(
275    object_kinds: &[ObjectKind],
276    task_context_required: bool,
277    minimum_issuer_scope: Option<IssuerScope>,
278    common: &DTGCommon,
279    subject: &CredentialSubjectStatement,
280) -> Result<(), DTGCredentialError> {
281    let kind = subject.object.kind();
282    if !object_kinds.contains(&kind) {
283        return Err(DTGCredentialError::ProfileViolation(format!(
284            "`{}` does not permit an `object.{kind}`",
285            subject.predicate
286        )));
287    }
288    if let Some(minimum) = minimum_issuer_scope
289        && !common.issuer_scope.satisfies(minimum)
290    {
291        return Err(DTGCredentialError::IssuerScopeTooNarrow {
292            declared: common.issuer_scope,
293            minimum,
294        });
295    }
296    if task_context_required {
297        if common.task_context.is_none() {
298            return Err(DTGCredentialError::MissingTaskContext);
299        }
300        if common.task_digest_multibase.is_none() {
301            return Err(DTGCredentialError::MissingTaskDigest);
302        }
303    }
304    Ok(())
305}
306
307/// The statement checks a parse and [DTGCredential::validate] both make: a well-formed
308/// predicate and object, and the constraints of the predicate's core profile where it has
309/// one.
310pub(crate) fn check_statement(
311    common: &DTGCommon,
312    subject: &CredentialSubjectStatement,
313) -> Result<(), DTGCredentialError> {
314    check_predicate_iri(&subject.predicate)?;
315    if let StatementObject::Id(id) = &subject.object
316        && !has_iri_scheme(id)
317    {
318        return Err(DTGCredentialError::ProfileViolation(format!(
319            "`object.id` `{id}` is not a DID or other absolute IRI"
320        )));
321    }
322    match PredicateProfile::core(&subject.predicate) {
323        Some(profile) => profile.check(common, subject),
324        None => Ok(()),
325    }
326}
327
328/// Does `s` begin with an RFC 3986 scheme — `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` —
329/// followed by `:` and something?
330fn has_iri_scheme(s: &str) -> bool {
331    let Some((scheme, rest)) = s.split_once(':') else {
332        return false;
333    };
334    let mut chars = scheme.chars();
335    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
336        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
337        && !rest.is_empty()
338}
339
340/// Checks that `predicate` is a well-formed predicate IRI: absolute, not a compact form,
341/// and in Unicode Normalization Form C.
342///
343/// A verifier MUST reject a credential whose `predicate` is not an absolute IRI, and no
344/// expansion is performed: a compact form is malformed, not unknown. This library applies
345/// that at parse, at issue and when checking against an accept-list.
346///
347/// # Telling an IRI from a CURIE
348///
349/// `dtg:witnessed` is syntactically an absolute IRI with the scheme `dtg`, so syntax alone
350/// cannot refuse it. The rule applied is structural: the IRI must be hierarchical with an
351/// authority (`scheme://authority…`), or use one of the two non-hierarchical schemes a
352/// predicate plausibly lives under, `urn:` and `did:`. A predicate MUST resolve to its
353/// definition, so this admits every predicate a registry or a community can publish, and
354/// refuses every `prefix:term` a JSON-LD context would have to expand.
355///
356/// # Normalization
357///
358/// Comparison is on the IRI as written, so two predicates that render identically but
359/// differ as byte strings are two predicates. An IRI that is not in NFC could never match
360/// its canonical spelling, and is refused rather than normalized.
361///
362/// # Errors
363///
364/// [DTGCredentialError::InvalidPredicate], naming what was wrong.
365pub fn check_predicate_iri(predicate: &str) -> Result<(), DTGCredentialError> {
366    let invalid = |why: &str| {
367        Err(DTGCredentialError::InvalidPredicate(format!(
368            "`{predicate}` {why}"
369        )))
370    };
371
372    if !unicode_normalization::is_nfc(predicate) {
373        return invalid("is not in Unicode Normalization Form C");
374    }
375    if let Some(c) = predicate
376        .chars()
377        .find(|c| c.is_whitespace() || c.is_control() || "<>\"{}|\\^`".contains(*c))
378    {
379        return invalid(&format!("contains {c:?}, which an IRI cannot"));
380    }
381    if !has_iri_scheme(predicate) {
382        return invalid(
383            "is not an absolute IRI — a relative reference or a bare JSON-LD term is never \
384             expanded",
385        );
386    }
387
388    let (scheme, rest) = predicate.split_once(':').expect("has a scheme");
389    let hierarchical = rest
390        .strip_prefix("//")
391        .is_some_and(|authority| !authority.is_empty() && !authority.starts_with('/'));
392    let opaque_scheme = scheme.eq_ignore_ascii_case("urn") || scheme.eq_ignore_ascii_case("did");
393    if !hierarchical && !opaque_scheme {
394        return invalid(
395            "is a compact IRI (CURIE); a predicate is always written as the absolute IRI \
396             and is never expanded against a context",
397        );
398    }
399    Ok(())
400}
401
402impl DTGCredential {
403    /// Creates a Verifiable Statement Credential (VSC) under any predicate.
404    ///
405    /// - `issuer` / `issuer_scope`: the party making the statement, and the correlation
406    ///   scope it declares for that identifier.
407    /// - `subject`: the DID of the node the statement is about.
408    /// - `predicate`: the absolute IRI fixing the statement's meaning — one of the core
409    ///   constants ([ENDORSES_V1], [WITNESSED_V1], [VETTED_V1], [PRESENTED_V1]) or a
410    ///   predicate a community defines in a namespace it controls.
411    /// - `object`: exactly one of `id`, `digestMultibase` or `value`.
412    ///
413    /// Prefer the profile constructors for the core predicates — [Self::new_endorses_vsc],
414    /// [Self::new_witnessed_vsc], [Self::new_vetted_vsc], [Self::new_presented_vsc] — which
415    /// set the citation a profile requires and enforce its subject–object rule. This one
416    /// checks what it can without them: the predicate, the `object` kind and the minimum
417    /// `issuerScope` of a core profile. A profile that REQUIRES `taskContext` is completed
418    /// with [DTGCredential::with_task_citation], and [DTGCredential::validate] — so also
419    /// [DTGCredential::sign] — refuses it until it is.
420    ///
421    /// # Errors
422    ///
423    /// [DTGCredentialError::InvalidPredicate] for a predicate that is not an absolute NFC
424    /// IRI, [DTGCredentialError::ProfileViolation] for an `object` a core profile does not
425    /// permit, [DTGCredentialError::IssuerScopeTooNarrow] for a scope below a core
426    /// profile's minimum, [DTGCredentialError::InvalidValidityWindow] for a window that
427    /// closes before it opens, and [DTGCredentialError::JsonTooDeep] for an `object.value`
428    /// nested past [crate::MAX_JSON_DEPTH].
429    pub fn new_vsc(
430        issuer: String,
431        issuer_scope: IssuerScope,
432        subject: String,
433        predicate: impl Into<String>,
434        object: StatementObject,
435        valid_from: DateTime<Utc>,
436        valid_until: Option<DateTime<Utc>>,
437    ) -> Result<Self, DTGCredentialError> {
438        check_window(valid_from, valid_until)?;
439        let predicate = predicate.into();
440        check_predicate_iri(&predicate)?;
441
442        let vsc = Self::build(
443            DTGCredentialType::Statement,
444            issuer,
445            issuer_scope,
446            valid_from,
447            valid_until,
448            CredentialSubject::Statement(CredentialSubjectStatement {
449                id: subject,
450                predicate,
451                object,
452                witness_context: None,
453                extra: serde_json::Map::new(),
454            }),
455        );
456        vsc.credential.check_depth()?;
457
458        // Everything but the citation, which a caller adds afterwards. The profile check
459        // proper runs again in `validate`, once the citation is there to check.
460        let statement = vsc.statement().expect("built as a statement");
461        check_statement_before_citation(&vsc.credential, statement)?;
462        Ok(vsc)
463    }
464
465    /// Creates a VEC — a statement under [ENDORSES_V1] — endorsing `subject` with
466    /// `endorsement` as `object.value`.
467    ///
468    /// The endorsement's schema is the governing community's vocabulary, and nothing about
469    /// it is checked but its depth. A VEC says only that its issuer said this: a verifier
470    /// must establish that the issuer is one whose endorsements it accepts for this purpose
471    /// before relying on any member of it.
472    ///
473    /// Not a role grant. A role *confers* something, and conferring is a VAC's job: see
474    /// [DTGCredential::new_community_role_vac].
475    ///
476    /// # Errors
477    ///
478    /// As [DTGCredential::new_vsc].
479    pub fn new_endorses_vsc(
480        issuer: String,
481        issuer_scope: IssuerScope,
482        subject: String,
483        endorsement: Value,
484        valid_from: DateTime<Utc>,
485        valid_until: Option<DateTime<Utc>>,
486    ) -> Result<Self, DTGCredentialError> {
487        Self::new_vsc(
488            issuer,
489            issuer_scope,
490            subject,
491            ENDORSES_V1,
492            StatementObject::Value(endorsement),
493            valid_from,
494            valid_until,
495        )
496    }
497
498    /// Creates a VWC — a statement under [WITNESSED_V1] — attesting that the witness
499    /// observed a party issue `witnessed`, in the `witness/session` that `session` opened.
500    ///
501    /// Both halves of every binding are read from the documents themselves, so none of
502    /// them can disagree:
503    ///
504    /// - `credentialSubject.id` is `witnessed`'s `issuer` — the profile's subject–object
505    ///   rule: the witness observed the subject *issue* it. One VWC per direction, so a
506    ///   witnessed VRC pair takes two calls, one with each VRC.
507    /// - `object.digestMultibase` is the digest of `witnessed` in its wire form, top-level
508    ///   `proof` excluded, as [crate::digest_multibase_json] computes it.
509    /// - `taskContext` is the `id` of `session`, and `taskDigestMultibase` its task digest
510    ///   (`witness/session/submit` Conformance, item 1).
511    ///
512    /// `issuer` is the witness — a member, or a VTA acting under VTC policy — and must
513    /// declare at least `directed`: a witness's identifier must be recognizable to both
514    /// parties and to the community, so `pairwise` cannot describe it truthfully.
515    ///
516    /// # Errors
517    ///
518    /// - [DTGCredentialError::IssuerScopeTooNarrow] for `pairwise`.
519    /// - [DTGCredentialError::ProfileViolation] if `witnessed` is not a JSON object with an
520    ///   `issuer`.
521    /// - [DTGCredentialError::MalformedTaskDocument] if `session` is not an object with a
522    ///   string `id`; [DTGCredentialError::NotAWitnessSession] if its `type` is not a
523    ///   `witness/session` version or its `threadId` is not its own `id` — which catches
524    ///   citing the `submit` document, or the relationship exchange the session is nested
525    ///   in.
526    /// - [DTGCredentialError::InvalidValidityWindow] and [DTGCredentialError::JsonTooDeep].
527    ///
528    /// # Security
529    ///
530    /// Pass the session document the witness itself received and answered, not one the
531    /// party supplies alongside its submission, and the edge credential the witness itself
532    /// observed. The digests are load-bearing because the witness signs them.
533    pub fn new_witnessed_vsc(
534        issuer: String,
535        issuer_scope: IssuerScope,
536        witnessed: &Value,
537        session: &Value,
538        valid_from: DateTime<Utc>,
539        valid_until: Option<DateTime<Utc>>,
540        witness_context: Option<WitnessContext>,
541    ) -> Result<Self, DTGCredentialError> {
542        check_window(valid_from, valid_until)?;
543        check_minimum_scope(WITNESSED_V1, issuer_scope)?;
544        check_witness_session(session)?;
545        crate::check_json_depth(witnessed)?;
546
547        let subject = witnessed.as_object().and_then(issuer_of).ok_or_else(|| {
548            DTGCredentialError::ProfileViolation(
549                "the witnessed credential has no `issuer`, so there is no party the \
550                     witness observed issuing it"
551                    .into(),
552            )
553        })?;
554
555        let mut vwc = Self::new_vsc(
556            issuer,
557            issuer_scope,
558            subject,
559            WITNESSED_V1,
560            StatementObject::DigestMultibase(crate::digest_multibase_json(witnessed)?),
561            valid_from,
562            valid_until,
563        )?;
564        if let Some(statement) = vwc.credential.statement_mut() {
565            statement.witness_context = witness_context;
566        }
567        vwc.with_task_citation(session)
568    }
569
570    /// Creates a vetting statement — a statement under [VETTED_V1] — recording that the
571    /// issuer checked `subject`'s claimed identity in the vetting exchange `session`
572    /// opened.
573    ///
574    /// `vetting` is `object.value`. This library is generic about it: the payload's schema
575    /// is the registry definition's, and a typed payload belongs with the code that fills
576    /// it in. `session` is the initiating document of the vetting exchange;
577    /// `taskContext` and `taskDigestMultibase` are both read from it.
578    ///
579    /// `issuer` is the vetter, issuing under the member identifier its VMC names, and must
580    /// declare at least `directed`. Whether it was *eligible* to vet is a fact about the
581    /// community, checked separately — a community-issued VAC is the credential that
582    /// answers it (see [DTGCredential::new_community_role_vac]).
583    ///
584    /// # Errors
585    ///
586    /// [DTGCredentialError::IssuerScopeTooNarrow] for `pairwise`,
587    /// [DTGCredentialError::MalformedTaskDocument] if `session` has no string `id`, and the
588    /// errors of [DTGCredential::new_vsc].
589    pub fn new_vetted_vsc(
590        issuer: String,
591        issuer_scope: IssuerScope,
592        subject: String,
593        vetting: Value,
594        session: &Value,
595        valid_from: DateTime<Utc>,
596        valid_until: Option<DateTime<Utc>>,
597    ) -> Result<Self, DTGCredentialError> {
598        check_window(valid_from, valid_until)?;
599        check_minimum_scope(VETTED_V1, issuer_scope)?;
600
601        Self::new_vsc(
602            issuer,
603            issuer_scope,
604            subject,
605            VETTED_V1,
606            StatementObject::Value(vetting),
607            valid_from,
608            valid_until,
609        )?
610        .with_task_citation(session)
611    }
612
613    /// Creates a statement under [PRESENTED_V1], attesting that the issuer observed a
614    /// party present `presented` in the exchange `session` opened.
615    ///
616    /// As for [DTGCredential::new_witnessed_vsc], both halves of each binding are read
617    /// from the documents: `credentialSubject.id` is `presented`'s own
618    /// `credentialSubject.id` — the party who holds it, and the profile's subject–object
619    /// rule — `object.digestMultibase` its digest, and the citation `session`'s `id` and
620    /// task digest.
621    ///
622    /// # Errors
623    ///
624    /// [DTGCredentialError::IssuerScopeTooNarrow] for `pairwise`,
625    /// [DTGCredentialError::ProfileViolation] if `presented` has no `credentialSubject.id`,
626    /// [DTGCredentialError::MalformedTaskDocument] if `session` has no string `id`, and the
627    /// errors of [DTGCredential::new_vsc].
628    pub fn new_presented_vsc(
629        issuer: String,
630        issuer_scope: IssuerScope,
631        presented: &Value,
632        session: &Value,
633        valid_from: DateTime<Utc>,
634        valid_until: Option<DateTime<Utc>>,
635    ) -> Result<Self, DTGCredentialError> {
636        check_window(valid_from, valid_until)?;
637        check_minimum_scope(PRESENTED_V1, issuer_scope)?;
638        crate::check_json_depth(presented)?;
639
640        let subject = subject_of(presented).ok_or_else(|| {
641            DTGCredentialError::ProfileViolation(
642                "the presented credential has no `credentialSubject.id`, so there is no \
643                 holder to name"
644                    .into(),
645            )
646        })?;
647
648        Self::new_vsc(
649            issuer,
650            issuer_scope,
651            subject,
652            PRESENTED_V1,
653            StatementObject::DigestMultibase(crate::digest_multibase_json(presented)?),
654            valid_from,
655            valid_until,
656        )?
657        .with_task_citation(session)
658    }
659
660    /// Is this a [WITNESSED_V1] statement about `credential` — the edge credential in its
661    /// wire form — and does the profile's subject–object rule hold?
662    ///
663    /// True when the predicate is `witnessed/1`, `object.digestMultibase` matches
664    /// `credential`'s recomputed digest (decoded bytes, not strings), and
665    /// `credentialSubject.id` is `credential`'s `issuer`. A verifier holding the referenced
666    /// credential MUST check both, and this is those two checks.
667    ///
668    /// # What this does not check
669    ///
670    /// The statement's proof, its window, whether its issuer is a witness the verifier
671    /// trusts, its `taskContext` against the session (see [DTGCredential::cites_task]), and
672    /// whether `credential` is itself valid: a witness attestation is about claims at the
673    /// moment of witnessing, not about their being current.
674    ///
675    /// # Errors
676    ///
677    /// [DTGCredentialError::InvalidDigest] or [DTGCredentialError::UnsupportedDigestAlgorithm]
678    /// if the carried digest cannot be read, rather than `Ok(false)`.
679    pub fn witnesses_issuance_of(&self, credential: &Value) -> Result<bool, DTGCredentialError> {
680        let named = credential.as_object().and_then(issuer_of);
681        self.statement_names(WITNESSED_V1, credential, named)
682    }
683
684    /// Is this a [PRESENTED_V1] statement about `credential` in its wire form, with
685    /// `credentialSubject.id` equal to `credential`'s own `credentialSubject.id`?
686    ///
687    /// The [PRESENTED_V1] counterpart of [DTGCredential::witnesses_issuance_of], with the
688    /// same caveats.
689    pub fn witnesses_presentation_of(
690        &self,
691        credential: &Value,
692    ) -> Result<bool, DTGCredentialError> {
693        self.statement_names(PRESENTED_V1, credential, subject_of(credential))
694    }
695
696    /// The shared half of the two subject–object checks: the predicate, the digest, and
697    /// the party the statement is about.
698    fn statement_names(
699        &self,
700        predicate: &str,
701        credential: &Value,
702        expected_subject: Option<String>,
703    ) -> Result<bool, DTGCredentialError> {
704        let Some(statement) = self.statement() else {
705            return Ok(false);
706        };
707        let Some(carried) = statement.object.digest_multibase() else {
708            return Ok(false);
709        };
710        if statement.predicate != predicate
711            || expected_subject.as_deref() != Some(statement.id.as_str())
712        {
713            return Ok(false);
714        }
715        crate::digests_match(carried, &crate::digest_multibase_json(credential)?)
716    }
717}
718
719/// The checks [DTGCredential::new_vsc] can make before a citation is attached: every
720/// profile constraint but `taskContext`.
721fn check_statement_before_citation(
722    common: &DTGCommon,
723    subject: &CredentialSubjectStatement,
724) -> Result<(), DTGCredentialError> {
725    check_predicate_iri(&subject.predicate)?;
726    match PredicateProfile::core(&subject.predicate) {
727        Some(profile) => check_constraints(
728            profile.object_kinds,
729            false,
730            profile.minimum_issuer_scope,
731            common,
732            subject,
733        ),
734        None => Ok(()),
735    }
736}
737
738/// Refuses an `issuer_scope` narrower than the core profile of `predicate` permits, before
739/// any document is read.
740fn check_minimum_scope(
741    predicate: &str,
742    issuer_scope: IssuerScope,
743) -> Result<(), DTGCredentialError> {
744    match PredicateProfile::core(predicate).and_then(|p| p.minimum_issuer_scope) {
745        Some(minimum) if !issuer_scope.satisfies(minimum) => {
746            Err(DTGCredentialError::IssuerScopeTooNarrow {
747                declared: issuer_scope,
748                minimum,
749            })
750        }
751        _ => Ok(()),
752    }
753}
754
755/// `credentialSubject.id` of a credential in its wire form.
756fn subject_of(credential: &Value) -> Option<String> {
757    credential
758        .get("credentialSubject")
759        .and_then(|subject| subject.get("id"))
760        .and_then(Value::as_str)
761        .map(str::to_string)
762}