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),
}
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(format!("urn:uuid:{}", Uuid::new_v4()), 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
}
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());
}
}