Skip to main content

dtg_credentials/
create.rs

1/*!
2*   Builder methods for creating new entities.
3*/
4
5use crate::{
6    AuthorityGrant, CredentialSubject, CredentialSubjectAuthority, CredentialSubjectBasic,
7    CredentialSubjectDelegation, CredentialSubjectMembership, DTGCredential, DTGCredentialError,
8    DTGCredentialType, DelegationGrant, IssuerScope,
9};
10use chrono::{DateTime, SubsecRound, Utc};
11use serde_json::Value;
12
13/// Refuses a validity window that closes before, or at the instant, it opens.
14///
15/// Only the ordering is checked. A `valid_from` in the past is legitimate — backdating is
16/// how a re-issued credential keeps the date the original took effect — and whether a
17/// window is current is a question about an instant the verifier chooses.
18///
19/// Both ends are compared at whole seconds, because that is all the wire form carries: a
20/// window a few hundred milliseconds wide in memory serializes as an empty one.
21pub(crate) fn check_window(
22    valid_from: DateTime<Utc>,
23    valid_until: Option<DateTime<Utc>>,
24) -> Result<(), DTGCredentialError> {
25    match valid_until {
26        Some(valid_until) if valid_until.trunc_subsecs(0) <= valid_from.trunc_subsecs(0) => {
27            Err(DTGCredentialError::InvalidValidityWindow {
28                valid_from,
29                valid_until,
30            })
31        }
32        _ => Ok(()),
33    }
34}
35
36/// The `type` prefix every version of `witness/session` shares.
37const WITNESS_SESSION_TYPE_PREFIX: &str = "https://trusttasks.org/spec/witness/session/";
38
39/// Refuses anything but the `witness/session` document that opened a witness session.
40///
41/// Two checks, both about naming the right exchange. The `type` must be
42/// `witness/session/<major>.<minor>` exactly — `witness/session/submit/0.1` shares the
43/// prefix and is the likeliest wrong document to hold, and a `#response` fragment is the
44/// witness's answer, not the opening document. And `threadId` must equal `id`, which
45/// `witness/session` Conformance, item 1, requires of the opening document.
46pub(crate) fn check_witness_session(session: &Value) -> Result<(), DTGCredentialError> {
47    let object = session
48        .as_object()
49        .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("not a JSON object".into()))?;
50    let id = object
51        .get("id")
52        .and_then(Value::as_str)
53        .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("no string `id`".into()))?;
54
55    let type_ = object
56        .get("type")
57        .and_then(Value::as_str)
58        .unwrap_or_default();
59    let is_version = |v: &str| {
60        v.split_once('.').is_some_and(|(major, minor)| {
61            !major.is_empty()
62                && !minor.is_empty()
63                && major.bytes().all(|b| b.is_ascii_digit())
64                && minor.bytes().all(|b| b.is_ascii_digit())
65        })
66    };
67    if !type_
68        .strip_prefix(WITNESS_SESSION_TYPE_PREFIX)
69        .is_some_and(is_version)
70    {
71        return Err(DTGCredentialError::NotAWitnessSession(format!(
72            "`type` is `{type_}`"
73        )));
74    }
75
76    match object.get("threadId").and_then(Value::as_str) {
77        Some(thread_id) if thread_id == id => Ok(()),
78        Some(thread_id) => Err(DTGCredentialError::NotAWitnessSession(format!(
79            "`threadId` `{thread_id}` is not the document's own `id` `{id}`"
80        ))),
81        None => Err(DTGCredentialError::NotAWitnessSession(
82            "no `threadId`; the opening document names its own thread".into(),
83        )),
84    }
85}
86
87/// The issuer of a credential in its wire form: a string, or an object carrying an `id`, per
88/// the W3C data model.
89pub(crate) fn issuer_of(credential: &serde_json::Map<String, Value>) -> Option<String> {
90    credential.get("issuer").and_then(|issuer| {
91        issuer
92            .as_str()
93            .or_else(|| issuer.get("id").and_then(Value::as_str))
94            .map(str::to_string)
95    })
96}
97
98/// Reads an RFC 3339 timestamp off a credential in its wire form, under its W3C VC 2.0 name
99/// or its 1.1 alias.
100///
101/// `Ok(None)` when neither is present. A value that is present but unreadable is an error
102/// rather than an absence: an expiry that cannot be read must not be treated as no expiry.
103pub(crate) fn read_timestamp(
104    credential: &serde_json::Map<String, Value>,
105    name: &str,
106    alias: &str,
107) -> Result<Option<DateTime<Utc>>, String> {
108    let Some(value) = credential.get(name).or_else(|| credential.get(alias)) else {
109        return Ok(None);
110    };
111    value
112        .as_str()
113        .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
114        .map(|t| Some(t.with_timezone(&Utc)))
115        .ok_or_else(|| format!("`{name}` is not an RFC 3339 timestamp"))
116}
117
118/// The `validUntil` of a grant in its wire form, if it has one.
119fn grant_valid_until(grant: &Value) -> Result<Option<DateTime<Utc>>, String> {
120    match grant.as_object() {
121        Some(object) => read_timestamp(object, "validUntil", "expirationDate"),
122        None => Ok(None),
123    }
124}
125
126/// Refuses an answer to a grant — an acknowledgement or an acceptance — that would remain
127/// valid after the grant it answers has expired.
128///
129/// Open-ended counts as outliving a grant that expires. Compared at whole seconds, the
130/// precision the wire form carries.
131fn check_within_grant(
132    valid_until: Option<DateTime<Utc>>,
133    grant_valid_until: Option<DateTime<Utc>>,
134) -> Result<(), DTGCredentialError> {
135    let Some(grant_valid_until) = grant_valid_until else {
136        return Ok(());
137    };
138    match valid_until {
139        Some(until) if until.trunc_subsecs(0) <= grant_valid_until.trunc_subsecs(0) => Ok(()),
140        _ => Err(DTGCredentialError::OutlivesGrant {
141            valid_until,
142            grant_valid_until,
143        }),
144    }
145}
146
147/// The prefix of the action a community-issued role VAC confers: `role:<name>`.
148pub const ROLE_ACTION_PREFIX: &str = "role:";
149
150/// The action string conferring role `name`: `role:<name>`, as
151/// [DTGCredential::new_community_role_vac] issues it. `role_action("vetter")` is
152/// `"role:vetter"`.
153///
154/// A convention, not a vocabulary this library enforces: actions are compared exactly,
155/// and the governing party defines what each one means.
156pub fn role_action(name: &str) -> String {
157    format!("{ROLE_ACTION_PREFIX}{name}")
158}
159
160/// What the holder asks for when attenuating, gathered so the two entry points share one
161/// narrowing check.
162struct Attenuation {
163    issuer_scope: IssuerScope,
164    subject: String,
165    actions: Vec<String>,
166    valid_from: DateTime<Utc>,
167    valid_until: DateTime<Utc>,
168    max_attenuation: Option<u32>,
169}
170
171impl DTGCredential {
172    /// Creates a new community-issued Verifiable Membership Credential (VMC) — the
173    /// membership **grant**, the community → member half of a membership edge.
174    ///
175    /// A membership edge is a *pair* of VMCs, and this is only one of them. The member
176    /// answers with [DTGCredential::new_member_vmc_for], and the edge is not complete until
177    /// they have: a community can always issue a credential naming somebody as a member,
178    /// but it cannot produce the acknowledgement without that party's signature. The pair
179    /// is what makes an unconsented membership claim unprovable.
180    ///
181    /// The grant MUST NOT carry a `digestMultibase` — that property is what marks the
182    /// other direction — and this constructor does not set one.
183    ///
184    /// # `issuerScope` is always `public`
185    ///
186    /// A community's own identifier can only truthfully be declared `public` — a community
187    /// that cannot be found cannot be joined — so there is no parameter for it: the grant
188    /// declares `public`, and a grant declaring anything else is refused at parse. The
189    /// member declares its own scope in the acknowledgement.
190    ///
191    /// issuer: The identifier of the VTC or VTN granting membership
192    /// subject: The member's identifier, or the member VTC's own for VTN membership
193    /// valid_from: The datetime from which this credential is valid
194    /// valid_until: Optional: The datetime this credential is valid until
195    /// personhood: Whether this VMC can be used as a form of Personhood Credential
196    ///             - Adds PersonhoodCredential to the type array if true, as the
197    ///               non-authoritative hint DTG Core Credentials permits. PHC status is
198    ///               determined by governance and trust registries, never by this string.
199    ///
200    /// # Give it an `id`
201    ///
202    /// Chain [DTGCredential::with_id] on: the member stores the grant under its `id`, and
203    /// re-issuing is only recognisable as a renewal rather than a duplicate if there is one.
204    pub fn new_vmc(
205        issuer: String,
206        subject: String,
207        valid_from: DateTime<Utc>,
208        valid_until: Option<DateTime<Utc>>,
209        personhood: bool,
210    ) -> Self {
211        let mut vmc = Self::build(
212            DTGCredentialType::Membership,
213            issuer,
214            IssuerScope::Public,
215            valid_from,
216            valid_until,
217            CredentialSubject::Membership(CredentialSubjectMembership {
218                id: subject,
219                digest_multibase: None,
220            }),
221        );
222
223        if personhood {
224            vmc.credential
225                .type_
226                .push(crate::PERSONHOOD_HINT.to_string());
227        }
228        vmc
229    }
230
231    /// Creates a new member-issued Verifiable Membership Credential (VMC) — the membership
232    /// **acknowledgement**, the member → community half of a membership edge.
233    ///
234    /// The roles of [DTGCredential::new_vmc] are reversed (the member issues, the community
235    /// is the subject) and the subject carries a `digestMultibase` of the grant being
236    /// acknowledged.
237    /// That digest is what binds the two halves into one edge: an acknowledgement whose
238    /// digest matches no valid grant does not complete anything, and the binding forces an
239    /// order — the grant must exist before this can reference it.
240    ///
241    /// This is the member's consent artifact. Because the member is its issuer, withdrawing
242    /// consent needs no cooperation from the community.
243    ///
244    /// # Takes the grant in its wire form, deliberately
245    ///
246    /// `grant` is the JSON the community sent, not a parsed [DTGCredential]. The digest has
247    /// to cover the document the community will recompute it over, and this library does
248    /// not model every member a credential may carry — `credentialStatus`, which every VMC
249    /// issued against a status list carries, is dropped by a parse-then-re-serialise round
250    /// trip. Building the acknowledgement from a parsed grant would produce a digest that
251    /// verifies nowhere, and would do it silently.
252    ///
253    /// So: keep the bytes you were given, and pass them here.
254    ///
255    /// member: The member acknowledging — the party whose key will sign this. Refused unless
256    ///         the grant names exactly this identifier as its subject.
257    /// issuer_scope: The correlation scope the member declares for `member` — its own
258    ///         choice, which this specification does not constrain. Declare the same scope
259    ///         on every credential issued under one identifier.
260    /// valid_from: The datetime from which this credential is valid
261    /// valid_until: Optional: The datetime this credential is valid until. Must not be later
262    ///              than the grant's own `validUntil`, and must be set if the grant's is.
263    ///
264    /// # Errors
265    ///
266    /// [DTGCredentialError::NotAMembershipGrant] if `grant` is not a JSON object, does not
267    /// carry `MembershipCredential` in its `type`, has no `issuer` or
268    /// `credentialSubject.id`, or already carries a `digestMultibase` — that last is an
269    /// acknowledgement, and acknowledging one does not form an edge. Also if its
270    /// `issuerScope` is not `public`, the only scope a community-issued grant can carry.
271    ///
272    /// It is also [DTGCredentialError::NotAMembershipGrant] if the grant carries a
273    /// `validUntil` that is not an RFC 3339 timestamp.
274    ///
275    /// [DTGCredentialError::NotTheGrantSubject] if the grant's `credentialSubject.id` is not
276    /// `member`.
277    ///
278    /// [DTGCredentialError::OutlivesGrant] if the grant expires and `valid_until` is later
279    /// than it, or absent.
280    ///
281    /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
282    /// `valid_from`, and [DTGCredentialError::JsonTooDeep] if the grant is nested more deeply
283    /// than [crate::MAX_JSON_DEPTH].
284    ///
285    /// # Give it an `id`
286    ///
287    /// Chain [DTGCredential::with_id] on before signing. A community keys a member's VMC by
288    /// `id` to tell a re-send from a renewal.
289    ///
290    /// # Security
291    ///
292    /// The result is binding evidence, not membership. This constructor does not verify the
293    /// grant's proof, so it builds an acknowledgement of a grant nobody signed as readily as
294    /// of one the community did. Verify the grant first — with
295    /// `verify_grant_with_public_key` under the `affinidi-signing` feature, or against your
296    /// own resolver — and treat the edge as complete only once both proofs and both windows
297    /// have verified.
298    ///
299    /// Pass as `member` the identity whose key will sign the acknowledgement, established
300    /// independently of the grant. An identifier read out of the grant would make the check
301    /// compare the grant with itself.
302    pub fn new_member_vmc_for(
303        grant: &Value,
304        member: &str,
305        issuer_scope: IssuerScope,
306        valid_from: DateTime<Utc>,
307        valid_until: Option<DateTime<Utc>>,
308    ) -> Result<Self, DTGCredentialError> {
309        check_window(valid_from, valid_until)?;
310
311        let (found, community) = Self::read_membership_grant(grant)?;
312        if found != member {
313            return Err(DTGCredentialError::NotTheGrantSubject {
314                expected: member.to_string(),
315                found,
316            });
317        }
318        let grant_valid_until =
319            grant_valid_until(grant).map_err(DTGCredentialError::NotAMembershipGrant)?;
320        check_within_grant(valid_until, grant_valid_until)?;
321
322        Self::assemble_member_vmc(
323            grant,
324            found,
325            issuer_scope,
326            community,
327            valid_from,
328            valid_until,
329        )
330    }
331
332    /// Reads the member and the community off a membership grant in its wire form, refusing
333    /// anything that is not a community-issued grant.
334    fn read_membership_grant(grant: &Value) -> Result<(String, String), DTGCredentialError> {
335        let object = grant
336            .as_object()
337            .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("not a JSON object".into()))?;
338
339        let is_membership = object
340            .get("type")
341            .and_then(Value::as_array)
342            .is_some_and(|types| {
343                types
344                    .iter()
345                    .filter_map(Value::as_str)
346                    .any(|t| t == "MembershipCredential")
347            });
348        if !is_membership {
349            return Err(DTGCredentialError::NotAMembershipGrant(
350                "`type` does not include `MembershipCredential`".into(),
351            ));
352        }
353
354        let subject = object
355            .get("credentialSubject")
356            .and_then(Value::as_object)
357            .ok_or_else(|| {
358                DTGCredentialError::NotAMembershipGrant("no `credentialSubject`".into())
359            })?;
360
361        // Both spellings: `digestMultibase` is the Working Draft 02 name, `digest` the
362        // Working Draft 01 one this library also accepts on the wire. Probing only the
363        // current name would let an acknowledgement issued against the older draft be
364        // acknowledged in turn, which forms no edge.
365        if subject.contains_key("digestMultibase") || subject.contains_key("digest") {
366            return Err(DTGCredentialError::NotAMembershipGrant(
367                "the credential carries a digest of another credential, so it is itself a \
368                 member-issued acknowledgement rather than a community-issued grant"
369                    .into(),
370            ));
371        }
372
373        // The member is the grant's subject and the community its issuer: reading both off
374        // the grant is what keeps the two halves naming the same pair. Taking them as
375        // parameters would let a caller acknowledge one grant while naming the parties of
376        // another, which verifies as a digest match and means nothing.
377        let member = subject
378            .get("id")
379            .and_then(Value::as_str)
380            .ok_or_else(|| {
381                DTGCredentialError::NotAMembershipGrant("no `credentialSubject.id`".into())
382            })?
383            .to_string();
384
385        let community = issuer_of(object)
386            .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("no `issuer`".into()))?;
387
388        // A community-issued grant declares `public`, the only scope a community can
389        // truthfully declare. One that declares anything else is not a conformant grant.
390        match object.get("issuerScope").and_then(Value::as_str) {
391            Some("public") => {}
392            Some(other) => {
393                return Err(DTGCredentialError::NotAMembershipGrant(format!(
394                    "the grant declares issuerScope `{other}`; a community-issued VMC is \
395                     always `public`"
396                )));
397            }
398            None => {
399                return Err(DTGCredentialError::NotAMembershipGrant(
400                    "no `issuerScope`".into(),
401                ));
402            }
403        }
404
405        Ok((member, community))
406    }
407
408    /// Assembles the acknowledgement once the grant has been read and every check has passed.
409    fn assemble_member_vmc(
410        grant: &Value,
411        member: String,
412        issuer_scope: IssuerScope,
413        community: String,
414        valid_from: DateTime<Utc>,
415        valid_until: Option<DateTime<Utc>>,
416    ) -> Result<Self, DTGCredentialError> {
417        Ok(Self::build(
418            DTGCredentialType::Membership,
419            member,
420            issuer_scope,
421            valid_from,
422            valid_until,
423            CredentialSubject::Membership(CredentialSubjectMembership {
424                id: community,
425                digest_multibase: Some(crate::digest_multibase_json(grant)?),
426            }),
427        ))
428    }
429
430    /// Creates a new Verified Relationship Credential (VRC)
431    /// issuer: The DID of the source party
432    /// issuer_scope: The scope the source party declares for `issuer`. `pairwise` is
433    ///               RECOMMENDED; a wider declaration is a disclosure made deliberately.
434    /// subject: The DID of the target party as used in this relationship
435    /// valid_from: The datetime from which this credential is valid
436    /// valid_until: Optional: The datetime this credential is valid until
437    pub fn new_vrc(
438        issuer: String,
439        issuer_scope: IssuerScope,
440        subject: String,
441        valid_from: DateTime<Utc>,
442        valid_until: Option<DateTime<Utc>>,
443    ) -> Self {
444        Self::build(
445            DTGCredentialType::Relationship,
446            issuer,
447            issuer_scope,
448            valid_from,
449            valid_until,
450            CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
451        )
452    }
453
454    /// Creates a new Verified Invitation Credential (VIC)
455    /// issuer: The DID of the VTC or VTN, or of an authorized member or member VTC
456    /// issuer_scope: The scope the issuer declares for `issuer` — `public` where the issuer
457    ///               is the VTC or VTN itself
458    /// subject: The DID of the prospective member or member VTC
459    /// valid_from: The datetime from which this credential is valid
460    /// valid_until: Optional: The datetime this credential is valid until. Keep it short:
461    ///              an invitation should be single-use and short-lived.
462    pub fn new_vic(
463        issuer: String,
464        issuer_scope: IssuerScope,
465        subject: String,
466        valid_from: DateTime<Utc>,
467        valid_until: Option<DateTime<Utc>>,
468    ) -> Self {
469        Self::build(
470            DTGCredentialType::Invitation,
471            issuer,
472            issuer_scope,
473            valid_from,
474            valid_until,
475            CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
476        )
477    }
478
479    /// Creates a new Verifiable Authority Credential (VAC) — a chain root.
480    ///
481    /// The issuer is the party governing `scope`. To derive a narrower VAC from one you
482    /// already hold, use [DTGCredential::attenuate] instead: a chain root is a grant made
483    /// by the governing party, and minting one directly is how a self-issued grant of
484    /// arbitrary authority gets in.
485    ///
486    /// `actions` MUST NOT be empty — an empty list confers nothing rather than everything.
487    ///
488    /// `issuer_scope` is the governing party's declaration for its own identifier —
489    /// `public` where the issuer is the party governing the scope, which a chain root's
490    /// issuer is. For a community conferring a role on a member,
491    /// [DTGCredential::new_community_role_vac] fixes it.
492    ///
493    /// Attenuation below this VAC is permitted by default. To bound or forbid it, chain
494    /// [DTGCredential::with_max_attenuation] on before signing.
495    ///
496    /// ```
497    /// # use chrono::{Duration, Utc};
498    /// # use dtg_credentials::{DTGCredential, IssuerScope};
499    /// let vac = DTGCredential::new_vac(
500    ///     "did:example:room".to_string(),
501    ///     IssuerScope::Public,
502    ///     "did:example:member".to_string(),
503    ///     "did:example:room".to_string(),
504    ///     vec!["read".to_string(), "write".to_string()],
505    ///     Utc::now(),
506    ///     Utc::now() + Duration::days(30),
507    /// )
508    /// .unwrap();
509    /// assert_eq!(vac.credential().authority().unwrap().actions, ["read", "write"]);
510    /// ```
511    ///
512    /// # `valid_until` is required
513    ///
514    /// Not optional, unlike the base structure and unlike every other `new_*` constructor
515    /// here. Nothing about the subject's current standing is consulted when a VAC is
516    /// verified, so authority that does not expire is authority nobody can withdraw by
517    /// waiting.
518    ///
519    /// # Errors
520    ///
521    /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
522    /// `valid_from`, and [DTGCredentialError::EmptyAuthorityActions] if `actions` is empty.
523    pub fn new_vac(
524        issuer: String,
525        issuer_scope: IssuerScope,
526        subject: String,
527        scope: String,
528        actions: Vec<String>,
529        valid_from: DateTime<Utc>,
530        valid_until: DateTime<Utc>,
531    ) -> Result<Self, DTGCredentialError> {
532        check_window(valid_from, Some(valid_until))?;
533
534        if actions.is_empty() {
535            return Err(DTGCredentialError::EmptyAuthorityActions);
536        }
537        Ok(Self::build(
538            DTGCredentialType::Authority,
539            issuer,
540            issuer_scope,
541            valid_from,
542            Some(valid_until),
543            CredentialSubject::Authority(CredentialSubjectAuthority {
544                id: subject,
545                authority: AuthorityGrant {
546                    scope,
547                    actions,
548                    parent: None,
549                    max_attenuation: None,
550                },
551            }),
552        ))
553    }
554
555    /// Creates the VAC a community issues to confer a **role** on one of its members: a
556    /// chain root with `scope` the community's own DID and `actions` the single
557    /// `role:<name>` action [role_action] spells.
558    ///
559    /// A role is authority, not reputation. It is conferred by the party governing the
560    /// scope — the community — and a verifier reads it as a decision that party made,
561    /// which is what a VAC is and an endorsement is not. So the vetter role a community
562    /// makes a member eligible for is:
563    ///
564    /// ```
565    /// # use chrono::{Duration, Utc};
566    /// # use dtg_credentials::{DTGCredential, IssuerScope};
567    /// let vac = DTGCredential::new_community_role_vac(
568    ///     "did:example:community".to_string(),
569    ///     "did:example:member".to_string(),
570    ///     "vetter",
571    ///     Utc::now(),
572    ///     Utc::now() + Duration::days(90),
573    /// )
574    /// .unwrap();
575    ///
576    /// assert_eq!(vac.issuer_scope(), IssuerScope::Public);
577    /// let authority = vac.credential().authority().unwrap();
578    /// assert_eq!(authority.scope, "did:example:community");
579    /// assert_eq!(authority.actions, ["role:vetter"]);
580    /// ```
581    ///
582    /// `issuerScope` is `public`, the only scope a community can truthfully declare. The
583    /// role is checked like any other action — exactly and case-sensitively, through
584    /// [crate::authority::verify_chain] with `requested_scope` the community's DID and
585    /// `requested_action` the `role:<name>` string. A member holding several roles holds
586    /// several VACs, or one built with [DTGCredential::new_vac] listing every role action:
587    /// `actions` stays a plain string set, and nothing here privileges the `role:`
588    /// convention over any other action a community defines.
589    ///
590    /// Membership is a separate credential. A role VAC does not attest that its subject is
591    /// a member, and a verifier requiring both asks for both.
592    ///
593    /// # Errors
594    ///
595    /// As [DTGCredential::new_vac]. `role` is not validated beyond being non-empty.
596    pub fn new_community_role_vac(
597        community: String,
598        member: String,
599        role: &str,
600        valid_from: DateTime<Utc>,
601        valid_until: DateTime<Utc>,
602    ) -> Result<Self, DTGCredentialError> {
603        if role.is_empty() {
604            return Err(DTGCredentialError::EmptyAuthorityActions);
605        }
606        Self::new_vac(
607            community.clone(),
608            IssuerScope::Public,
609            member,
610            community,
611            vec![role_action(role)],
612            valid_from,
613            valid_until,
614        )
615    }
616
617    /// Sets `authority.maxAttenuation` on a VAC: the number of further attenuations
618    /// permitted below it, with `0` forbidding attenuation outright.
619    ///
620    /// For a chain root, where any value is the governing party's to choose. On a VAC this
621    /// library attenuated, pass the limit to [DTGCredential::attenuate] instead, which
622    /// refuses one above what the parent permits; set here, an over-limit value is not
623    /// caught until [crate::authority::verify_chain] rejects the chain.
624    ///
625    /// # Set it before signing
626    ///
627    /// Same caveat as [DTGCredential::with_id].
628    ///
629    /// # Errors
630    ///
631    /// [DTGCredentialError::NotAnAuthorityCredential] on anything but a VAC.
632    pub fn with_max_attenuation(
633        mut self,
634        max_attenuation: u32,
635    ) -> Result<Self, DTGCredentialError> {
636        self.credential
637            .authority_mut()
638            .ok_or(DTGCredentialError::NotAnAuthorityCredential)?
639            .max_attenuation = Some(max_attenuation);
640        Ok(self)
641    }
642
643    /// Derive a narrower VAC from one this holder already holds.
644    ///
645    /// This is what lets a member equip an agent, a device, or a short-lived session with
646    /// only the authority that task needs, rather than lending it their own. The derived
647    /// credential is issued by the *holder*, not by the party governing the scope, and
648    /// carries `parent` — the **digest** of the credential it narrows — so a verifier can
649    /// walk back to a root.
650    ///
651    /// Refuses anything that would widen. The checks here mirror
652    /// [crate::authority::verify_chain] on purpose: a holder should be unable to *build* a
653    /// chain a verifier would reject, so the failure surfaces at issue time rather than at
654    /// use — but the verifier's checks remain authoritative, because nothing stops a
655    /// different implementation constructing the JSON by hand.
656    ///
657    /// - `self` must be a VAC, and must not bear `maxAttenuation` `0`.
658    /// - `actions` must be a subset of what `self` confers.
659    /// - `valid_until` must not exceed `self`'s.
660    /// - `max_attenuation` must not exceed one less than `self`'s, where `self` bears one.
661    ///   `None` takes that ceiling — the strictest the chain already imposes — or, where
662    ///   `self` bears none, leaves the derived VAC unbounded too.
663    ///
664    /// `issuer_scope` is the attenuating holder's own declaration for its identifier: the
665    /// derived VAC's issuer is `self`'s subject, and it is that party's scope to declare.
666    ///
667    /// # Binding the derivative to the agent is `subject`, not a separate field
668    ///
669    /// A VAC is not a bearer credential: [crate::authority::verify_chain] requires the
670    /// party presenting the leaf to be its subject. So equipping an agent means naming the
671    /// agent in `subject`, and there is nothing further to bind. An earlier version of this
672    /// method took an `audience` for that job; it was removed with the property.
673    ///
674    /// # Digests the model
675    ///
676    /// The `parent` digest is computed with [DTGCredential::digest_multibase], which hashes
677    /// this in-memory credential. That is right for a VAC this process built and signed.
678    /// For one that **arrived from a counterparty**, use
679    /// [DTGCredential::attenuate_from_json] and give it the bytes you received — the same
680    /// distinction [DTGCredential::new_member_vmc_for] draws, and for the same reason.
681    pub fn attenuate(
682        &self,
683        issuer_scope: IssuerScope,
684        subject: String,
685        actions: Vec<String>,
686        valid_from: DateTime<Utc>,
687        valid_until: DateTime<Utc>,
688        max_attenuation: Option<u32>,
689    ) -> Result<Self, DTGCredentialError> {
690        let parent_grant = self
691            .credential()
692            .authority()
693            .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
694
695        Self::attenuate_inner(
696            parent_grant.clone(),
697            self.credential().subject().to_string(),
698            self.credential().valid_until(),
699            self.digest_multibase()?,
700            Attenuation {
701                issuer_scope,
702                subject,
703                actions,
704                valid_from,
705                valid_until,
706                max_attenuation,
707            },
708        )
709    }
710
711    /// Derive a narrower VAC from a parent in its **wire form**.
712    ///
713    /// Identical to [DTGCredential::attenuate] except that the parent is the JSON a
714    /// counterparty sent rather than a parsed credential, so the `parent` digest covers
715    /// the document the verifier will recompute it over. Use this whenever the VAC being
716    /// narrowed came from somewhere else.
717    ///
718    /// # Errors
719    ///
720    /// [DTGCredentialError::NotAnAuthorityCredential] if `parent` is not a JSON object
721    /// carrying `AuthorityCredential` in its `type` and a well-formed
722    /// `credentialSubject.authority`, and the same widening errors as
723    /// [DTGCredential::attenuate].
724    pub fn attenuate_from_json(
725        parent: &Value,
726        issuer_scope: IssuerScope,
727        subject: String,
728        actions: Vec<String>,
729        valid_from: DateTime<Utc>,
730        valid_until: DateTime<Utc>,
731        max_attenuation: Option<u32>,
732    ) -> Result<Self, DTGCredentialError> {
733        // Before any member is read out: reading clones one, and cloning recurses.
734        crate::check_json_depth(parent)?;
735
736        let object = parent
737            .as_object()
738            .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
739
740        let is_authority = object
741            .get("type")
742            .and_then(Value::as_array)
743            .is_some_and(|types| {
744                types
745                    .iter()
746                    .filter_map(Value::as_str)
747                    .any(|t| t == "AuthorityCredential")
748            });
749        if !is_authority {
750            return Err(DTGCredentialError::NotAnAuthorityCredential);
751        }
752
753        let parent_subject = object
754            .get("credentialSubject")
755            .and_then(Value::as_object)
756            .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
757
758        // The holder attenuating is the parent's subject; reading it off the parent is what
759        // keeps a derived VAC from citing a chain its issuer never held.
760        let holder = parent_subject
761            .get("id")
762            .and_then(Value::as_str)
763            .ok_or(DTGCredentialError::NotAnAuthorityCredential)?
764            .to_string();
765
766        let parent_grant: AuthorityGrant = parent_subject
767            .get("authority")
768            .ok_or(DTGCredentialError::NotAnAuthorityCredential)
769            .and_then(|a| {
770                serde_json::from_value(a.clone())
771                    .map_err(|_| DTGCredentialError::NotAnAuthorityCredential)
772            })?;
773
774        let parent_until = object
775            .get("validUntil")
776            .or_else(|| object.get("expirationDate"))
777            .and_then(Value::as_str)
778            .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
779            .map(|t| t.with_timezone(&Utc));
780
781        Self::attenuate_inner(
782            parent_grant,
783            holder,
784            parent_until,
785            crate::digest_multibase_json(parent)?,
786            Attenuation {
787                issuer_scope,
788                subject,
789                actions,
790                valid_from,
791                valid_until,
792                max_attenuation,
793            },
794        )
795    }
796
797    /// The narrowing checks and the assembly, shared by both attenuation entry points.
798    fn attenuate_inner(
799        parent_grant: AuthorityGrant,
800        holder: String,
801        parent_until: Option<DateTime<Utc>>,
802        parent_digest: String,
803        derived: Attenuation,
804    ) -> Result<Self, DTGCredentialError> {
805        let Attenuation {
806            issuer_scope,
807            subject,
808            actions,
809            valid_from,
810            valid_until,
811            max_attenuation,
812        } = derived;
813        check_window(valid_from, Some(valid_until))?;
814
815        // `maxAttenuation` is a ceiling every link inherits: `0` forbids a child at all, and
816        // a child of `n` may bear at most `n - 1`. Absent on the parent, the child may set
817        // any limit or none.
818        let max_attenuation = match (parent_grant.max_attenuation, max_attenuation) {
819            (Some(0), _) => {
820                return Err(DTGCredentialError::AttenuationWidens(
821                    "the parent bears `maxAttenuation` 0, which forbids attenuating it".into(),
822                ));
823            }
824            (Some(n), Some(requested)) if requested > n - 1 => {
825                return Err(DTGCredentialError::AttenuationWidens(format!(
826                    "`maxAttenuation` {requested} exceeds the {} the parent's {n} permits",
827                    n - 1
828                )));
829            }
830            (Some(n), None) => Some(n - 1),
831            (_, requested) => requested,
832        };
833
834        if actions.is_empty() {
835            return Err(DTGCredentialError::EmptyAuthorityActions);
836        }
837        for action in &actions {
838            if !parent_grant.actions.contains(action) {
839                return Err(DTGCredentialError::AttenuationWidens(format!(
840                    "action `{action}` is not conferred by the parent"
841                )));
842            }
843        }
844        if let Some(parent_until) = parent_until
845            && valid_until > parent_until
846        {
847            return Err(DTGCredentialError::AttenuationWidens(format!(
848                "validUntil {valid_until} is beyond the parent's {parent_until}"
849            )));
850        }
851
852        Ok(Self::build(
853            DTGCredentialType::Authority,
854            // The holder issues: they are the subject of the parent grant.
855            holder,
856            issuer_scope,
857            valid_from,
858            Some(valid_until),
859            CredentialSubject::Authority(CredentialSubjectAuthority {
860                id: subject,
861                authority: AuthorityGrant {
862                    // Scope never changes down a chain.
863                    scope: parent_grant.scope.clone(),
864                    actions,
865                    parent: Some(parent_digest),
866                    max_attenuation,
867                },
868            }),
869        ))
870    }
871
872    /// Creates a new Verifiable Delegation Credential (VDC) — the delegation **grant**,
873    /// the delegator → delegate half of a delegation edge.
874    ///
875    /// Establishes that `subject` may act **in the issuer's name**, for the acts named in
876    /// `scope`, until `valid_until`. Within that scope what the delegate does is
877    /// attributable to the delegator.
878    ///
879    /// # This is not authority
880    ///
881    /// A VDC never supplies permission the delegator did not itself hold. A verifier
882    /// substitutes the delegator for the delegate and then asks the permission question it
883    /// would have asked of the delegator directly — so withdrawing the delegator's own
884    /// permission ends the delegate's ability to act immediately, without revoking
885    /// anything. See [DTGCredential::new_vac] for the credential that answers that
886    /// question.
887    ///
888    /// # The edge is not complete without the acceptance
889    ///
890    /// This is one half. The delegate answers with [DTGCredential::new_delegate_vdc_for], and
891    /// a verifier MUST obtain and verify that half before accepting any party as acting
892    /// under the delegation: a grant alone establishes what the delegator appointed, not
893    /// what the delegate agreed to. Same consent rule as a membership edge, and for the
894    /// same reason — a delegator can always name someone as its delegate, but cannot
895    /// produce the countersignature.
896    ///
897    /// `scope` MUST NOT be empty: a VDC cannot express an unbounded appointment by
898    /// omitting it.
899    ///
900    /// `max_depth` is the number of further re-delegations permitted below this one.
901    /// `None` and `Some(0)` both prohibit re-delegation — the default is a single hop, and
902    /// setting it above zero is the delegator's explicit authorisation, of which there is
903    /// no other kind.
904    ///
905    /// `issuer_scope` is the delegator's declaration for `issuer`. A VDC is presented to
906    /// every verifier the delegate acts toward, so `pairwise` is seldom truthful and
907    /// `directed` is the ordinary declaration.
908    ///
909    /// # `valid_until` is required
910    ///
911    /// An appointment with no expiry cannot be reasoned about by a verifier that cannot
912    /// reach the delegator.
913    ///
914    /// # Errors
915    ///
916    /// [DTGCredentialError::MalformedDelegation] if `scope` is empty, and
917    /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
918    /// `valid_from`.
919    pub fn new_vdc(
920        issuer: String,
921        issuer_scope: IssuerScope,
922        subject: String,
923        valid_from: DateTime<Utc>,
924        valid_until: DateTime<Utc>,
925        scope: Vec<String>,
926        max_depth: Option<u32>,
927    ) -> Result<Self, DTGCredentialError> {
928        check_window(valid_from, Some(valid_until))?;
929
930        if scope.is_empty() {
931            return Err(DTGCredentialError::MalformedDelegation(
932                "a grant MUST carry at least one `scope` entry — a VDC cannot express an \
933                 unbounded appointment by emptying it"
934                    .into(),
935            ));
936        }
937
938        Ok(Self::build(
939            DTGCredentialType::Delegation,
940            issuer,
941            issuer_scope,
942            valid_from,
943            Some(valid_until),
944            CredentialSubject::Delegation(CredentialSubjectDelegation {
945                id: subject,
946                delegation: DelegationGrant {
947                    scope: Some(scope),
948                    parent: None,
949                    max_depth,
950                    accepts: None,
951                },
952            }),
953        ))
954    }
955
956    /// Derive a further VDC from one this delegate already holds — a **re-delegation**.
957    ///
958    /// Only permitted where the held VDC sets `maxDepth` above zero, and only for a subset
959    /// of the acts it was itself appointed for. The default is a single hop: a delegate
960    /// that needs a further delegate and is not authorised to re-delegate asks the
961    /// principal, who issues a fresh root delegation directly — so that the principal
962    /// always holds the complete register of who may speak in its name.
963    ///
964    /// The derived VDC carries `parent`, the digest of the VDC it derives from, and a
965    /// `maxDepth` one less than its parent's.
966    ///
967    /// Like [DTGCredential::attenuate], this digests the in-memory model; for a grant that
968    /// arrived from a counterparty, use [DTGCredential::redelegate_from_json].
969    ///
970    /// `issuer_scope` is the re-delegating delegate's own declaration for its identifier.
971    ///
972    /// # Errors
973    ///
974    /// [DTGCredentialError::MalformedDelegation] if `self` is not a delegation grant, if
975    /// it does not permit re-delegation, if `scope` is empty or not a subset of the
976    /// parent's, or if `valid_until` is later than the parent's.
977    /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
978    /// `valid_from`.
979    pub fn redelegate(
980        &self,
981        issuer_scope: IssuerScope,
982        subject: String,
983        scope: Vec<String>,
984        valid_from: DateTime<Utc>,
985        valid_until: DateTime<Utc>,
986    ) -> Result<Self, DTGCredentialError> {
987        let parent = self.credential().delegation().ok_or_else(|| {
988            DTGCredentialError::MalformedDelegation("not a DelegationCredential".into())
989        })?;
990
991        Self::redelegate_inner(
992            parent.clone(),
993            self.credential().subject().to_string(),
994            self.credential().valid_until(),
995            self.digest_multibase()?,
996            issuer_scope,
997            subject,
998            scope,
999            valid_from,
1000            valid_until,
1001        )
1002    }
1003
1004    /// Derive a further VDC from a parent grant in its **wire form**.
1005    ///
1006    /// Identical to [DTGCredential::redelegate] except that the parent is the JSON the
1007    /// delegator sent, so the `parent` digest covers the document a verifier will
1008    /// recompute it over.
1009    pub fn redelegate_from_json(
1010        parent: &Value,
1011        issuer_scope: IssuerScope,
1012        subject: String,
1013        scope: Vec<String>,
1014        valid_from: DateTime<Utc>,
1015        valid_until: DateTime<Utc>,
1016    ) -> Result<Self, DTGCredentialError> {
1017        let (delegate, grant, parent_until) = Self::read_delegation_json(parent)?;
1018
1019        Self::redelegate_inner(
1020            grant,
1021            delegate,
1022            parent_until,
1023            crate::digest_multibase_json(parent)?,
1024            issuer_scope,
1025            subject,
1026            scope,
1027            valid_from,
1028            valid_until,
1029        )
1030    }
1031
1032    /// The narrowing checks and the assembly, shared by both re-delegation entry points.
1033    #[allow(clippy::too_many_arguments)]
1034    fn redelegate_inner(
1035        parent_grant: DelegationGrant,
1036        holder: String,
1037        parent_until: Option<DateTime<Utc>>,
1038        parent_digest: String,
1039        issuer_scope: IssuerScope,
1040        subject: String,
1041        scope: Vec<String>,
1042        valid_from: DateTime<Utc>,
1043        valid_until: DateTime<Utc>,
1044    ) -> Result<Self, DTGCredentialError> {
1045        check_window(valid_from, Some(valid_until))?;
1046
1047        if parent_grant.accepts.is_some() {
1048            return Err(DTGCredentialError::MalformedDelegation(
1049                "the parent is an acceptance, not a grant — an acceptance appoints nobody \
1050                 and cannot be re-delegated from"
1051                    .into(),
1052            ));
1053        }
1054
1055        // Absence prohibits re-delegation just as `0` does. This is the opposite default
1056        // from a VAC, deliberately: a delegate speaks in the principal's name, so the
1057        // principal keeps the register of who may do so.
1058        let parent_depth = parent_grant.max_depth.unwrap_or(0);
1059        if parent_depth == 0 {
1060            return Err(DTGCredentialError::MalformedDelegation(
1061                "the parent does not permit re-delegation — `maxDepth` is absent or zero, \
1062                 and setting it above zero is the delegator's only way to authorise one"
1063                    .into(),
1064            ));
1065        }
1066
1067        if scope.is_empty() {
1068            return Err(DTGCredentialError::MalformedDelegation(
1069                "a grant MUST carry at least one `scope` entry".into(),
1070            ));
1071        }
1072        let parent_scope = parent_grant.scope.as_deref().unwrap_or(&[]);
1073        for act in &scope {
1074            if !parent_scope.contains(act) {
1075                return Err(DTGCredentialError::MalformedDelegation(format!(
1076                    "`{act}` is not in the scope this delegation derives from"
1077                )));
1078            }
1079        }
1080        if let Some(parent_until) = parent_until
1081            && valid_until > parent_until
1082        {
1083            return Err(DTGCredentialError::MalformedDelegation(format!(
1084                "validUntil {valid_until} is beyond the parent's {parent_until}"
1085            )));
1086        }
1087
1088        Ok(Self::build(
1089            DTGCredentialType::Delegation,
1090            holder,
1091            issuer_scope,
1092            valid_from,
1093            Some(valid_until),
1094            CredentialSubject::Delegation(CredentialSubjectDelegation {
1095                id: subject,
1096                delegation: DelegationGrant {
1097                    scope: Some(scope),
1098                    parent: Some(parent_digest),
1099                    max_depth: Some(parent_depth - 1),
1100                    accepts: None,
1101                },
1102            }),
1103        ))
1104    }
1105
1106    /// Creates the delegate-issued half of a delegation edge — the **acceptance**.
1107    ///
1108    /// The roles of [DTGCredential::new_vdc] are reversed (the delegate issues, the
1109    /// delegator is the subject) and the subject carries `accepts`, the digest of the
1110    /// grant being taken on. That digest is what binds the two halves into one edge.
1111    ///
1112    /// An acceptance carries no `scope` of its own. What the delegate consented to is the
1113    /// scope of the grant it names, which a verifier holds in any case; restating it would
1114    /// require an equality check across the two credentials that cannot be satisfied under
1115    /// selective disclosure of either.
1116    ///
1117    /// This is the delegate's consent artifact, and its accountability for acting in
1118    /// another's name. Because a delegator cannot produce it, a party holding only the
1119    /// delegate's key cannot manufacture appointments either.
1120    ///
1121    /// # Takes the grant in its wire form, deliberately
1122    ///
1123    /// Same reasoning as [DTGCredential::new_member_vmc_for]: the digest has to cover the
1124    /// document the delegator will recompute it over. Keep the bytes you were given and
1125    /// pass them here.
1126    ///
1127    /// # Errors
1128    ///
1129    /// [DTGCredentialError::NotADelegationGrant] if `grant` is not a JSON object carrying
1130    /// `DelegationCredential` in its `type`, has no `issuer` or `credentialSubject.id`, or
1131    /// already carries `accepts` — that last is itself an acceptance, and accepting one
1132    /// forms no edge.
1133    ///
1134    /// It is also [DTGCredentialError::NotADelegationGrant] if the grant carries a
1135    /// `validUntil` that is not an RFC 3339 timestamp.
1136    ///
1137    /// [DTGCredentialError::NotTheGrantSubject] if the grant's `credentialSubject.id` is not
1138    /// `delegate`.
1139    ///
1140    /// [DTGCredentialError::OutlivesGrant] if `valid_until` is later than the grant's.
1141    ///
1142    /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
1143    /// `valid_from`, and [DTGCredentialError::JsonTooDeep] if the grant is nested more deeply
1144    /// than [crate::MAX_JSON_DEPTH].
1145    ///
1146    /// # Security
1147    ///
1148    /// The result is binding evidence, not an appointment. This constructor does not verify
1149    /// the grant's proof, so it builds an acceptance of a grant nobody signed as readily as of
1150    /// one the delegator did. Verify the grant first — with `verify_grant_with_public_key`
1151    /// under the `affinidi-signing` feature, or against your own resolver — and treat the edge
1152    /// as complete only once both proofs and both windows have verified.
1153    ///
1154    /// Pass as `delegate` the identity whose key will sign the acceptance, established
1155    /// independently of the grant. An identifier read out of the grant would make the check
1156    /// compare the grant with itself.
1157    pub fn new_delegate_vdc_for(
1158        grant: &Value,
1159        delegate: &str,
1160        issuer_scope: IssuerScope,
1161        valid_from: DateTime<Utc>,
1162        valid_until: DateTime<Utc>,
1163    ) -> Result<Self, DTGCredentialError> {
1164        check_window(valid_from, Some(valid_until))?;
1165
1166        let (found, delegator) = Self::read_delegation_grant(grant)?;
1167        if found != delegate {
1168            return Err(DTGCredentialError::NotTheGrantSubject {
1169                expected: delegate.to_string(),
1170                found,
1171            });
1172        }
1173        let grant_valid_until =
1174            grant_valid_until(grant).map_err(DTGCredentialError::NotADelegationGrant)?;
1175        check_within_grant(Some(valid_until), grant_valid_until)?;
1176
1177        Self::assemble_delegate_vdc(
1178            grant,
1179            found,
1180            issuer_scope,
1181            delegator,
1182            valid_from,
1183            valid_until,
1184        )
1185    }
1186
1187    /// Reads the delegate and the delegator off a delegation grant in its wire form, refusing
1188    /// anything that is not a grant.
1189    fn read_delegation_grant(grant: &Value) -> Result<(String, String), DTGCredentialError> {
1190        let object = grant
1191            .as_object()
1192            .ok_or_else(|| DTGCredentialError::NotADelegationGrant("not a JSON object".into()))?;
1193
1194        let is_delegation = object
1195            .get("type")
1196            .and_then(Value::as_array)
1197            .is_some_and(|types| {
1198                types
1199                    .iter()
1200                    .filter_map(Value::as_str)
1201                    .any(|t| t == "DelegationCredential")
1202            });
1203        if !is_delegation {
1204            return Err(DTGCredentialError::NotADelegationGrant(
1205                "`type` does not include `DelegationCredential`".into(),
1206            ));
1207        }
1208
1209        let subject = object
1210            .get("credentialSubject")
1211            .and_then(Value::as_object)
1212            .ok_or_else(|| {
1213                DTGCredentialError::NotADelegationGrant("no `credentialSubject`".into())
1214            })?;
1215
1216        let delegation = subject
1217            .get("delegation")
1218            .and_then(Value::as_object)
1219            .ok_or_else(|| {
1220                DTGCredentialError::NotADelegationGrant("no `credentialSubject.delegation`".into())
1221            })?;
1222
1223        if delegation.contains_key("accepts") {
1224            return Err(DTGCredentialError::NotADelegationGrant(
1225                "the credential carries `accepts`, so it is itself an acceptance rather \
1226                 than a grant"
1227                    .into(),
1228            ));
1229        }
1230        if !delegation.contains_key("scope") {
1231            return Err(DTGCredentialError::NotADelegationGrant(
1232                "the grant carries no `scope`, so there is no appointment to accept".into(),
1233            ));
1234        }
1235
1236        // The delegate is the grant's subject and the delegator its issuer. Reading both
1237        // off the grant is what keeps the two halves naming the same pair — taking them as
1238        // parameters would let a caller accept one grant while naming the parties of
1239        // another, which verifies as a digest match and means nothing.
1240        let delegate = subject
1241            .get("id")
1242            .and_then(Value::as_str)
1243            .ok_or_else(|| {
1244                DTGCredentialError::NotADelegationGrant("no `credentialSubject.id`".into())
1245            })?
1246            .to_string();
1247
1248        let delegator = issuer_of(object)
1249            .ok_or_else(|| DTGCredentialError::NotADelegationGrant("no `issuer`".into()))?;
1250
1251        Ok((delegate, delegator))
1252    }
1253
1254    /// Assembles the acceptance once the grant has been read and every check has passed.
1255    fn assemble_delegate_vdc(
1256        grant: &Value,
1257        delegate: String,
1258        issuer_scope: IssuerScope,
1259        delegator: String,
1260        valid_from: DateTime<Utc>,
1261        valid_until: DateTime<Utc>,
1262    ) -> Result<Self, DTGCredentialError> {
1263        Ok(Self::build(
1264            DTGCredentialType::Delegation,
1265            delegate,
1266            issuer_scope,
1267            valid_from,
1268            Some(valid_until),
1269            CredentialSubject::Delegation(CredentialSubjectDelegation {
1270                id: delegator,
1271                delegation: DelegationGrant {
1272                    scope: None,
1273                    parent: None,
1274                    max_depth: None,
1275                    accepts: Some(crate::digest_multibase_json(grant)?),
1276                },
1277            }),
1278        ))
1279    }
1280
1281    /// Reads the delegate, the grant, and the parent's expiry off a VDC in its wire form.
1282    fn read_delegation_json(
1283        doc: &Value,
1284    ) -> Result<(String, DelegationGrant, Option<DateTime<Utc>>), DTGCredentialError> {
1285        // Before any member is read out: reading clones one, and cloning recurses.
1286        crate::check_json_depth(doc)?;
1287
1288        let object = doc
1289            .as_object()
1290            .ok_or_else(|| DTGCredentialError::MalformedDelegation("not a JSON object".into()))?;
1291
1292        let is_delegation = object
1293            .get("type")
1294            .and_then(Value::as_array)
1295            .is_some_and(|types| {
1296                types
1297                    .iter()
1298                    .filter_map(Value::as_str)
1299                    .any(|t| t == "DelegationCredential")
1300            });
1301        if !is_delegation {
1302            return Err(DTGCredentialError::MalformedDelegation(
1303                "`type` does not include `DelegationCredential`".into(),
1304            ));
1305        }
1306
1307        let subject = object
1308            .get("credentialSubject")
1309            .and_then(Value::as_object)
1310            .ok_or_else(|| {
1311                DTGCredentialError::MalformedDelegation("no `credentialSubject`".into())
1312            })?;
1313
1314        let delegate = subject
1315            .get("id")
1316            .and_then(Value::as_str)
1317            .ok_or_else(|| {
1318                DTGCredentialError::MalformedDelegation("no `credentialSubject.id`".into())
1319            })?
1320            .to_string();
1321
1322        let grant: DelegationGrant = subject
1323            .get("delegation")
1324            .ok_or_else(|| {
1325                DTGCredentialError::MalformedDelegation("no `credentialSubject.delegation`".into())
1326            })
1327            .and_then(|d| {
1328                serde_json::from_value(d.clone()).map_err(|e| {
1329                    DTGCredentialError::MalformedDelegation(format!("malformed `delegation`: {e}"))
1330                })
1331            })?;
1332
1333        let until = object
1334            .get("validUntil")
1335            .or_else(|| object.get("expirationDate"))
1336            .and_then(Value::as_str)
1337            .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
1338            .map(|t| t.with_timezone(&Utc));
1339
1340        Ok((delegate, grant, until))
1341    }
1342
1343    /// Creates a new Verified Persona Credential (VPC)
1344    /// issuer: The DID under which the persona is asserted
1345    /// issuer_scope: The scope declared for `issuer` — ordinarily `directed`, since a
1346    ///               persona exists to be recognized across counterparties the holder chooses
1347    /// subject: The DID of the counterparty as used in the relationship
1348    /// valid_from: The datetime from which this credential is valid
1349    /// valid_until: Optional: The datetime this credential is valid until
1350    pub fn new_vpc(
1351        issuer: String,
1352        issuer_scope: IssuerScope,
1353        subject: String,
1354        valid_from: DateTime<Utc>,
1355        valid_until: Option<DateTime<Utc>>,
1356    ) -> Self {
1357        Self::build(
1358            DTGCredentialType::Persona,
1359            issuer,
1360            issuer_scope,
1361            valid_from,
1362            valid_until,
1363            CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
1364        )
1365    }
1366
1367    /// Sets this credential's own identifier, consuming and returning it so it chains onto
1368    /// any of the `new_*` constructors above.
1369    ///
1370    /// `id` MUST be a single URL per the W3C VC Data Model; `urn:uuid:<uuid>` is the usual
1371    /// choice for a credential with no dereferenceable home. This crate does not validate it.
1372    ///
1373    /// ```
1374    /// # use chrono::Utc;
1375    /// # use dtg_credentials::DTGCredential;
1376    /// let vmc = DTGCredential::new_vmc(
1377    ///     "did:example:community".to_string(),
1378    ///     "did:example:member".to_string(),
1379    ///     Utc::now(),
1380    ///     None,
1381    ///     false,
1382    /// )
1383    /// .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1384    /// assert_eq!(vmc.id(), Some("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"));
1385    /// ```
1386    ///
1387    /// # Set it before signing
1388    ///
1389    /// A Data Integrity proof covers the credential minus its `proof`, so `id` is part of what
1390    /// is signed. Chain this onto the constructor, before [DTGCredential::sign] — adding an id
1391    /// to an already-signed credential leaves a document whose proof no longer verifies.
1392    pub fn with_id(mut self, id: impl Into<String>) -> Self {
1393        self.credential.id = Some(id.into());
1394        self
1395    }
1396
1397    /// Sets this credential's own identifier in place.
1398    ///
1399    /// The non-consuming form of [DTGCredential::with_id]; the same "before signing" caveat
1400    /// applies.
1401    pub fn set_id(&mut self, id: impl Into<String>) {
1402        self.credential.id = Some(id.into());
1403    }
1404
1405    /// Cites a Trust Task document: sets `taskContext` to its `id` and
1406    /// `taskDigestMultibase` to its task digest, together.
1407    ///
1408    /// Use it for any credential whose meaning depends on an exchange — a VSC under a
1409    /// profile requiring `taskContext`, such as a VWC or a statement a `vetting/session`
1410    /// produces. Name the **innermost** exchange that attests
1411    /// what the credential states, by the document that initiated it (Trust Tasks §4.9.1).
1412    /// Setting both halves from one document is the point: the `id` locates the exchange
1413    /// and the digest binds the credential to it, and a pair taken from two places binds
1414    /// nothing. See [crate::task_digest_multibase_json] for how the digest is computed.
1415    ///
1416    /// Replaces a `taskContext` and `taskDigestMultibase` already set.
1417    ///
1418    /// # Set it before signing
1419    ///
1420    /// Both members are covered by the credential's proof, as for [DTGCredential::with_id].
1421    ///
1422    /// # Errors
1423    ///
1424    /// [DTGCredentialError::MalformedTaskDocument] if `document` is not an object with a
1425    /// string `id`; [DTGCredentialError::JsonTooDeep] if it is nested past
1426    /// [crate::MAX_JSON_DEPTH].
1427    pub fn with_task_citation(mut self, document: &Value) -> Result<Self, DTGCredentialError> {
1428        self.set_task_citation(document)?;
1429        Ok(self)
1430    }
1431
1432    /// Cites a Trust Task document in place.
1433    ///
1434    /// The non-consuming form of [DTGCredential::with_task_citation]; the same caveats
1435    /// apply. On error, the credential is left unchanged.
1436    pub fn set_task_citation(&mut self, document: &Value) -> Result<(), DTGCredentialError> {
1437        let id = document
1438            .as_object()
1439            .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("not a JSON object".into()))?
1440            .get("id")
1441            .and_then(Value::as_str)
1442            .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("no string `id`".into()))?
1443            .to_string();
1444        let digest = crate::task_digest_multibase_json(document)?;
1445
1446        self.credential.task_context = Some(id);
1447        self.credential.task_digest_multibase = Some(digest);
1448        Ok(())
1449    }
1450
1451    /// Attaches the status mechanism through which a verifier determines whether this
1452    /// credential has been revoked.
1453    ///
1454    /// The entry is opaque to this library: the mechanism is chosen by the governing VTC
1455    /// or VTN, and nothing here selects one or resolves it. `BitstringStatusListEntry` is
1456    /// the common choice.
1457    ///
1458    /// ```
1459    /// # use chrono::{Duration, Utc};
1460    /// # use dtg_credentials::{DTGCredential, IssuerScope};
1461    /// # use serde_json::json;
1462    /// let vdc = DTGCredential::new_vdc(
1463    ///     "did:example:delegator".to_string(),
1464    ///     IssuerScope::Directed,
1465    ///     "did:example:delegate".to_string(),
1466    ///     Utc::now(),
1467    ///     Utc::now() + Duration::days(90),
1468    ///     vec!["sign:invoices".to_string()],
1469    ///     None,
1470    /// )
1471    /// .unwrap()
1472    /// .with_credential_status(json!({
1473    ///     "id": "https://example.com/status/3#94567",
1474    ///     "type": "BitstringStatusListEntry",
1475    ///     "statusPurpose": "revocation",
1476    ///     "statusListIndex": "94567",
1477    ///     "statusListCredential": "https://example.com/status/3"
1478    /// }));
1479    /// assert!(vdc.credential().credential_status.is_some());
1480    /// ```
1481    ///
1482    /// # When a VDC needs one
1483    ///
1484    /// CONDITIONAL, not required. A verifier MUST be able to establish that an appointment
1485    /// is in force without contacting the delegator, and either of two things satisfies
1486    /// that: a `validUntil` short enough that expiry alone bounds the exposure, or a status
1487    /// entry the verifier can check. A VDC MUST carry one where its validity period exceeds
1488    /// the freshness window the governing VTC or VTN defines for delegations, and MAY omit
1489    /// it otherwise.
1490    ///
1491    /// That window is governance this library does not know, so it cannot decide for a
1492    /// caller which side of the condition a given VDC falls on — hence a setter rather than
1493    /// a constructor parameter. Prefer short validity and re-issuance wherever the
1494    /// delegator is reachable: a status check is a live lookup that reveals the
1495    /// verification event to whoever hosts the status list. A long-lived appointment made
1496    /// in advance of a delegator's unavailability is the case this exists for.
1497    ///
1498    /// # Set it before signing
1499    ///
1500    /// Same caveat as [DTGCredential::with_id] — a Data Integrity proof covers the
1501    /// credential minus its `proof`, so attaching a status entry to an already-signed
1502    /// credential leaves a document whose proof no longer verifies.
1503    ///
1504    /// # This library does not check it
1505    ///
1506    /// Neither [`crate::delegation::verify_chain`] nor [`crate::authority::verify_chain`]
1507    /// resolves a status entry; both verify structure, scope and validity only. Revocation
1508    /// is a live lookup the caller performs.
1509    ///
1510    /// # Security
1511    ///
1512    /// The entry is embedded verbatim and nothing about it is checked when it is attached.
1513    /// It tells a verifier where to look, so a verifier must check the credential's proof
1514    /// before following it, and should apply its own policy to where it leads. Nesting past
1515    /// [crate::MAX_JSON_DEPTH] is refused when the credential is validated, digested or
1516    /// signed, because this setter cannot return an error.
1517    pub fn with_credential_status(mut self, status: Value) -> Self {
1518        self.credential.credential_status = Some(status);
1519        self
1520    }
1521
1522    /// Attaches a revocation status mechanism in place.
1523    ///
1524    /// The non-consuming form of [DTGCredential::with_credential_status]; the same "before
1525    /// signing" caveat, the same CONDITIONAL rule and the same security notes apply.
1526    pub fn set_credential_status(&mut self, status: Value) {
1527        self.credential.credential_status = Some(status);
1528    }
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533    use crate::{
1534        DTGCredential, DTGCredentialError, ENDORSES_V1, IssuerScope, StatementObject, WITNESSED_V1,
1535        WitnessContext,
1536    };
1537    use chrono::{DateTime, Duration, Utc};
1538    use serde_json::json;
1539
1540    fn t0() -> DateTime<Utc> {
1541        DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1542            .unwrap()
1543            .with_timezone(&Utc)
1544    }
1545
1546    fn vmc() -> DTGCredential {
1547        DTGCredential::new_vmc(
1548            "did:example:issuer".to_string(),
1549            "did:example:subject".to_string(),
1550            t0(),
1551            None,
1552            false,
1553        )
1554    }
1555
1556    #[test]
1557    fn test_vmc_serialization() {
1558        let txt = serde_json::to_string_pretty(&vmc()).unwrap();
1559        let sample = r#"{
1560  "@context": [
1561    "https://www.w3.org/ns/credentials/v2",
1562    "https://registry.trustoverip.org/dtg/context/v1"
1563  ],
1564  "type": [
1565    "VerifiableCredential",
1566    "DTGCredential",
1567    "MembershipCredential"
1568  ],
1569  "issuer": "did:example:issuer",
1570  "issuerScope": "public",
1571  "validFrom": "2025-12-11T00:00:00Z",
1572  "credentialSubject": {
1573    "id": "did:example:subject"
1574  }
1575}"#;
1576
1577        assert_eq!(txt, sample);
1578    }
1579
1580    #[test]
1581    fn test_vmc_phc_serialization() {
1582        let vmc = DTGCredential::new_vmc(
1583            "did:example:issuer".to_string(),
1584            "did:example:subject".to_string(),
1585            t0(),
1586            None,
1587            true,
1588        );
1589
1590        let txt = serde_json::to_string_pretty(&vmc).unwrap();
1591        let sample = r#"{
1592  "@context": [
1593    "https://www.w3.org/ns/credentials/v2",
1594    "https://registry.trustoverip.org/dtg/context/v1"
1595  ],
1596  "type": [
1597    "VerifiableCredential",
1598    "DTGCredential",
1599    "MembershipCredential",
1600    "PersonhoodCredential"
1601  ],
1602  "issuer": "did:example:issuer",
1603  "issuerScope": "public",
1604  "validFrom": "2025-12-11T00:00:00Z",
1605  "credentialSubject": {
1606    "id": "did:example:subject"
1607  }
1608}"#;
1609
1610        assert_eq!(txt, sample);
1611    }
1612
1613    /// `id` is OPTIONAL, and a credential that was never given one must keep serializing the
1614    /// shape it always did — no `"id": null`, no empty string.
1615    #[test]
1616    fn test_vmc_without_id_omits_the_property() {
1617        let vmc = vmc();
1618        assert_eq!(vmc.id(), None);
1619        let value: serde_json::Value = serde_json::to_value(&vmc).unwrap();
1620        assert!(
1621            value.get("id").is_none(),
1622            "an unset id must not appear on the wire at all: {value}"
1623        );
1624    }
1625
1626    /// `with_id` puts the identifier at the top level of the credential — a sibling of
1627    /// `issuer`, not something nested under `credentialSubject` (which carries the *subject's*
1628    /// id, a different thing entirely).
1629    #[test]
1630    fn test_vmc_with_id_serialization() {
1631        let vmc = vmc().with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1632
1633        let txt = serde_json::to_string_pretty(&vmc).unwrap();
1634        let sample = r#"{
1635  "@context": [
1636    "https://www.w3.org/ns/credentials/v2",
1637    "https://registry.trustoverip.org/dtg/context/v1"
1638  ],
1639  "type": [
1640    "VerifiableCredential",
1641    "DTGCredential",
1642    "MembershipCredential"
1643  ],
1644  "id": "urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff",
1645  "issuer": "did:example:issuer",
1646  "issuerScope": "public",
1647  "validFrom": "2025-12-11T00:00:00Z",
1648  "credentialSubject": {
1649    "id": "did:example:subject"
1650  }
1651}"#;
1652
1653        assert_eq!(txt, sample);
1654    }
1655
1656    /// The identifier has to survive a round trip. It arrives on the wire and is read back
1657    /// through `TryFrom<DTGCommon>`, which is where `taskContext` was previously being dropped
1658    /// — a field that deserializes into nothing breaks signing and verification silently.
1659    #[test]
1660    fn test_id_round_trips_through_deserialization() {
1661        let vmc = vmc().with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1662
1663        let txt = serde_json::to_string(&vmc).unwrap();
1664        let parsed: DTGCredential = serde_json::from_str(&txt).unwrap();
1665        assert_eq!(
1666            parsed.id(),
1667            Some("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff")
1668        );
1669    }
1670
1671    /// A credential with no `id` still deserializes — the property is OPTIONAL.
1672    #[test]
1673    fn test_missing_id_deserializes_as_none() {
1674        let parsed: DTGCredential = serde_json::from_str(
1675            r#"{
1676              "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
1677              "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1678              "issuer": "did:example:issuer",
1679              "issuerScope": "public",
1680              "validFrom": "2025-12-11T00:00:00Z",
1681              "credentialSubject": { "id": "did:example:subject" }
1682            }"#,
1683        )
1684        .unwrap();
1685        assert_eq!(parsed.id(), None);
1686    }
1687
1688    /// `set_id` is the in-place form of `with_id`; both write the same property.
1689    #[test]
1690    fn test_set_id_matches_with_id() {
1691        let build = || {
1692            DTGCredential::new_vrc(
1693                "did:example:issuer".to_string(),
1694                IssuerScope::Pairwise,
1695                "did:example:subject".to_string(),
1696                t0(),
1697                None,
1698            )
1699        };
1700        let mut in_place = build();
1701        in_place.set_id("urn:uuid:abc");
1702        assert_eq!(
1703            serde_json::to_value(&in_place).unwrap(),
1704            serde_json::to_value(build().with_id("urn:uuid:abc")).unwrap()
1705        );
1706    }
1707
1708    /// VRC, VIC and VPC share one shape and differ in their concrete type and in the scope
1709    /// their issuer declares.
1710    #[test]
1711    fn test_basic_subject_serialization() {
1712        for (vc, type_, scope) in [
1713            (
1714                DTGCredential::new_vrc(
1715                    "did:example:issuer".to_string(),
1716                    IssuerScope::Pairwise,
1717                    "did:example:subject".to_string(),
1718                    t0(),
1719                    None,
1720                ),
1721                "RelationshipCredential",
1722                "pairwise",
1723            ),
1724            (
1725                DTGCredential::new_vic(
1726                    "did:example:issuer".to_string(),
1727                    IssuerScope::Public,
1728                    "did:example:subject".to_string(),
1729                    t0(),
1730                    None,
1731                ),
1732                "InvitationCredential",
1733                "public",
1734            ),
1735            (
1736                DTGCredential::new_vpc(
1737                    "did:example:issuer".to_string(),
1738                    IssuerScope::Directed,
1739                    "did:example:subject".to_string(),
1740                    t0(),
1741                    None,
1742                ),
1743                "PersonaCredential",
1744                "directed",
1745            ),
1746        ] {
1747            let txt = serde_json::to_string_pretty(&vc).unwrap();
1748            let sample = format!(
1749                r#"{{
1750  "@context": [
1751    "https://www.w3.org/ns/credentials/v2",
1752    "https://registry.trustoverip.org/dtg/context/v1"
1753  ],
1754  "type": [
1755    "VerifiableCredential",
1756    "DTGCredential",
1757    "{type_}"
1758  ],
1759  "issuer": "did:example:issuer",
1760  "issuerScope": "{scope}",
1761  "validFrom": "2025-12-11T00:00:00Z",
1762  "credentialSubject": {{
1763    "id": "did:example:subject"
1764  }}
1765}}"#
1766            );
1767            assert_eq!(txt, sample);
1768            let parsed: DTGCredential = serde_json::from_str(&txt).unwrap();
1769            assert_eq!(parsed.issuer_scope(), vc.issuer_scope());
1770        }
1771    }
1772
1773    /// The spec's `dtg:endorses` example, byte for byte in shape.
1774    #[test]
1775    fn test_endorses_vsc_serialization() {
1776        let vec = DTGCredential::new_endorses_vsc(
1777            "did:example:issuer".to_string(),
1778            IssuerScope::Directed,
1779            "did:example:subject".to_string(),
1780            json!({
1781              "type": "SkillEndorsement",
1782              "name": "Software Development",
1783              "competencyLevel": "expert"
1784            }),
1785            t0(),
1786            None,
1787        )
1788        .unwrap();
1789
1790        let txt = serde_json::to_string_pretty(&vec).unwrap();
1791        let sample = r#"{
1792  "@context": [
1793    "https://www.w3.org/ns/credentials/v2",
1794    "https://registry.trustoverip.org/dtg/context/v1"
1795  ],
1796  "type": [
1797    "VerifiableCredential",
1798    "DTGCredential",
1799    "StatementCredential"
1800  ],
1801  "issuer": "did:example:issuer",
1802  "issuerScope": "directed",
1803  "validFrom": "2025-12-11T00:00:00Z",
1804  "credentialSubject": {
1805    "id": "did:example:subject",
1806    "predicate": "https://registry.trustoverip.org/dtg/vsc/endorses/1",
1807    "object": {
1808      "value": {
1809        "competencyLevel": "expert",
1810        "name": "Software Development",
1811        "type": "SkillEndorsement"
1812      }
1813    }
1814  }
1815}"#;
1816
1817        assert_eq!(txt, sample);
1818    }
1819
1820    /// The spec's `dtg:witnessed` example, in shape: the citation at the top level, the
1821    /// digest under `object`, and `witnessContext` beside them in the subject.
1822    #[test]
1823    fn test_witnessed_vsc_serialization() {
1824        let session = json!({
1825            "id": "urn:uuid:2c7f5d19-6e0b-4c3d-8a41-9b2e6f0d4c88",
1826            "type": "https://trusttasks.org/spec/witness/session/0.1",
1827            "threadId": "urn:uuid:2c7f5d19-6e0b-4c3d-8a41-9b2e6f0d4c88",
1828        });
1829        let vrc = serde_json::to_value(DTGCredential::new_vrc(
1830            "did:example:subject".to_string(),
1831            IssuerScope::Pairwise,
1832            "did:example:peer".to_string(),
1833            t0(),
1834            None,
1835        ))
1836        .unwrap();
1837
1838        let vwc = DTGCredential::new_witnessed_vsc(
1839            "did:example:witness".to_string(),
1840            IssuerScope::Public,
1841            &vrc,
1842            &session,
1843            t0(),
1844            None,
1845            Some(WitnessContext {
1846                event: Some("EthDenver 2024".to_string()),
1847                session_id: Some("session-8822-nonce".to_string()),
1848                method: Some("in-person-proximity".to_string()),
1849            }),
1850        )
1851        .unwrap();
1852
1853        let txt = serde_json::to_string_pretty(&vwc).unwrap();
1854        let sample = format!(
1855            r#"{{
1856  "@context": [
1857    "https://www.w3.org/ns/credentials/v2",
1858    "https://registry.trustoverip.org/dtg/context/v1"
1859  ],
1860  "type": [
1861    "VerifiableCredential",
1862    "DTGCredential",
1863    "StatementCredential"
1864  ],
1865  "issuer": "did:example:witness",
1866  "issuerScope": "public",
1867  "validFrom": "2025-12-11T00:00:00Z",
1868  "taskContext": "urn:uuid:2c7f5d19-6e0b-4c3d-8a41-9b2e6f0d4c88",
1869  "taskDigestMultibase": "{}",
1870  "credentialSubject": {{
1871    "id": "did:example:subject",
1872    "predicate": "https://registry.trustoverip.org/dtg/vsc/witnessed/1",
1873    "object": {{
1874      "digestMultibase": "{}"
1875    }},
1876    "witnessContext": {{
1877      "event": "EthDenver 2024",
1878      "sessionId": "session-8822-nonce",
1879      "method": "in-person-proximity"
1880    }}
1881  }}
1882}}"#,
1883            crate::task_digest_multibase_json(&session).unwrap(),
1884            crate::digest_multibase_json(&vrc).unwrap(),
1885        );
1886
1887        assert_eq!(txt, sample);
1888        assert!(vwc.witnesses_issuance_of(&vrc).unwrap());
1889    }
1890
1891    /// A generic statement under a predicate this library has no profile for is built and
1892    /// checked for shape only.
1893    #[test]
1894    fn test_community_predicate_vsc() {
1895        let vsc = DTGCredential::new_vsc(
1896            "did:example:observer".to_string(),
1897            IssuerScope::Directed,
1898            "did:example:subject".to_string(),
1899            "https://vtc.example/vocab#observedDocument",
1900            StatementObject::Value(json!({ "documentType": "passport" })),
1901            t0(),
1902            None,
1903        )
1904        .unwrap();
1905        assert_eq!(
1906            vsc.predicate(),
1907            Some("https://vtc.example/vocab#observedDocument")
1908        );
1909        vsc.validate().unwrap();
1910    }
1911
1912    /// A core profile's `object` kind and minimum scope are refused at construction.
1913    #[test]
1914    fn test_new_vsc_enforces_core_profile_shape() {
1915        let witnessed_with_value = DTGCredential::new_vsc(
1916            "did:example:witness".to_string(),
1917            IssuerScope::Public,
1918            "did:example:subject".to_string(),
1919            WITNESSED_V1,
1920            StatementObject::Value(json!(true)),
1921            t0(),
1922            None,
1923        );
1924        assert!(matches!(
1925            witnessed_with_value,
1926            Err(DTGCredentialError::ProfileViolation(_))
1927        ));
1928
1929        let pairwise_witness = DTGCredential::new_vsc(
1930            "did:example:witness".to_string(),
1931            IssuerScope::Pairwise,
1932            "did:example:subject".to_string(),
1933            WITNESSED_V1,
1934            StatementObject::DigestMultibase(
1935                "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n".into(),
1936            ),
1937            t0(),
1938            None,
1939        );
1940        assert!(matches!(
1941            pairwise_witness,
1942            Err(DTGCredentialError::IssuerScopeTooNarrow {
1943                declared: IssuerScope::Pairwise,
1944                minimum: IssuerScope::Directed,
1945            })
1946        ));
1947
1948        // `endorses/1` sets no minimum.
1949        DTGCredential::new_vsc(
1950            "did:example:peer".to_string(),
1951            IssuerScope::Pairwise,
1952            "did:example:subject".to_string(),
1953            ENDORSES_V1,
1954            StatementObject::Value(json!({})),
1955            t0(),
1956            None,
1957        )
1958        .unwrap();
1959    }
1960
1961    /// A witnessed statement built generically is refused at validation — and so at signing —
1962    /// until the citation the profile requires is attached.
1963    #[test]
1964    fn test_generic_witnessed_vsc_needs_its_citation() {
1965        let vsc = DTGCredential::new_vsc(
1966            "did:example:witness".to_string(),
1967            IssuerScope::Directed,
1968            "did:example:subject".to_string(),
1969            WITNESSED_V1,
1970            StatementObject::DigestMultibase(
1971                "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n".into(),
1972            ),
1973            t0(),
1974            None,
1975        )
1976        .unwrap();
1977        assert!(matches!(
1978            vsc.validate(),
1979            Err(DTGCredentialError::MissingTaskContext)
1980        ));
1981
1982        let cited = vsc
1983            .with_task_citation(&json!({ "id": "urn:uuid:session" }))
1984            .unwrap();
1985        cited.validate().unwrap();
1986    }
1987
1988    /// The community-issued role VAC: issued by the community, `public`, scoped to the
1989    /// community, conferring `role:<name>`.
1990    #[test]
1991    fn test_community_role_vac_serialization() {
1992        let vac = DTGCredential::new_community_role_vac(
1993            "did:example:community".to_string(),
1994            "did:example:member".to_string(),
1995            "vetter",
1996            t0(),
1997            t0() + Duration::days(90),
1998        )
1999        .unwrap();
2000
2001        assert_eq!(
2002            serde_json::to_value(&vac).unwrap(),
2003            json!({
2004                "@context": [
2005                    "https://www.w3.org/ns/credentials/v2",
2006                    "https://registry.trustoverip.org/dtg/context/v1"
2007                ],
2008                "type": ["VerifiableCredential", "DTGCredential", "AuthorityCredential"],
2009                "issuer": "did:example:community",
2010                "issuerScope": "public",
2011                "validFrom": "2025-12-11T00:00:00Z",
2012                "validUntil": "2026-03-11T00:00:00Z",
2013                "credentialSubject": {
2014                    "id": "did:example:member",
2015                    "authority": {
2016                        "scope": "did:example:community",
2017                        "actions": ["role:vetter"]
2018                    }
2019                }
2020            })
2021        );
2022    }
2023}