Skip to main content

trust_tasks_capability_client/
lib.rs

1//! Client-side wire helpers for the **capability** Trust Task families —
2//! `governance/capability/*` (enable / disable / list a community capability)
3//! and `git-trust/*` (grant / revoke commit-signing trust).
4//!
5//! This crate owns the *documents*, not a transport: it builds request
6//! documents, parses inbound envelope replies, classifies them, and (behind
7//! the `signing` feature) attaches a Data-Integrity proof. Each consumer keeps
8//! its own send/receive plumbing but shares this wire layer, so a capability
9//! producer (a community service) and a management UI cannot drift on the
10//! contract.
11//!
12//! ## Layers
13//!
14//! - **Envelope**: capability documents travel as the `trust-tasks-didcomm`
15//!   binding envelope ([`TRUST_TASK_ENVELOPE_TYPE`]); [`parse_envelope_document`]
16//!   turns an inbound body into `(threadId, document)`.
17//! - **Builders**: [`build_document`] plus the family-specific
18//!   [`build_list_document`], [`build_toggle_document`],
19//!   [`build_git_trust_grant`], [`build_git_trust_revoke`].
20//! - **Replies**: [`classify_git_trust_reply`] (for grant/revoke writers) and
21//!   [`parse_capability_reply`] (for governance management UIs).
22//!
23//! ## Retries versus fresh attempts
24//!
25//! This crate is a **producer**, and the producer half of SPEC §7.2 item 11 is
26//! §8.4: *a retry is a bit-for-bit identical resend*. As of `trust-tasks-rs`
27//! 0.12.0 there is a consumer that enforces it — the record is keyed on the
28//! document `id` and compared against the whole document — and the DIDComm and
29//! TSP bindings now keep that record by default. So the two ways of sending a
30//! request again have become genuinely different operations:
31//!
32//! | Intent | What to send | What the consumer does |
33//! |---|---|---|
34//! | The first send may not have arrived | `previous` itself, unchanged | Absorbs it; returns whatever the first execution determined |
35//! | Something about the request changed | [`new_attempt(&previous)`](new_attempt) | Treats it as the new document it is |
36//! | Anything else under a reused `id` | — | Rejects it with `idConflict` |
37//!
38//! "Something about the request changed" is wider than it sounds: a re-stamped
39//! `issuedAt` or a re-signed `proof` over identical content is already a
40//! different document. That is deliberate — §8.4 says a producer that
41//! "retries" by re-signing "has not retried", and the whole point of item 11's
42//! comparison is that an `id` alone cannot tell the retry it must absorb from
43//! the conflict it must reject.
44//!
45//! [`build_document`] and every builder over it mint a fresh `id` per call, so
46//! a caller that rebuilds is already minting a new attempt. [`new_attempt`]
47//! covers the case where the document has already been built (and possibly
48//! signed) and is about to be sent again.
49//!
50//! Signing is deliberately **not** here — it is a thin Data-Integrity call
51//! each consumer makes with its own signer (a service reuses its credential
52//! signer; a client signs with the persona key), so this crate stays free of
53//! any crypto dependency. Sign the built document over its canonical form
54//! (the document minus its `proof` member, `eddsa-jcs-2022`) and set the
55//! `proof` member.
56//!
57//! # Versioning
58//!
59//! This crate exposes `trust-tasks-rs` types in its own public API, so a
60//! breaking change there breaks this crate's callers even when nothing here
61//! changes. `cargo-semver-checks` cannot catch that: it compares each crate's
62//! rustdoc against that crate's own published baseline, and does not track
63//! type identity across dependency versions. The crates that share
64//! `trust-tasks-rs` in their public API are therefore released as one
65//! compatibility unit with a single shared version — see `version_group` in
66//! `release-plz.toml`.
67
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use trust_tasks_rs::TrustTask;
71use uuid::Uuid;
72
73/// The `trust-tasks-didcomm` binding envelope type (what a registry's DIDComm
74/// Trust Task handler listens for).
75pub const TRUST_TASK_ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
76
77/// `governance/capability/*` type URIs.
78pub const CAPABILITY_LIST_TYPE: &str = "https://trusttasks.org/spec/governance/capability/list/0.1";
79pub const CAPABILITY_ENABLE_TYPE: &str =
80    "https://trusttasks.org/spec/governance/capability/enable/0.1";
81pub const CAPABILITY_DISABLE_TYPE: &str =
82    "https://trusttasks.org/spec/governance/capability/disable/0.1";
83
84/// `git-trust/*` type URIs.
85pub const GIT_TRUST_GRANT_TYPE: &str = "https://trusttasks.org/spec/git-trust/grant/0.1";
86pub const GIT_TRUST_REVOKE_TYPE: &str = "https://trusttasks.org/spec/git-trust/revoke/0.1";
87
88/// The extended error code `git-trust/grant` declares for "an active grant
89/// already exists for this subject and resource" (SPEC §8.5; the code is
90/// declared in the registry entry's `errorCodes` front matter).
91///
92/// This is the **control surface** for idempotent success on a grant. SPEC
93/// §8.2 types `message` as non-normative free text "intended for logs and
94/// operator UI"; a client that decides an outcome from it is deciding on a
95/// string the emitting service is free to reword, translate, or drop.
96pub const GIT_TRUST_ALREADY_GRANTED_CODE: &str = "git-trust/grant:already_granted";
97
98/// The extended error code `git-trust/revoke` declares for "no active grant
99/// exists for this subject and resource".
100pub const GIT_TRUST_NOT_GRANTED_CODE: &str = "git-trust/revoke:not_granted";
101
102/// The lowerCamelCase spellings of the two codes above.
103///
104/// The registry entries declare the snake_case forms, which is what a
105/// conforming emitter sends today. SPEC §4.10 rule 4 **SHOULD**s lowerCamelCase
106/// for specification-defined values, so the registry may normalise; accepting
107/// both spellings means that normalisation is not a flag day for this client.
108/// Both are namespaced extended codes either way — neither is free text.
109pub const GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL: &str = "git-trust/grant:alreadyGranted";
110/// See [`GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL`].
111pub const GIT_TRUST_NOT_GRANTED_CODE_CAMEL: &str = "git-trust/revoke:notGranted";
112
113/// Errors from document construction.
114#[derive(Debug, thiserror::Error)]
115pub enum CapabilityClientError {
116    #[error("capability document error: {0}")]
117    Document(String),
118}
119
120// --- builders ----------------------------------------------------------------
121
122/// A fresh document `id`. One per *attempt* — never reused across attempts;
123/// see [`new_attempt`] and the [module docs](self#retries-versus-fresh-attempts).
124fn fresh_id() -> String {
125    format!("urn:uuid:{}", Uuid::new_v4())
126}
127
128/// Build a capability Trust Task addressed `issuer` → `recipient`.
129///
130/// Mints a fresh `id` and stamps `issuedAt` **on every call**, so each built
131/// document is a new attempt in the sense of SPEC §8.4. To re-send one you
132/// have already built, see [`new_attempt`] — and read
133/// [Retries versus fresh attempts](self#retries-versus-fresh-attempts) first,
134/// because the choice between resending the identical document and minting a
135/// new one is now enforced by the consumer.
136pub fn build_document(
137    issuer_did: &str,
138    recipient_did: &str,
139    type_uri: &str,
140    payload: Value,
141) -> TrustTask<Value> {
142    let type_uri = type_uri
143        .parse()
144        .unwrap_or_else(|_| unreachable!("static capability type URIs are valid"));
145    let mut doc = TrustTask::new(fresh_id(), type_uri, payload);
146    doc.issuer = Some(issuer_did.to_string());
147    doc.recipient = Some(recipient_did.to_string());
148    doc.issued_at = Some(chrono::Utc::now());
149    doc
150}
151
152/// A **new attempt** at the request `previous` carried: the same addressing,
153/// type and payload under a *fresh* `id`, a fresh `issuedAt`, and no `proof`.
154///
155/// This is the counterpart of a SPEC §8.4 retry, and the two are not
156/// interchangeable:
157///
158/// * A **retry** is a bit-for-bit identical resend of `previous`. Send
159///   `previous` itself — unchanged, same `id`, same `issuedAt`, same `proof`.
160///   The consumer's §7.2 item 11 record absorbs it and returns whatever the
161///   first execution determined; that absorption is the whole reason retrying
162///   is safe.
163/// * A **new attempt** is a different document. Anything that changes the
164///   bytes makes it one: an edited payload, a re-stamped `issuedAt`, even a
165///   re-signed `proof` over identical content. It **MUST** carry a fresh `id`,
166///   which is what this function is for.
167///
168/// Reusing an `id` with altered content used to pass unnoticed. As of
169/// `trust-tasks-rs` 0.12.0 the consumer keeps a record keyed on the document
170/// `id` and compares the whole document against it, so that combination is
171/// rejected with `idConflict` — and, as the DIDComm and TSP bindings now
172/// default that record on, it will be rejected by every consumer this client
173/// talks to.
174///
175/// `proof` is cleared because it committed to the previous `id` and
176/// `issuedAt`; carrying it over would ship a signature over a document that no
177/// longer exists. Sign the returned document before sending it.
178///
179/// **Hold the new correlation thread.** Where `previous` opened its own
180/// exchange (no `threadId`), SPEC §4.9's fallback names that exchange by the
181/// document `id` — so a new attempt opens a *new* exchange, and the value to
182/// wait on is [`correlation_thread`] of the returned document, not of
183/// `previous`. Where `previous` carried an explicit `threadId` it is preserved
184/// and the attempt stays in the same exchange.
185#[must_use]
186pub fn new_attempt(previous: &TrustTask<Value>) -> TrustTask<Value> {
187    let mut next = previous.clone();
188    next.id = fresh_id();
189    next.issued_at = Some(chrono::Utc::now());
190    next.proof = None;
191    next
192}
193
194/// Build a `governance/capability/list` request (status `all`).
195pub fn build_list_document(issuer_did: &str, vtc_did: &str) -> TrustTask<Value> {
196    build_document(
197        issuer_did,
198        vtc_did,
199        CAPABILITY_LIST_TYPE,
200        serde_json::json!({ "status": "all" }),
201    )
202}
203
204/// Build a `governance/capability/enable` or `/disable` request. On enable,
205/// `config.authority` defaults to the community's own DID — the community is
206/// the authority its capability records are issued under.
207pub fn build_toggle_document(
208    issuer_did: &str,
209    vtc_did: &str,
210    slug: &str,
211    version: &str,
212    enable: bool,
213) -> TrustTask<Value> {
214    if enable {
215        build_document(
216            issuer_did,
217            vtc_did,
218            CAPABILITY_ENABLE_TYPE,
219            serde_json::json!({
220                "capability": slug,
221                "version": version,
222                "config": { "authority": vtc_did },
223            }),
224        )
225    } else {
226        build_document(
227            issuer_did,
228            vtc_did,
229            CAPABILITY_DISABLE_TYPE,
230            serde_json::json!({ "capability": slug }),
231        )
232    }
233}
234
235/// Build a `git-trust/grant`: grant `subject` commit-signing trust for
236/// `resource` (an org or `org/repo` slug).
237pub fn build_git_trust_grant(
238    authority_did: &str,
239    registry_did: &str,
240    subject_did: &str,
241    resource: &str,
242) -> TrustTask<Value> {
243    build_document(
244        authority_did,
245        registry_did,
246        GIT_TRUST_GRANT_TYPE,
247        serde_json::json!({ "subject": subject_did, "resource": resource }),
248    )
249}
250
251/// Build a `git-trust/revoke`.
252pub fn build_git_trust_revoke(
253    authority_did: &str,
254    registry_did: &str,
255    subject_did: &str,
256    resource: &str,
257    reason: Option<&str>,
258) -> TrustTask<Value> {
259    let mut payload = serde_json::json!({ "subject": subject_did, "resource": resource });
260    if let Some(reason) = reason {
261        payload["reason"] = serde_json::json!(reason);
262    }
263    build_document(authority_did, registry_did, GIT_TRUST_REVOKE_TYPE, payload)
264}
265
266// --- envelope parsing --------------------------------------------------------
267
268/// Parse a DIDComm envelope body into `(threadId, document)`. `None` when the
269/// body is not a threaded Trust Task document.
270///
271/// The returned `threadId` is a **dispatch key, not a check**: it tells a
272/// caller holding a map of outstanding requests which one this document
273/// belongs to. It does not establish that the document is a reply to anything
274/// the caller sent. Correlate before acting — either by finding the thread in
275/// your own outstanding map, or with [`parse_envelope_document_for`].
276pub fn parse_envelope_document(body: &Value) -> Option<(String, TrustTask<Value>)> {
277    let doc: TrustTask<Value> = serde_json::from_value(body.clone()).ok()?;
278    let thid = doc.thread_id.clone()?;
279    Some((thid, doc))
280}
281
282/// Parse a DIDComm envelope body into the document it carries, **only** if
283/// that document is threaded to `expected_thread_id`.
284///
285/// `None` covers both "not a Trust Task document" and "a Trust Task document
286/// belonging to some other exchange"; in either case it is not an answer to
287/// the request you are waiting on, and the correct action is to keep waiting.
288pub fn parse_envelope_document_for(
289    body: &Value,
290    expected_thread_id: &str,
291) -> Option<TrustTask<Value>> {
292    let (_, doc) = parse_envelope_document(body)?;
293    replies_to(&doc, expected_thread_id).then_some(doc)
294}
295
296/// The thread an exchange started by `doc` is correlated by: its own
297/// `threadId`, or its `id` where it opens the exchange (SPEC §4.9's fallback,
298/// which is the value `respond_with` and `reject_with` will thread the reply
299/// to).
300///
301/// Hold this from the moment you send a request; it is what every
302/// reply-classifying function here wants as `expected_thread_id`.
303pub fn correlation_thread<P>(doc: &TrustTask<P>) -> &str {
304    doc.thread_id.as_deref().unwrap_or(&doc.id)
305}
306
307/// Whether `reply` is threaded to `expected_thread_id` — SPEC §4.9
308/// correlation, and the precondition for acting on any reply.
309///
310/// A reply with no `threadId` at all matches nothing: §8.1 requires an error
311/// response to carry one, and a `#response` gets one from `respond_with`, so
312/// its absence means the document is not correlated to any exchange.
313pub fn replies_to<P>(reply: &TrustTask<P>, expected_thread_id: &str) -> bool {
314    reply.thread_id.as_deref() == Some(expected_thread_id)
315}
316
317// --- git-trust write replies (grant/revoke producers) ------------------------
318
319/// The classification of a `git-trust` write reply.
320///
321/// `IdempotentSuccess` is load-bearing for redelivery-safe writers: an
322/// `already_granted` / `not_granted` rejection means the desired end state
323/// already holds, so the write is done, not failed.
324#[derive(Debug, Clone, PartialEq)]
325pub enum WriteOutcome {
326    /// The `#response` document acknowledged the write.
327    Success,
328    /// Rejected because the end state already holds.
329    IdempotentSuccess,
330    /// Any other rejection: the machine-readable code and human detail.
331    Rejected {
332        code: String,
333        message: Option<String>,
334    },
335}
336
337/// How much a caller is willing to infer from a non-conforming peer.
338///
339/// The default infers nothing: an outcome is decided from the error `code`
340/// alone.
341#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
342pub struct ReplyPolicy {
343    /// **DEPRECATED — opt-in compatibility only, removed in the next MAJOR.**
344    ///
345    /// Also treat a `taskFailed` whose free-text `message` contains
346    /// `already_granted:` or `not_granted:` as [`WriteOutcome::IdempotentSuccess`].
347    ///
348    /// This is how the classification worked before 0.10.0, and it was wrong:
349    /// SPEC §8.2 defines `message` as non-normative free text "intended for
350    /// logs and operator UI". Deciding an outcome from it means the client's
351    /// behaviour hinges on wording the emitting service may reword, translate
352    /// or drop at any time — and, in the other direction, that a *genuine*
353    /// `taskFailed` whose operator message happens to quote the phrase is
354    /// silently reported to the caller as success.
355    ///
356    /// Enable this only while a specific peer still emits the free-text form,
357    /// and only after confirming there is no code-bearing alternative. The
358    /// correct fix is on the emitting side: send
359    /// [`GIT_TRUST_ALREADY_GRANTED_CODE`] / [`GIT_TRUST_NOT_GRANTED_CODE`],
360    /// which SPEC §8.5 provides for exactly this and the registry entries
361    /// already declare.
362    pub accept_legacy_free_text_idempotence: bool,
363}
364
365impl ReplyPolicy {
366    /// The strict policy: the error `code` decides, free text decides nothing.
367    pub fn strict() -> Self {
368        Self::default()
369    }
370
371    /// DEPRECATED: additionally accept the pre-0.10 free-text form. See
372    /// [`Self::accept_legacy_free_text_idempotence`].
373    pub fn with_legacy_free_text() -> Self {
374        Self {
375            accept_legacy_free_text_idempotence: true,
376        }
377    }
378}
379
380/// Classify the reply to a `git-trust/grant` or `git-trust/revoke` write.
381///
382/// `expected_thread_id` is the thread of the request this is supposed to be
383/// answering — [`correlation_thread`] of the document you sent. A reply
384/// threaded to anything else is not an answer to it, and yields `None` rather
385/// than an outcome: acting on an uncorrelated reply means letting whichever
386/// document arrives next decide the fate of a write it has nothing to do with.
387///
388/// `None` therefore means "not an answer to this request" — either a document
389/// of another family, or a reply belonging to another exchange. Use
390/// [`replies_to`] if you need to tell those apart.
391///
392/// Idempotent success is keyed on the **extended error code** of SPEC §8.5
393/// ([`GIT_TRUST_ALREADY_GRANTED_CODE`], [`GIT_TRUST_NOT_GRANTED_CODE`]), never
394/// on the free-text `message`. See
395/// [`classify_git_trust_reply_with_policy`] for the deprecated compatibility
396/// path.
397pub fn classify_git_trust_reply(
398    doc: &TrustTask<Value>,
399    expected_thread_id: &str,
400) -> Option<WriteOutcome> {
401    classify_git_trust_reply_with_policy(doc, expected_thread_id, ReplyPolicy::strict())
402}
403
404/// [`classify_git_trust_reply`] with an explicit [`ReplyPolicy`].
405///
406/// Pass [`ReplyPolicy::strict`] unless you are talking to a peer that predates
407/// the extended error codes; see
408/// [`ReplyPolicy::accept_legacy_free_text_idempotence`].
409pub fn classify_git_trust_reply_with_policy(
410    doc: &TrustTask<Value>,
411    expected_thread_id: &str,
412    policy: ReplyPolicy,
413) -> Option<WriteOutcome> {
414    // SPEC §4.9: correlation comes first. Nothing below is safe to act on for
415    // a document that is not answering this request.
416    if !replies_to(doc, expected_thread_id) {
417        return None;
418    }
419
420    let slug = doc.type_uri.slug();
421    if slug == "trust-task-error" {
422        let (code, message) = error_code_and_message(doc);
423        if is_idempotent_code(&code) {
424            return Some(WriteOutcome::IdempotentSuccess);
425        }
426        // DEPRECATED: pre-0.10 peers signalled idempotence in the free-text
427        // `message` under a bare `taskFailed`. SPEC §8.2 makes `message`
428        // non-normative, so this is a string match on a field nobody promised
429        // to keep stable — opt-in, and gone in the next MAJOR. Remove this
430        // block, `ReplyPolicy`, and the `*_with_policy` entry point together.
431        if policy.accept_legacy_free_text_idempotence && code == "taskFailed" {
432            let reason = message.as_deref().unwrap_or("");
433            if reason.contains("already_granted:") || reason.contains("not_granted:") {
434                return Some(WriteOutcome::IdempotentSuccess);
435            }
436        }
437        return Some(WriteOutcome::Rejected { code, message });
438    }
439    if doc.type_uri.is_response() && matches!(slug, "git-trust/grant" | "git-trust/revoke") {
440        return Some(WriteOutcome::Success);
441    }
442    None
443}
444
445/// Whether `code` is one of the extended codes that mean "the end state you
446/// asked for already holds" (SPEC §8.5).
447fn is_idempotent_code(code: &str) -> bool {
448    matches!(
449        code,
450        GIT_TRUST_ALREADY_GRANTED_CODE
451            | GIT_TRUST_NOT_GRANTED_CODE
452            | GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL
453            | GIT_TRUST_NOT_GRANTED_CODE_CAMEL
454    )
455}
456
457// --- governance/capability replies (management UIs) --------------------------
458
459/// One capability entry as rendered by a management UI.
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461pub struct CapabilitySummary {
462    pub slug: String,
463    pub title: Option<String>,
464    pub version: String,
465    pub enabled: bool,
466    pub enabled_at: Option<String>,
467    pub delegate: Option<String>,
468    /// The full manifest, for a detail view.
469    pub manifest: Value,
470}
471
472/// The classification of a `governance/capability/*` reply.
473#[derive(Debug, Clone, PartialEq)]
474pub enum CapabilityReply {
475    /// A `list` response: the community's capabilities.
476    Listing(Vec<CapabilitySummary>),
477    /// An `enable`/`disable` acknowledgement.
478    Toggled { capability: String, enabled: bool },
479    /// A `trust-task-error` document.
480    Rejected {
481        code: String,
482        message: Option<String>,
483    },
484}
485
486/// Parse an inbound envelope body directly into a reply to the request
487/// threaded `expected_thread_id` — the entry point for a UI's inbound
488/// dispatch, which holds only a `Value`.
489///
490/// `None` when the body is not a `governance/capability/*` reply, or is one
491/// belonging to a different exchange. As on the write side, a reply the caller
492/// did not ask for must not be allowed to resolve a request the caller did.
493pub fn parse_envelope_reply(body: &Value, expected_thread_id: &str) -> Option<CapabilityReply> {
494    let doc = parse_envelope_document_for(body, expected_thread_id)?;
495    parse_capability_reply(&doc, expected_thread_id)
496}
497
498/// Classify a `governance/capability/*` reply document. `None` when it is not
499/// part of this family, or is not threaded to `expected_thread_id`.
500pub fn parse_capability_reply(
501    doc: &TrustTask<Value>,
502    expected_thread_id: &str,
503) -> Option<CapabilityReply> {
504    // SPEC §4.9 correlation, as on the write side: a listing or a toggle
505    // acknowledgement from another exchange answers nothing here.
506    if !replies_to(doc, expected_thread_id) {
507        return None;
508    }
509    let slug = doc.type_uri.slug();
510    if slug == "trust-task-error" {
511        let (code, message) = error_code_and_message(doc);
512        return Some(CapabilityReply::Rejected { code, message });
513    }
514    if !doc.type_uri.is_response() {
515        return None;
516    }
517    match slug {
518        "governance/capability/list" => {
519            let entries = doc
520                .payload
521                .get("capabilities")
522                .and_then(Value::as_array)
523                .map(|entries| entries.iter().filter_map(summary_of).collect())
524                .unwrap_or_default();
525            Some(CapabilityReply::Listing(entries))
526        }
527        "governance/capability/enable" | "governance/capability/disable" => {
528            Some(CapabilityReply::Toggled {
529                capability: doc
530                    .payload
531                    .get("capability")
532                    .and_then(Value::as_str)
533                    .unwrap_or_default()
534                    .to_string(),
535                enabled: doc
536                    .payload
537                    .get("enabled")
538                    .and_then(Value::as_bool)
539                    .unwrap_or(false),
540            })
541        }
542        _ => None,
543    }
544}
545
546fn error_code_and_message(doc: &TrustTask<Value>) -> (String, Option<String>) {
547    let code = doc
548        .payload
549        .get("code")
550        .and_then(Value::as_str)
551        .unwrap_or("unknown")
552        .to_string();
553    let message = doc
554        .payload
555        .get("message")
556        .and_then(Value::as_str)
557        .map(str::to_string);
558    (code, message)
559}
560
561fn summary_of(entry: &Value) -> Option<CapabilitySummary> {
562    let manifest = entry.get("manifest")?.clone();
563    Some(CapabilitySummary {
564        slug: manifest.get("capability")?.as_str()?.to_string(),
565        title: manifest
566            .get("title")
567            .and_then(Value::as_str)
568            .map(str::to_string),
569        version: manifest
570            .get("version")
571            .and_then(Value::as_str)
572            .unwrap_or("?")
573            .to_string(),
574        enabled: entry
575            .get("enabled")
576            .and_then(Value::as_bool)
577            .unwrap_or(false),
578        enabled_at: entry
579            .get("enabledAt")
580            .and_then(Value::as_str)
581            .map(str::to_string),
582        delegate: entry
583            .get("delegate")
584            .and_then(Value::as_str)
585            .map(str::to_string),
586        manifest,
587    })
588}
589
590#[cfg(test)]
591mod tests {
592    #![allow(clippy::unwrap_used, clippy::expect_used)]
593
594    use super::*;
595    use trust_tasks_rs::RejectReason;
596
597    #[test]
598    fn builders_are_addressed_and_typed() {
599        let list = build_list_document("did:example:me", "did:example:vtc");
600        assert_eq!(list.type_uri.slug(), "governance/capability/list");
601        assert_eq!(list.issuer.as_deref(), Some("did:example:me"));
602        assert_eq!(list.payload["status"], "all");
603
604        let enable = build_toggle_document(
605            "did:example:me",
606            "did:example:vtc",
607            "git-trust",
608            "0.1",
609            true,
610        );
611        assert_eq!(enable.payload["config"]["authority"], "did:example:vtc");
612        let disable = build_toggle_document(
613            "did:example:me",
614            "did:example:vtc",
615            "git-trust",
616            "0.1",
617            false,
618        );
619        assert_eq!(disable.type_uri.slug(), "governance/capability/disable");
620
621        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
622        assert_eq!(grant.type_uri.slug(), "git-trust/grant");
623        assert_eq!(grant.payload["subject"], "did:s");
624        let revoke = build_git_trust_revoke("did:a", "did:r", "did:s", "openvtc", Some("ended"));
625        assert_eq!(revoke.payload["reason"], "ended");
626    }
627
628    fn reserialize(doc: &trust_tasks_rs::ErrorResponse) -> TrustTask<Value> {
629        serde_json::from_value(serde_json::to_value(doc).unwrap()).unwrap()
630    }
631
632    /// An error response carrying an arbitrary `code`, built the way a
633    /// conforming emitter would (SPEC §8.2), so the tests below exercise the
634    /// wire form rather than a Rust enum.
635    fn error_reply(
636        request: &TrustTask<Value>,
637        code: &str,
638        message: Option<&str>,
639    ) -> TrustTask<Value> {
640        let mut payload = serde_json::json!({ "code": code, "retryable": false });
641        if let Some(message) = message {
642            payload["message"] = serde_json::json!(message);
643        }
644        let mut doc = TrustTask::new(
645            "urn:uuid:err".to_string(),
646            "https://trusttasks.org/spec/trust-task-error/0.5"
647                .parse()
648                .unwrap(),
649            payload,
650        );
651        doc.thread_id = Some(correlation_thread(request).to_string());
652        doc
653    }
654
655    #[test]
656    fn git_trust_reply_classification() {
657        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
658        let thread = correlation_thread(&grant).to_string();
659
660        let ok = grant.respond_with(
661            "urn:uuid:r".to_string(),
662            serde_json::json!({ "granted": true }),
663        );
664        assert_eq!(
665            classify_git_trust_reply(&ok, &thread),
666            Some(WriteOutcome::Success)
667        );
668
669        let denied = reserialize(&grant.reject_with(
670            "urn:uuid:e2".to_string(),
671            RejectReason::PermissionDenied {
672                reason: "no".to_string(),
673            },
674        ));
675        assert!(matches!(
676            classify_git_trust_reply(&denied, &thread),
677            Some(WriteOutcome::Rejected { .. })
678        ));
679    }
680
681    /// SPEC §8.2 defines `message` as non-normative free text "intended for
682    /// logs and operator UI". Before 0.10.0 this client decided idempotent
683    /// success from a substring of it, which meant (a) the client's behaviour
684    /// was pinned to wording no emitter promised to keep, and (b) a genuine
685    /// `taskFailed` whose operator message merely *quoted* the phrase was
686    /// reported to the caller as success — a failed write recorded as done.
687    ///
688    /// The control surface is the namespaced extended code of §8.5, which the
689    /// registry entries for `git-trust/grant` and `git-trust/revoke` already
690    /// declare.
691    #[test]
692    fn idempotent_success_is_keyed_on_the_extended_code_not_the_message() {
693        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
694        let thread = correlation_thread(&grant).to_string();
695
696        // The declared code decides — with no `message` at all.
697        let by_code = error_reply(&grant, GIT_TRUST_ALREADY_GRANTED_CODE, None);
698        assert_eq!(
699            classify_git_trust_reply(&by_code, &thread),
700            Some(WriteOutcome::IdempotentSuccess)
701        );
702        let revoke = build_git_trust_revoke("did:a", "did:r", "did:s", "org", None);
703        let revoke_thread = correlation_thread(&revoke).to_string();
704        assert_eq!(
705            classify_git_trust_reply(
706                &error_reply(&revoke, GIT_TRUST_NOT_GRANTED_CODE, None),
707                &revoke_thread
708            ),
709            Some(WriteOutcome::IdempotentSuccess)
710        );
711        // §4.10's lowerCamelCase spelling, should the registry normalise.
712        assert_eq!(
713            classify_git_trust_reply(
714                &error_reply(&grant, GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL, None),
715                &thread
716            ),
717            Some(WriteOutcome::IdempotentSuccess)
718        );
719
720        // The free text does NOT decide. This is the regression: a real
721        // failure whose operator message names the condition is a failure.
722        let free_text = error_reply(
723            &grant,
724            "taskFailed",
725            Some("registry write aborted; not already_granted: the tuple was never written"),
726        );
727        assert_eq!(
728            classify_git_trust_reply(&free_text, &thread),
729            Some(WriteOutcome::Rejected {
730                code: "taskFailed".to_string(),
731                message: Some(
732                    "registry write aborted; not already_granted: the tuple was never written"
733                        .to_string()
734                ),
735            }),
736            "a taskFailed whose free text quotes the phrase is still a failure"
737        );
738
739        // The deprecated compatibility path is opt-in, and only reachable by
740        // asking for it by name.
741        assert_eq!(
742            classify_git_trust_reply_with_policy(
743                &free_text,
744                &thread,
745                ReplyPolicy::with_legacy_free_text()
746            ),
747            Some(WriteOutcome::IdempotentSuccess)
748        );
749        assert_eq!(
750            classify_git_trust_reply_with_policy(&free_text, &thread, ReplyPolicy::strict()),
751            classify_git_trust_reply(&free_text, &thread),
752            "strict is the default"
753        );
754        assert!(!ReplyPolicy::default().accept_legacy_free_text_idempotence);
755    }
756
757    /// A reply must be matched to the request before it is acted on (SPEC
758    /// §4.9). Before 0.10.0 the thread was extracted and discarded, so a reply
759    /// belonging to a different exchange — including an attacker-chosen one on
760    /// a shared inbound path — could resolve a write the caller was still
761    /// waiting on.
762    #[test]
763    fn a_reply_on_another_thread_resolves_nothing() {
764        let mine = build_git_trust_grant("did:a", "did:r", "did:s", "org");
765        let theirs = build_git_trust_grant("did:a", "did:r", "did:other", "other-org");
766        let my_thread = correlation_thread(&mine).to_string();
767        assert_ne!(my_thread, correlation_thread(&theirs));
768
769        // A perfectly valid success for somebody else's grant.
770        let their_ok = theirs.respond_with(
771            "urn:uuid:r".to_string(),
772            serde_json::json!({ "granted": true }),
773        );
774        assert_eq!(
775            classify_git_trust_reply(&their_ok, &my_thread),
776            None,
777            "a reply to another exchange must not resolve this request"
778        );
779        // And the same document does resolve the request it actually answers.
780        assert_eq!(
781            classify_git_trust_reply(&their_ok, correlation_thread(&theirs)),
782            Some(WriteOutcome::Success)
783        );
784
785        // Same for the idempotent-success shortcut, which is the one an
786        // attacker would want to forge: it must not be reachable off-thread.
787        let their_already = error_reply(&theirs, GIT_TRUST_ALREADY_GRANTED_CODE, None);
788        assert_eq!(classify_git_trust_reply(&their_already, &my_thread), None);
789
790        // A reply carrying no thread at all correlates to nothing.
791        let mut unthreaded = their_ok.clone();
792        unthreaded.thread_id = None;
793        assert!(!replies_to(&unthreaded, &my_thread));
794        assert_eq!(classify_git_trust_reply(&unthreaded, &my_thread), None);
795
796        // The governance family enforces the same rule.
797        let list = build_list_document("did:me", "did:vtc");
798        let other_list = build_list_document("did:me", "did:vtc");
799        let other_reply = other_list.respond_with(
800            "urn:uuid:r".to_string(),
801            serde_json::json!({ "capabilities": [] }),
802        );
803        assert_eq!(
804            parse_capability_reply(&other_reply, correlation_thread(&list)),
805            None
806        );
807        assert_eq!(
808            parse_envelope_reply(
809                &serde_json::to_value(&other_reply).unwrap(),
810                correlation_thread(&list)
811            ),
812            None
813        );
814    }
815
816    #[test]
817    fn governance_reply_classification() {
818        let list = build_list_document("did:me", "did:vtc");
819        let list_thread = correlation_thread(&list).to_string();
820        let reply = list.respond_with(
821            "urn:uuid:r".to_string(),
822            serde_json::json!({ "capabilities": [{
823                "manifest": { "capability": "git-trust", "version": "0.1", "title": "Git Commit Trust" },
824                "enabled": true, "enabledAt": "2026-07-18T00:00:00Z"
825            }]}),
826        );
827        let Some(CapabilityReply::Listing(items)) = parse_capability_reply(&reply, &list_thread)
828        else {
829            panic!("expected listing");
830        };
831        assert_eq!(items.len(), 1);
832        assert_eq!(items[0].slug, "git-trust");
833        assert!(items[0].enabled);
834
835        let toggle = build_toggle_document("did:me", "did:vtc", "git-trust", "0.1", true);
836        let toggle_thread = correlation_thread(&toggle).to_string();
837        let ack = toggle.respond_with(
838            "urn:uuid:t".to_string(),
839            serde_json::json!({ "capability": "git-trust", "enabled": true }),
840        );
841        assert_eq!(
842            parse_capability_reply(&ack, &toggle_thread),
843            Some(CapabilityReply::Toggled {
844                capability: "git-trust".to_string(),
845                enabled: true
846            })
847        );
848        // And through the envelope entry point a UI actually uses.
849        assert_eq!(
850            parse_envelope_reply(&serde_json::to_value(&ack).unwrap(), &toggle_thread),
851            Some(CapabilityReply::Toggled {
852                capability: "git-trust".to_string(),
853                enabled: true
854            })
855        );
856    }
857
858    #[test]
859    fn envelope_parse_requires_thread_id() {
860        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
861        let reply = grant.respond_with("urn:uuid:r".to_string(), serde_json::json!({}));
862        let body = serde_json::to_value(&reply).unwrap();
863        let (thid, _) = parse_envelope_document(&body).unwrap();
864        assert_eq!(thid, grant.id);
865        assert!(parse_envelope_document(&serde_json::to_value(&grant).unwrap()).is_none());
866
867        // The correlating form takes the same body only for the right thread.
868        assert!(parse_envelope_document_for(&body, &grant.id).is_some());
869        assert!(parse_envelope_document_for(&body, "urn:uuid:someone-else").is_none());
870    }
871
872    // --- SPEC §8.4: retries versus fresh attempts ---------------------------
873
874    /// Every builder mints its own `id`, so rebuilding a request is already a
875    /// new attempt rather than a reuse.
876    #[test]
877    fn builders_mint_a_fresh_id_per_attempt() {
878        let first = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
879        let second = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
880        assert_ne!(first.id, second.id);
881        assert!(first.id.starts_with("urn:uuid:"));
882    }
883
884    /// A new attempt is a *different document*: fresh `id`, fresh `issuedAt`,
885    /// and no carried-over `proof` — the old one committed to the old `id`.
886    #[test]
887    fn a_new_attempt_mints_a_fresh_id_and_drops_the_stale_proof() {
888        let mut first = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
889        first.proof = Some(trust_tasks_rs::Proof {
890            proof_type: "DataIntegrityProof".into(),
891            cryptosuite: "eddsa-jcs-2022".into(),
892            created: chrono::Utc::now(),
893            proof_purpose: "assertionMethod".into(),
894            verification_method: "did:a#key-1".into(),
895            proof_value: "zStale".into(),
896            extra: Default::default(),
897        });
898
899        let next = new_attempt(&first);
900        assert_ne!(next.id, first.id, "a new attempt MUST NOT reuse the `id`");
901        assert!(next.proof.is_none(), "the old proof signed the old `id`");
902        assert_eq!(next.payload, first.payload);
903        assert_eq!(next.issuer, first.issuer);
904        assert_eq!(next.recipient, first.recipient);
905        assert_eq!(next.type_uri.to_string(), first.type_uri.to_string());
906    }
907
908    /// SPEC §4.9's fallback: a request that opens its own exchange is named by
909    /// its `id`, so a new attempt opens a new exchange and the caller must
910    /// hold the *new* correlation thread. An explicit `threadId` is preserved.
911    #[test]
912    fn a_new_attempt_re_threads_only_where_the_id_was_the_thread() {
913        let opening = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
914        let next = new_attempt(&opening);
915        assert_eq!(correlation_thread(&next), next.id);
916        assert_ne!(correlation_thread(&next), correlation_thread(&opening));
917
918        let mut in_exchange = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
919        in_exchange.thread_id = Some("exchange-0001".into());
920        let next = new_attempt(&in_exchange);
921        assert_eq!(correlation_thread(&next), "exchange-0001");
922    }
923
924    /// The end-to-end producer property, checked against the consumer rule
925    /// that now enforces it (`trust-tasks-rs` 0.12's §7.2 item 11 record).
926    ///
927    /// * resending the identical document is absorbed — that is the §8.4 retry;
928    /// * altering it while reusing the `id` is `Conflict` → `idConflict`;
929    /// * `new_attempt` is the way through, because its `id` is fresh.
930    #[tokio::test]
931    async fn a_reused_id_with_altered_content_conflicts_and_new_attempt_does_not() {
932        use trust_tasks_rs::{document_digest, InMemoryReplayGuard, ReplayGuard, ReplayVerdict};
933
934        let guard = InMemoryReplayGuard::new(16);
935        let now = chrono::Utc::now();
936        let retain = Some(now + chrono::TimeDelta::minutes(5));
937
938        let sent = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
939        let digest = document_digest(&sent).unwrap();
940        assert_eq!(
941            guard.claim(&sent.id, &digest, retain, now).await.unwrap(),
942            ReplayVerdict::Fresh
943        );
944
945        // §8.4 retry: the identical document, resent. Absorbed.
946        let retried = sent.clone();
947        let retried_digest = document_digest(&retried).unwrap();
948        assert_eq!(retried_digest, digest, "a retry is bit-for-bit identical");
949        assert!(matches!(
950            guard
951                .claim(&retried.id, &retried_digest, retain, now)
952                .await
953                .unwrap(),
954            ReplayVerdict::Duplicate { .. }
955        ));
956
957        // The mistake this release closes: edit the payload, keep the `id`.
958        let mut altered = sent.clone();
959        altered.payload["resource"] = serde_json::json!("some-other-org");
960        let altered_digest = document_digest(&altered).unwrap();
961        assert_eq!(
962            guard
963                .claim(&altered.id, &altered_digest, retain, now)
964                .await
965                .unwrap(),
966            ReplayVerdict::Conflict,
967            "a reused `id` with altered content is `idConflict`, not a retry"
968        );
969
970        // `new_attempt` is how a producer sends a changed request instead.
971        let attempt = new_attempt(&altered);
972        let attempt_digest = document_digest(&attempt).unwrap();
973        assert_eq!(
974            guard
975                .claim(&attempt.id, &attempt_digest, retain, now)
976                .await
977                .unwrap(),
978            ReplayVerdict::Fresh
979        );
980    }
981}