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}