use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_rs::TrustTask;
use uuid::Uuid;
pub const TRUST_TASK_ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
pub const CAPABILITY_LIST_TYPE: &str = "https://trusttasks.org/spec/governance/capability/list/0.1";
pub const CAPABILITY_ENABLE_TYPE: &str =
"https://trusttasks.org/spec/governance/capability/enable/0.1";
pub const CAPABILITY_DISABLE_TYPE: &str =
"https://trusttasks.org/spec/governance/capability/disable/0.1";
pub const GIT_TRUST_GRANT_TYPE: &str = "https://trusttasks.org/spec/git-trust/grant/0.1";
pub const GIT_TRUST_REVOKE_TYPE: &str = "https://trusttasks.org/spec/git-trust/revoke/0.1";
pub const GIT_TRUST_ALREADY_GRANTED_CODE: &str = "git-trust/grant:already_granted";
pub const GIT_TRUST_NOT_GRANTED_CODE: &str = "git-trust/revoke:not_granted";
pub const GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL: &str = "git-trust/grant:alreadyGranted";
pub const GIT_TRUST_NOT_GRANTED_CODE_CAMEL: &str = "git-trust/revoke:notGranted";
#[derive(Debug, thiserror::Error)]
pub enum CapabilityClientError {
#[error("capability document error: {0}")]
Document(String),
}
fn fresh_id() -> String {
format!("urn:uuid:{}", Uuid::new_v4())
}
pub fn build_document(
issuer_did: &str,
recipient_did: &str,
type_uri: &str,
payload: Value,
) -> TrustTask<Value> {
let type_uri = type_uri
.parse()
.unwrap_or_else(|_| unreachable!("static capability type URIs are valid"));
let mut doc = TrustTask::new(fresh_id(), type_uri, payload);
doc.issuer = Some(issuer_did.to_string());
doc.recipient = Some(recipient_did.to_string());
doc.issued_at = Some(chrono::Utc::now());
doc
}
#[must_use]
pub fn new_attempt(previous: &TrustTask<Value>) -> TrustTask<Value> {
let mut next = previous.clone();
next.id = fresh_id();
next.issued_at = Some(chrono::Utc::now());
next.proof = None;
next
}
pub fn build_list_document(issuer_did: &str, vtc_did: &str) -> TrustTask<Value> {
build_document(
issuer_did,
vtc_did,
CAPABILITY_LIST_TYPE,
serde_json::json!({ "status": "all" }),
)
}
pub fn build_toggle_document(
issuer_did: &str,
vtc_did: &str,
slug: &str,
version: &str,
enable: bool,
) -> TrustTask<Value> {
if enable {
build_document(
issuer_did,
vtc_did,
CAPABILITY_ENABLE_TYPE,
serde_json::json!({
"capability": slug,
"version": version,
"config": { "authority": vtc_did },
}),
)
} else {
build_document(
issuer_did,
vtc_did,
CAPABILITY_DISABLE_TYPE,
serde_json::json!({ "capability": slug }),
)
}
}
pub fn build_git_trust_grant(
authority_did: &str,
registry_did: &str,
subject_did: &str,
resource: &str,
) -> TrustTask<Value> {
build_document(
authority_did,
registry_did,
GIT_TRUST_GRANT_TYPE,
serde_json::json!({ "subject": subject_did, "resource": resource }),
)
}
pub fn build_git_trust_revoke(
authority_did: &str,
registry_did: &str,
subject_did: &str,
resource: &str,
reason: Option<&str>,
) -> TrustTask<Value> {
let mut payload = serde_json::json!({ "subject": subject_did, "resource": resource });
if let Some(reason) = reason {
payload["reason"] = serde_json::json!(reason);
}
build_document(authority_did, registry_did, GIT_TRUST_REVOKE_TYPE, payload)
}
pub fn parse_envelope_document(body: &Value) -> Option<(String, TrustTask<Value>)> {
let doc: TrustTask<Value> = serde_json::from_value(body.clone()).ok()?;
let thid = doc.thread_id.clone()?;
Some((thid, doc))
}
pub fn parse_envelope_document_for(
body: &Value,
expected_thread_id: &str,
) -> Option<TrustTask<Value>> {
let (_, doc) = parse_envelope_document(body)?;
replies_to(&doc, expected_thread_id).then_some(doc)
}
pub fn correlation_thread<P>(doc: &TrustTask<P>) -> &str {
doc.thread_id.as_deref().unwrap_or(&doc.id)
}
pub fn replies_to<P>(reply: &TrustTask<P>, expected_thread_id: &str) -> bool {
reply.thread_id.as_deref() == Some(expected_thread_id)
}
#[derive(Debug, Clone, PartialEq)]
pub enum WriteOutcome {
Success,
IdempotentSuccess,
Rejected {
code: String,
message: Option<String>,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReplyPolicy {
pub accept_legacy_free_text_idempotence: bool,
}
impl ReplyPolicy {
pub fn strict() -> Self {
Self::default()
}
pub fn with_legacy_free_text() -> Self {
Self {
accept_legacy_free_text_idempotence: true,
}
}
}
pub fn classify_git_trust_reply(
doc: &TrustTask<Value>,
expected_thread_id: &str,
) -> Option<WriteOutcome> {
classify_git_trust_reply_with_policy(doc, expected_thread_id, ReplyPolicy::strict())
}
pub fn classify_git_trust_reply_with_policy(
doc: &TrustTask<Value>,
expected_thread_id: &str,
policy: ReplyPolicy,
) -> Option<WriteOutcome> {
if !replies_to(doc, expected_thread_id) {
return None;
}
let slug = doc.type_uri.slug();
if slug == "trust-task-error" {
let (code, message) = error_code_and_message(doc);
if is_idempotent_code(&code) {
return Some(WriteOutcome::IdempotentSuccess);
}
if policy.accept_legacy_free_text_idempotence && code == "taskFailed" {
let reason = message.as_deref().unwrap_or("");
if reason.contains("already_granted:") || reason.contains("not_granted:") {
return Some(WriteOutcome::IdempotentSuccess);
}
}
return Some(WriteOutcome::Rejected { code, message });
}
if doc.type_uri.is_response() && matches!(slug, "git-trust/grant" | "git-trust/revoke") {
return Some(WriteOutcome::Success);
}
None
}
fn is_idempotent_code(code: &str) -> bool {
matches!(
code,
GIT_TRUST_ALREADY_GRANTED_CODE
| GIT_TRUST_NOT_GRANTED_CODE
| GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL
| GIT_TRUST_NOT_GRANTED_CODE_CAMEL
)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapabilitySummary {
pub slug: String,
pub title: Option<String>,
pub version: String,
pub enabled: bool,
pub enabled_at: Option<String>,
pub delegate: Option<String>,
pub manifest: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CapabilityReply {
Listing(Vec<CapabilitySummary>),
Toggled { capability: String, enabled: bool },
Rejected {
code: String,
message: Option<String>,
},
}
pub fn parse_envelope_reply(body: &Value, expected_thread_id: &str) -> Option<CapabilityReply> {
let doc = parse_envelope_document_for(body, expected_thread_id)?;
parse_capability_reply(&doc, expected_thread_id)
}
pub fn parse_capability_reply(
doc: &TrustTask<Value>,
expected_thread_id: &str,
) -> Option<CapabilityReply> {
if !replies_to(doc, expected_thread_id) {
return None;
}
let slug = doc.type_uri.slug();
if slug == "trust-task-error" {
let (code, message) = error_code_and_message(doc);
return Some(CapabilityReply::Rejected { code, message });
}
if !doc.type_uri.is_response() {
return None;
}
match slug {
"governance/capability/list" => {
let entries = doc
.payload
.get("capabilities")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(summary_of).collect())
.unwrap_or_default();
Some(CapabilityReply::Listing(entries))
}
"governance/capability/enable" | "governance/capability/disable" => {
Some(CapabilityReply::Toggled {
capability: doc
.payload
.get("capability")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
enabled: doc
.payload
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
_ => None,
}
}
fn error_code_and_message(doc: &TrustTask<Value>) -> (String, Option<String>) {
let code = doc
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let message = doc
.payload
.get("message")
.and_then(Value::as_str)
.map(str::to_string);
(code, message)
}
fn summary_of(entry: &Value) -> Option<CapabilitySummary> {
let manifest = entry.get("manifest")?.clone();
Some(CapabilitySummary {
slug: manifest.get("capability")?.as_str()?.to_string(),
title: manifest
.get("title")
.and_then(Value::as_str)
.map(str::to_string),
version: manifest
.get("version")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string(),
enabled: entry
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false),
enabled_at: entry
.get("enabledAt")
.and_then(Value::as_str)
.map(str::to_string),
delegate: entry
.get("delegate")
.and_then(Value::as_str)
.map(str::to_string),
manifest,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use trust_tasks_rs::RejectReason;
#[test]
fn builders_are_addressed_and_typed() {
let list = build_list_document("did:example:me", "did:example:vtc");
assert_eq!(list.type_uri.slug(), "governance/capability/list");
assert_eq!(list.issuer.as_deref(), Some("did:example:me"));
assert_eq!(list.payload["status"], "all");
let enable = build_toggle_document(
"did:example:me",
"did:example:vtc",
"git-trust",
"0.1",
true,
);
assert_eq!(enable.payload["config"]["authority"], "did:example:vtc");
let disable = build_toggle_document(
"did:example:me",
"did:example:vtc",
"git-trust",
"0.1",
false,
);
assert_eq!(disable.type_uri.slug(), "governance/capability/disable");
let grant = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
assert_eq!(grant.type_uri.slug(), "git-trust/grant");
assert_eq!(grant.payload["subject"], "did:s");
let revoke = build_git_trust_revoke("did:a", "did:r", "did:s", "openvtc", Some("ended"));
assert_eq!(revoke.payload["reason"], "ended");
}
fn reserialize(doc: &trust_tasks_rs::ErrorResponse) -> TrustTask<Value> {
serde_json::from_value(serde_json::to_value(doc).unwrap()).unwrap()
}
fn error_reply(
request: &TrustTask<Value>,
code: &str,
message: Option<&str>,
) -> TrustTask<Value> {
let mut payload = serde_json::json!({ "code": code, "retryable": false });
if let Some(message) = message {
payload["message"] = serde_json::json!(message);
}
let mut doc = TrustTask::new(
"urn:uuid:err".to_string(),
"https://trusttasks.org/spec/trust-task-error/0.5"
.parse()
.unwrap(),
payload,
);
doc.thread_id = Some(correlation_thread(request).to_string());
doc
}
#[test]
fn git_trust_reply_classification() {
let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
let thread = correlation_thread(&grant).to_string();
let ok = grant.respond_with(
"urn:uuid:r".to_string(),
serde_json::json!({ "granted": true }),
);
assert_eq!(
classify_git_trust_reply(&ok, &thread),
Some(WriteOutcome::Success)
);
let denied = reserialize(&grant.reject_with(
"urn:uuid:e2".to_string(),
RejectReason::PermissionDenied {
reason: "no".to_string(),
},
));
assert!(matches!(
classify_git_trust_reply(&denied, &thread),
Some(WriteOutcome::Rejected { .. })
));
}
#[test]
fn idempotent_success_is_keyed_on_the_extended_code_not_the_message() {
let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
let thread = correlation_thread(&grant).to_string();
let by_code = error_reply(&grant, GIT_TRUST_ALREADY_GRANTED_CODE, None);
assert_eq!(
classify_git_trust_reply(&by_code, &thread),
Some(WriteOutcome::IdempotentSuccess)
);
let revoke = build_git_trust_revoke("did:a", "did:r", "did:s", "org", None);
let revoke_thread = correlation_thread(&revoke).to_string();
assert_eq!(
classify_git_trust_reply(
&error_reply(&revoke, GIT_TRUST_NOT_GRANTED_CODE, None),
&revoke_thread
),
Some(WriteOutcome::IdempotentSuccess)
);
assert_eq!(
classify_git_trust_reply(
&error_reply(&grant, GIT_TRUST_ALREADY_GRANTED_CODE_CAMEL, None),
&thread
),
Some(WriteOutcome::IdempotentSuccess)
);
let free_text = error_reply(
&grant,
"taskFailed",
Some("registry write aborted; not already_granted: the tuple was never written"),
);
assert_eq!(
classify_git_trust_reply(&free_text, &thread),
Some(WriteOutcome::Rejected {
code: "taskFailed".to_string(),
message: Some(
"registry write aborted; not already_granted: the tuple was never written"
.to_string()
),
}),
"a taskFailed whose free text quotes the phrase is still a failure"
);
assert_eq!(
classify_git_trust_reply_with_policy(
&free_text,
&thread,
ReplyPolicy::with_legacy_free_text()
),
Some(WriteOutcome::IdempotentSuccess)
);
assert_eq!(
classify_git_trust_reply_with_policy(&free_text, &thread, ReplyPolicy::strict()),
classify_git_trust_reply(&free_text, &thread),
"strict is the default"
);
assert!(!ReplyPolicy::default().accept_legacy_free_text_idempotence);
}
#[test]
fn a_reply_on_another_thread_resolves_nothing() {
let mine = build_git_trust_grant("did:a", "did:r", "did:s", "org");
let theirs = build_git_trust_grant("did:a", "did:r", "did:other", "other-org");
let my_thread = correlation_thread(&mine).to_string();
assert_ne!(my_thread, correlation_thread(&theirs));
let their_ok = theirs.respond_with(
"urn:uuid:r".to_string(),
serde_json::json!({ "granted": true }),
);
assert_eq!(
classify_git_trust_reply(&their_ok, &my_thread),
None,
"a reply to another exchange must not resolve this request"
);
assert_eq!(
classify_git_trust_reply(&their_ok, correlation_thread(&theirs)),
Some(WriteOutcome::Success)
);
let their_already = error_reply(&theirs, GIT_TRUST_ALREADY_GRANTED_CODE, None);
assert_eq!(classify_git_trust_reply(&their_already, &my_thread), None);
let mut unthreaded = their_ok.clone();
unthreaded.thread_id = None;
assert!(!replies_to(&unthreaded, &my_thread));
assert_eq!(classify_git_trust_reply(&unthreaded, &my_thread), None);
let list = build_list_document("did:me", "did:vtc");
let other_list = build_list_document("did:me", "did:vtc");
let other_reply = other_list.respond_with(
"urn:uuid:r".to_string(),
serde_json::json!({ "capabilities": [] }),
);
assert_eq!(
parse_capability_reply(&other_reply, correlation_thread(&list)),
None
);
assert_eq!(
parse_envelope_reply(
&serde_json::to_value(&other_reply).unwrap(),
correlation_thread(&list)
),
None
);
}
#[test]
fn governance_reply_classification() {
let list = build_list_document("did:me", "did:vtc");
let list_thread = correlation_thread(&list).to_string();
let reply = list.respond_with(
"urn:uuid:r".to_string(),
serde_json::json!({ "capabilities": [{
"manifest": { "capability": "git-trust", "version": "0.1", "title": "Git Commit Trust" },
"enabled": true, "enabledAt": "2026-07-18T00:00:00Z"
}]}),
);
let Some(CapabilityReply::Listing(items)) = parse_capability_reply(&reply, &list_thread)
else {
panic!("expected listing");
};
assert_eq!(items.len(), 1);
assert_eq!(items[0].slug, "git-trust");
assert!(items[0].enabled);
let toggle = build_toggle_document("did:me", "did:vtc", "git-trust", "0.1", true);
let toggle_thread = correlation_thread(&toggle).to_string();
let ack = toggle.respond_with(
"urn:uuid:t".to_string(),
serde_json::json!({ "capability": "git-trust", "enabled": true }),
);
assert_eq!(
parse_capability_reply(&ack, &toggle_thread),
Some(CapabilityReply::Toggled {
capability: "git-trust".to_string(),
enabled: true
})
);
assert_eq!(
parse_envelope_reply(&serde_json::to_value(&ack).unwrap(), &toggle_thread),
Some(CapabilityReply::Toggled {
capability: "git-trust".to_string(),
enabled: true
})
);
}
#[test]
fn envelope_parse_requires_thread_id() {
let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
let reply = grant.respond_with("urn:uuid:r".to_string(), serde_json::json!({}));
let body = serde_json::to_value(&reply).unwrap();
let (thid, _) = parse_envelope_document(&body).unwrap();
assert_eq!(thid, grant.id);
assert!(parse_envelope_document(&serde_json::to_value(&grant).unwrap()).is_none());
assert!(parse_envelope_document_for(&body, &grant.id).is_some());
assert!(parse_envelope_document_for(&body, "urn:uuid:someone-else").is_none());
}
#[test]
fn builders_mint_a_fresh_id_per_attempt() {
let first = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
let second = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
assert_ne!(first.id, second.id);
assert!(first.id.starts_with("urn:uuid:"));
}
#[test]
fn a_new_attempt_mints_a_fresh_id_and_drops_the_stale_proof() {
let mut first = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
first.proof = Some(trust_tasks_rs::Proof {
proof_type: "DataIntegrityProof".into(),
cryptosuite: "eddsa-jcs-2022".into(),
created: chrono::Utc::now(),
proof_purpose: "assertionMethod".into(),
verification_method: "did:a#key-1".into(),
proof_value: "zStale".into(),
extra: Default::default(),
});
let next = new_attempt(&first);
assert_ne!(next.id, first.id, "a new attempt MUST NOT reuse the `id`");
assert!(next.proof.is_none(), "the old proof signed the old `id`");
assert_eq!(next.payload, first.payload);
assert_eq!(next.issuer, first.issuer);
assert_eq!(next.recipient, first.recipient);
assert_eq!(next.type_uri.to_string(), first.type_uri.to_string());
}
#[test]
fn a_new_attempt_re_threads_only_where_the_id_was_the_thread() {
let opening = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
let next = new_attempt(&opening);
assert_eq!(correlation_thread(&next), next.id);
assert_ne!(correlation_thread(&next), correlation_thread(&opening));
let mut in_exchange = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
in_exchange.thread_id = Some("exchange-0001".into());
let next = new_attempt(&in_exchange);
assert_eq!(correlation_thread(&next), "exchange-0001");
}
#[tokio::test]
async fn a_reused_id_with_altered_content_conflicts_and_new_attempt_does_not() {
use trust_tasks_rs::{document_digest, InMemoryReplayGuard, ReplayGuard, ReplayVerdict};
let guard = InMemoryReplayGuard::new(16);
let now = chrono::Utc::now();
let retain = Some(now + chrono::TimeDelta::minutes(5));
let sent = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
let digest = document_digest(&sent).unwrap();
assert_eq!(
guard.claim(&sent.id, &digest, retain, now).await.unwrap(),
ReplayVerdict::Fresh
);
let retried = sent.clone();
let retried_digest = document_digest(&retried).unwrap();
assert_eq!(retried_digest, digest, "a retry is bit-for-bit identical");
assert!(matches!(
guard
.claim(&retried.id, &retried_digest, retain, now)
.await
.unwrap(),
ReplayVerdict::Duplicate { .. }
));
let mut altered = sent.clone();
altered.payload["resource"] = serde_json::json!("some-other-org");
let altered_digest = document_digest(&altered).unwrap();
assert_eq!(
guard
.claim(&altered.id, &altered_digest, retain, now)
.await
.unwrap(),
ReplayVerdict::Conflict,
"a reused `id` with altered content is `idConflict`, not a retry"
);
let attempt = new_attempt(&altered);
let attempt_digest = document_digest(&attempt).unwrap();
assert_eq!(
guard
.claim(&attempt.id, &attempt_digest, retain, now)
.await
.unwrap(),
ReplayVerdict::Fresh
);
}
}