use std::sync::{Arc, RwLock};
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::sync::OnceCell;
use tracing::debug;
use trust_tasks_rs::TrustTask;
use vta_sdk::protocol::matching::{Protocol, ServiceCapabilities, select_protocol};
use vti_common::capability_client::build_document;
use crate::credentials::LocalSigner;
use crate::hooks::PendingReplies;
use crate::messaging::VtcMessaging;
use super::client::{
CapabilityModuleChange, RegistryError, RegistryTransport, TrustRegistryClient,
};
use super::model::{RegistryRecord, RegistryStatus};
use super::upstream::UpstreamRegistryClient;
use super::{RECOGNISE_ACTION, TRUST_GRAPH_RESOURCE};
const RECORD_PUT: &str = "https://trusttasks.org/spec/registry/record/put/0.1";
const RECORD_DELETE: &str = "https://trusttasks.org/spec/registry/record/delete/0.1";
const RECORD_QUERY: &str = "https://trusttasks.org/spec/registry/record/query/0.1";
const RECOGNITION: &str = "https://trusttasks.org/spec/registry/recognition/0.1";
const CAPABILITY_ENABLE: &str = <trust_tasks_rs::specs::governance::capability::enable::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
const CAPABILITY_DISABLE: &str = <trust_tasks_rs::specs::governance::capability::disable::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const DEFAULT_REPLY_TIMEOUT_SECONDS: u64 = 60;
pub struct MessagingRegistryClient {
registry_did: String,
authority_did: Option<String>,
didcomm: Arc<OnceCell<Arc<VtcMessaging>>>,
signer: Arc<LocalSigner>,
replies: PendingReplies,
did_resolver: Option<DIDCacheClient>,
reply_timeout: Duration,
http: Option<UpstreamRegistryClient>,
transport: Arc<RwLock<RegistryTransport>>,
tsp_mediator: RwLock<Option<String>>,
}
impl std::fmt::Debug for MessagingRegistryClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MessagingRegistryClient")
.field("registry_did", &self.registry_did)
.field("authority_did", &self.authority_did)
.field("http_arm", &self.http.is_some())
.finish()
}
}
impl MessagingRegistryClient {
pub fn new(
registry_did: String,
authority_did: Option<String>,
didcomm: Arc<OnceCell<Arc<VtcMessaging>>>,
signer: Arc<LocalSigner>,
replies: PendingReplies,
did_resolver: Option<DIDCacheClient>,
http: Option<UpstreamRegistryClient>,
) -> Self {
let transport = Arc::new(RwLock::new(RegistryTransport {
did: Some(registry_did.clone()),
url: http.as_ref().map(|c| c.base_url().to_string()),
..RegistryTransport::default()
}));
Self {
registry_did,
authority_did,
didcomm,
signer,
replies,
did_resolver,
reply_timeout: Duration::from_secs(DEFAULT_REPLY_TIMEOUT_SECONDS),
http,
transport,
tsp_mediator: RwLock::new(None),
}
}
#[cfg(any(test, feature = "transport-harness"))]
pub fn with_reply_timeout(mut self, timeout: Duration) -> Self {
self.reply_timeout = timeout;
self
}
fn authority(&self) -> Result<&str, RegistryError> {
self.authority_did.as_deref().ok_or_else(|| {
RegistryError::Permanent(
"vtc_did not configured — cannot address the trust registry (complete `vtc setup` \
first)"
.into(),
)
})
}
async fn select(&self) -> Result<Protocol, RegistryError> {
match self.select_inner().await {
Ok((protocol, advertised)) => {
self.record_selection(advertised, Some(protocol), None);
Ok(protocol)
}
Err((e, advertised)) => {
self.record_selection(advertised, None, Some(e.to_string()));
Err(e)
}
}
}
async fn select_inner(
&self,
) -> Result<(Protocol, Vec<Protocol>), (RegistryError, Vec<Protocol>)> {
let Some(resolver) = self.did_resolver.as_ref() else {
return Err((
RegistryError::Permanent(
"no DID resolver configured — cannot read the trust registry's advertised \
transports"
.into(),
),
Vec::new(),
));
};
let resolved = resolver.resolve(&self.registry_did).await.map_err(|e| {
(
RegistryError::Unreachable(format!(
"could not resolve trust-registry DID {}: {e}",
self.registry_did
)),
Vec::new(),
)
})?;
let doc = serde_json::to_value(&resolved.doc).map_err(|e| {
(
RegistryError::Transient(format!(
"could not read the registry's DID document: {e}"
)),
Vec::new(),
)
})?;
let theirs = ServiceCapabilities::from_did_document(&doc);
if let Ok(mut slot) = self.tsp_mediator.write() {
slot.clone_from(&theirs.tsp);
}
let advertised = theirs.advertised();
let ours = self.our_capabilities();
let matched = select_protocol(&ours, &theirs, &self.registry_did).map_err(|e| {
(
classify_no_match(&ours, &advertised, e.to_string()),
advertised.clone(),
)
})?;
debug!(
registry_did = %self.registry_did,
protocol = %matched.protocol.as_str(),
"selected trust-registry transport",
);
Ok((matched.protocol, advertised))
}
fn record_selection(
&self,
advertised: Vec<Protocol>,
active: Option<Protocol>,
error: Option<String>,
) {
let mut slot = match self.transport.write() {
Ok(slot) => slot,
Err(_) => return,
};
slot.advertised = advertised.iter().map(|p| p.as_str().to_string()).collect();
slot.active = active.map(|p| p.as_str().to_string());
slot.error = error;
}
fn our_capabilities(&self) -> ServiceCapabilities {
let messaging_up = self.didcomm.get().is_some();
ServiceCapabilities {
tsp: (messaging_up && cfg!(feature = "tsp")).then(|| self.registry_did.clone()),
didcomm: messaging_up.then(|| self.registry_did.clone()),
rest: self.http.is_some().then(|| "rest-client".to_string()),
}
}
async fn signed_document(
&self,
type_uri: &str,
payload: Value,
) -> Result<TrustTask<Value>, RegistryError> {
let issuer = self.authority()?.to_string();
let doc = build_document(&issuer, &self.registry_did, type_uri, payload);
let mut as_value = serde_json::to_value(&doc)
.map_err(|e| RegistryError::Transient(format!("serialise document: {e}")))?;
self.signer
.sign_operational_doc(&mut as_value)
.await
.map_err(|e| RegistryError::Transient(format!("sign document: {e}")))?;
serde_json::from_value(as_value)
.map_err(|e| RegistryError::Transient(format!("reparse signed document: {e}")))
}
async fn round_trip(
&self,
type_uri: &str,
payload: Value,
protocol: Protocol,
) -> Result<TrustTask<Value>, RegistryError> {
self.authority()?;
let messaging = self.didcomm.get().ok_or_else(|| {
RegistryError::Transient("VTC messaging is not running yet".to_string())
})?;
let doc = self.signed_document(type_uri, payload).await?;
let receiver = self.replies.register(&doc.id, &self.registry_did);
#[cfg(feature = "tsp")]
let is_tsp = matches!(&protocol, Protocol::Tsp);
let send = match protocol {
Protocol::Tsp => self.send_tsp(messaging, &doc).await,
Protocol::Didcomm => self.send_didcomm(messaging, &doc).await,
Protocol::Rest => Err(RegistryError::Permanent(
"REST transport does not use the Trust-Task round trip".into(),
)),
};
if let Err(e) = send {
self.replies.abandon(&doc.id);
return Err(e);
}
match tokio::time::timeout(self.reply_timeout, receiver).await {
Ok(Ok(reply)) => Ok(reply),
Ok(Err(_closed)) => {
self.replies.abandon(&doc.id);
Err(RegistryError::Transient("reply channel closed".to_string()))
}
Err(_elapsed) => {
self.replies.abandon(&doc.id);
#[cfg(feature = "tsp")]
if is_tsp
&& let Err(e) = messaging
.atm
.tsp()
.reset_relationship(&messaging.profile, &self.registry_did)
.await
{
tracing::warn!(
error = %e,
"could not reset the TSP relationship after a registry reply timeout",
);
}
Err(RegistryError::Unreachable(format!(
"no reply from the trust registry within {}s",
self.reply_timeout.as_secs()
)))
}
}
}
async fn send_didcomm(
&self,
messaging: &VtcMessaging,
doc: &TrustTask<Value>,
) -> Result<(), RegistryError> {
use crate::outbound::OutboundError;
crate::outbound::send_trust_task_didcomm(messaging, &self.registry_did, doc)
.await
.map_err(|e| match e {
OutboundError::Serialise(_) => RegistryError::Transient(e.to_string()),
OutboundError::Pack(_) | OutboundError::Send(_) => {
RegistryError::Unreachable(e.to_string())
}
})
}
#[cfg(feature = "tsp")]
async fn send_tsp(
&self,
messaging: &VtcMessaging,
doc: &TrustTask<Value>,
) -> Result<(), RegistryError> {
let body = tsp_envelope(doc)?;
let registry_mediator = self.tsp_mediator.read().ok().and_then(|m| m.clone());
vti_common::tsp_route::send_reestablishing(
&messaging.atm,
&messaging.profile,
&messaging.mediator_did,
&self.registry_did,
registry_mediator.as_deref(),
&body,
)
.await
.map_err(|e| RegistryError::Unreachable(format!("TSP send failed: {e}")))?;
Ok(())
}
#[cfg(not(feature = "tsp"))]
async fn send_tsp(
&self,
_messaging: &VtcMessaging,
_doc: &TrustTask<Value>,
) -> Result<(), RegistryError> {
Err(RegistryError::Permanent(
"this build has no `tsp` feature and cannot send TSP frames".into(),
))
}
fn record_key(&self, member_did: &str) -> Result<(String, String), RegistryError> {
Ok((member_did.to_string(), self.authority()?.to_string()))
}
}
fn classify_no_match(
ours: &ServiceCapabilities,
theirs: &[Protocol],
detail: String,
) -> RegistryError {
if ours.advertised().is_empty() {
return RegistryError::Transient(format!(
"no transport available yet to reach the trust registry (VTC messaging is still \
starting, and no REST arm is configured); the registry advertises {theirs:?}",
));
}
RegistryError::Permanent(detail)
}
fn tsp_envelope(doc: &TrustTask<Value>) -> Result<Vec<u8>, RegistryError> {
crate::outbound::seal_trust_task_tsp(doc).map_err(|e| RegistryError::Transient(e.to_string()))
}
fn classify(doc: &TrustTask<Value>, expect_slug: &str) -> Result<(), RegistryError> {
let slug = doc.type_uri.slug();
if slug == "trust-task-error" {
let code = doc
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("unknown");
let message = doc
.payload
.get("message")
.and_then(Value::as_str)
.unwrap_or("");
let detail = format!("registry rejected {expect_slug}: {code}");
let detail = if message.is_empty() {
detail
} else {
format!("{detail} ({message})")
};
return match code {
"internalError" | "unavailable" => Err(RegistryError::Transient(detail)),
"unsupportedType" => Err(RegistryError::Incompatible(detail)),
_ => Err(RegistryError::Permanent(detail)),
};
}
if !doc.type_uri.is_response() || slug != expect_slug {
return Err(RegistryError::Transient(format!(
"expected a {expect_slug} response, got `{}`",
doc.type_uri
)));
}
Ok(())
}
fn classify_capability_toggle(
reply: &TrustTask<Value>,
slug: &str,
enabled: bool,
) -> Result<(), RegistryError> {
if reply.type_uri.slug() == "trust-task-error" {
let code = reply
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or_default();
let message = reply
.payload
.get("message")
.and_then(Value::as_str)
.unwrap_or_default();
let (extended, legacy) = if enabled {
(
trust_tasks_rs::specs::governance::capability::enable::v0_1::error_codes::ALREADY_ENABLED.code,
"already_enabled",
)
} else {
(
trust_tasks_rs::specs::governance::capability::disable::v0_1::error_codes::NOT_ENABLED.code,
"not_enabled",
)
};
if code == extended || (code == "taskFailed" && message.starts_with(legacy)) {
return Ok(());
}
}
classify(reply, slug)
}
fn trust_record(authority_did: &str, record: &RegistryRecord) -> Value {
let recognised = matches!(record.status, RegistryStatus::Active);
let mut context = json!({
"status": if recognised { "active" } else { "departed" },
"activeFrom": record.active_from.to_rfc3339(),
});
if let Some(active_to) = record.active_to {
context["activeTo"] = json!(active_to.to_rfc3339());
}
json!({
"entity_id": record.member_did,
"authority_id": authority_did,
"action": RECOGNISE_ACTION,
"resource": TRUST_GRAPH_RESOURCE,
"record_type": "recognition",
"recognized": recognised,
"context": context,
})
}
fn registry_record_from(value: &Value) -> Option<RegistryRecord> {
let member_did = value.get("entity_id")?.as_str()?.to_string();
let recognised = value
.get("recognized")
.and_then(Value::as_bool)
.unwrap_or(false);
let context = value.get("context");
let parse_time = |key: &str| {
context
.and_then(|c| c.get(key))
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&chrono::Utc))
};
let now = chrono::Utc::now();
Some(RegistryRecord {
member_did,
status: if recognised {
RegistryStatus::Active
} else {
RegistryStatus::Departed
},
active_from: parse_time("activeFrom").unwrap_or(now),
active_to: parse_time("activeTo"),
last_synced_at: now,
})
}
#[async_trait]
impl TrustRegistryClient for MessagingRegistryClient {
async fn publish_member(&self, record: &RegistryRecord) -> Result<(), RegistryError> {
let authority = self.authority()?.to_string();
match self.select().await? {
Protocol::Rest => self.rest()?.publish_member(record).await,
protocol => {
let payload = json!({ "record": trust_record(&authority, record) });
let reply = self.round_trip(RECORD_PUT, payload, protocol).await?;
classify(&reply, "registry/record/put")
}
}
}
async fn delete_member(&self, member_did: &str) -> Result<(), RegistryError> {
let (entity_id, authority_id) = self.record_key(member_did)?;
match self.select().await? {
Protocol::Rest => self.rest()?.delete_member(member_did).await,
protocol => {
let payload = json!({
"entity_id": entity_id,
"authority_id": authority_id,
"action": RECOGNISE_ACTION,
"resource": TRUST_GRAPH_RESOURCE,
});
let reply = self.round_trip(RECORD_DELETE, payload, protocol).await?;
match classify(&reply, "registry/record/delete") {
Err(RegistryError::Permanent(m))
if m.contains("registry/record/delete:notFound") =>
{
Ok(())
}
other => other,
}
}
}
}
async fn read_member(&self, member_did: &str) -> Result<Option<RegistryRecord>, RegistryError> {
let (entity_id, authority_id) = self.record_key(member_did)?;
match self.select().await? {
Protocol::Rest => self.rest()?.read_member(member_did).await,
protocol => {
let payload = json!({
"entity_id": entity_id,
"authority_id": authority_id,
"action": RECOGNISE_ACTION,
"limit": 50,
});
let reply = self.round_trip(RECORD_QUERY, payload, protocol).await?;
classify(&reply, "registry/record/query")?;
let found = reply
.payload
.get("records")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|r| {
r.get("resource").and_then(Value::as_str) == Some(TRUST_GRAPH_RESOURCE)
})
.and_then(registry_record_from);
Ok(found)
}
}
}
async fn list_records(&self) -> Result<Vec<RegistryRecord>, RegistryError> {
let authority = self.authority()?.to_string();
match self.select().await? {
Protocol::Rest => Err(crate::registry::drift::unsupported()),
protocol => {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
const MAX_PAGES: usize = 50;
const PAGE: u32 = 200;
for page in 0..MAX_PAGES {
let mut payload = json!({
"authority_id": authority,
"action": RECOGNISE_ACTION,
"limit": PAGE,
});
if let Some(c) = &cursor {
payload["cursor"] = json!(c);
}
let reply = self.round_trip(RECORD_QUERY, payload, protocol).await?;
classify(&reply, "registry/record/query")?;
out.extend(
reply
.payload
.get("records")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|r| {
r.get("resource").and_then(Value::as_str)
== Some(TRUST_GRAPH_RESOURCE)
})
.filter_map(registry_record_from),
);
cursor = reply
.payload
.get("nextCursor")
.and_then(Value::as_str)
.map(str::to_string);
if cursor.is_none() {
return Ok(out);
}
if page + 1 == MAX_PAGES {
return Err(RegistryError::Transient(format!(
"the trust registry is still paginating after {MAX_PAGES} pages \
({} records); refusing to compare a partial enumeration",
out.len()
)));
}
}
Ok(out)
}
}
}
async fn recognise(&self, foreign_issuer_did: &str) -> Result<bool, RegistryError> {
let authority = self.authority()?.to_string();
match self.select().await? {
Protocol::Rest => self.rest()?.recognise(foreign_issuer_did).await,
protocol => {
let payload = json!({
"entity_id": foreign_issuer_did,
"authority_id": authority,
"action": RECOGNISE_ACTION,
"resource": TRUST_GRAPH_RESOURCE,
});
let reply = self.round_trip(RECOGNITION, payload, protocol).await?;
classify(&reply, "registry/recognition")?;
Ok(reply
.payload
.get("recognized")
.and_then(Value::as_bool)
.unwrap_or(false))
}
}
}
async fn put_trust_record(&self, record: &Value) -> Result<(), RegistryError> {
let authority = self.authority()?.to_string();
if record.get("authority_id").and_then(Value::as_str) != Some(authority.as_str()) {
return Err(RegistryError::Permanent(
"an authorization record must carry this community as its authority".into(),
));
}
match self.select().await? {
Protocol::Rest => Err(RegistryError::Permanent(
"the trust registry is reachable only over REST, which has no record-write \
surface; the git-namespace projection needs TSP or DIDComm"
.into(),
)),
protocol => {
let payload = json!({ "record": record });
let reply = self.round_trip(RECORD_PUT, payload, protocol).await?;
classify(&reply, "registry/record/put")
}
}
}
async fn list_trust_records(&self, action: &str) -> Result<Vec<Value>, RegistryError> {
self.query_all_records(Some(action)).await
}
async fn list_all_trust_records(&self) -> Result<Vec<Value>, RegistryError> {
self.query_all_records(None).await
}
async fn delete_trust_record(
&self,
entity_id: &str,
action: &str,
resource: &str,
) -> Result<(), RegistryError> {
let authority = self.authority()?.to_string();
match self.select().await? {
Protocol::Rest => Err(RegistryError::Permanent(
"the trust registry is reachable only over REST, which has no record-delete \
surface"
.into(),
)),
protocol => {
let payload = json!({
"entity_id": entity_id,
"authority_id": authority,
"action": action,
"resource": resource,
});
let reply = self.round_trip(RECORD_DELETE, payload, protocol).await?;
match classify(&reply, "registry/record/delete") {
Err(RegistryError::Permanent(m))
if m.contains("registry/record/delete:notFound") =>
{
Ok(())
}
other => other,
}
}
}
}
async fn project_capability_module(
&self,
change: &CapabilityModuleChange,
) -> Result<(), RegistryError> {
use trust_tasks_rs::specs::governance::capability::{
disable::v0_1 as disable, enable::v0_1 as enable,
};
use trust_tasks_rs::validate::ValidatedPayload;
self.authority()?;
let (type_uri, slug, payload) = if change.enabled {
let mut payload = json!({
"capability": change.capability,
"version": change.version,
});
if let Some(config) = &change.config {
payload["config"] = config.clone();
}
(CAPABILITY_ENABLE, "governance/capability/enable", payload)
} else {
let mut payload = json!({ "capability": change.capability });
if let Some(reason) = &change.reason {
payload["reason"] = json!(reason);
}
(CAPABILITY_DISABLE, "governance/capability/disable", payload)
};
let checked = if change.enabled {
enable::Payload::validate_value(&payload)
} else {
disable::Payload::validate_value(&payload)
};
if let Err(e) = checked {
return Err(RegistryError::Permanent(format!(
"{slug} payload does not match its published schema: {e}"
)));
}
match self.select().await? {
Protocol::Rest => Err(RegistryError::Permanent(
"the trust registry is reachable only over REST, which has no governance \
surface; enabling a capability module there needs TSP or DIDComm"
.into(),
)),
protocol => {
let reply = self.round_trip(type_uri, payload, protocol).await?;
classify_capability_toggle(&reply, slug, change.enabled)
}
}
}
fn transport(&self) -> RegistryTransport {
self.transport
.read()
.map(|t| t.clone())
.unwrap_or_else(|_| RegistryTransport {
did: Some(self.registry_did.clone()),
..RegistryTransport::default()
})
}
async fn health(&self) -> Result<(), RegistryError> {
match self.select().await? {
Protocol::Rest => self.rest()?.health().await,
protocol => {
let payload = json!({ "limit": 1 });
match self.round_trip(RECORD_QUERY, payload, protocol).await {
Ok(_) => Ok(()),
Err(RegistryError::Permanent(e)) => {
debug!(error = %e, "registry health probe answered with a rejection");
Ok(())
}
Err(e) => Err(e),
}
}
}
}
}
impl MessagingRegistryClient {
async fn query_all_records(&self, action: Option<&str>) -> Result<Vec<Value>, RegistryError> {
let authority = self.authority()?.to_string();
match self.select().await? {
Protocol::Rest => Err(crate::registry::drift::unsupported()),
protocol => {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
const MAX_PAGES: usize = 50;
const PAGE: u32 = 200;
for page in 0..MAX_PAGES {
let mut payload = json!({
"authority_id": authority,
"limit": PAGE,
});
if let Some(a) = action {
payload["action"] = json!(a);
}
if let Some(c) = &cursor {
payload["cursor"] = json!(c);
}
let reply = self.round_trip(RECORD_QUERY, payload, protocol).await?;
classify(&reply, "registry/record/query")?;
out.extend(
reply
.payload
.get("records")
.and_then(Value::as_array)
.into_iter()
.flatten()
.cloned(),
);
cursor = reply
.payload
.get("nextCursor")
.and_then(Value::as_str)
.map(str::to_string);
if cursor.is_none() {
return Ok(out);
}
if page + 1 == MAX_PAGES {
let what = action.map_or_else(
|| "this community's records".to_string(),
|a| format!("`{a}`"),
);
return Err(RegistryError::Transient(format!(
"the trust registry is still paginating {what} after \
{MAX_PAGES} pages; refusing to answer from a partial list"
)));
}
}
Ok(out)
}
}
}
fn rest(&self) -> Result<&UpstreamRegistryClient, RegistryError> {
self.http.as_ref().ok_or_else(|| {
RegistryError::Permanent(
"the trust registry advertises only REST, but `registry.url` is not configured — \
set it, or advertise a messaging transport on the registry's DID"
.into(),
)
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use chrono::{TimeZone, Utc};
fn record_doc(request_type: &str, payload: Value) -> TrustTask<Value> {
TrustTask::new(
"urn:uuid:reply".to_string(),
format!("{request_type}#response").parse().unwrap(),
payload,
)
}
fn error_doc(code: &str) -> TrustTask<Value> {
TrustTask::new(
"urn:uuid:err".to_string(),
crate::trust_tasks::helpers::framework_error_type_uri(),
json!({ "code": code, "message": "nope" }),
)
}
#[test]
fn a_capability_toggle_already_in_the_wanted_state_is_success() {
let enable = "governance/capability/enable";
let disable = "governance/capability/disable";
let with = |code: &str, message: &str| {
let mut doc = error_doc(code);
doc.payload["message"] = json!(message);
doc
};
assert!(
classify_capability_toggle(&record_doc(CAPABILITY_ENABLE, json!({})), enable, true)
.is_ok()
);
assert!(
classify_capability_toggle(
&error_doc("governance/capability/enable:alreadyEnabled"),
enable,
true
)
.is_ok()
);
assert!(
classify_capability_toggle(&with("taskFailed", "already_enabled: it is"), enable, true)
.is_ok()
);
assert!(
classify_capability_toggle(
&error_doc("governance/capability/disable:notEnabled"),
disable,
false
)
.is_ok()
);
assert!(
classify_capability_toggle(
&with("taskFailed", "not_enabled: it is not"),
disable,
false
)
.is_ok()
);
assert!(
classify_capability_toggle(
&error_doc("governance/capability/enable:alreadyEnabled"),
disable,
false
)
.is_err()
);
assert!(matches!(
classify_capability_toggle(&error_doc("permissionDenied"), enable, true),
Err(RegistryError::Permanent(_))
));
assert!(matches!(
classify_capability_toggle(&error_doc("unsupportedType"), enable, true),
Err(RegistryError::Incompatible(_))
));
assert!(matches!(
classify_capability_toggle(&error_doc("internalError"), disable, false),
Err(RegistryError::Transient(_))
));
}
#[test]
fn tsp_envelope_wraps_the_document() {
let doc = build_document(
"did:webvh:vtc",
"did:webvh:registry",
RECORD_QUERY,
json!({ "limit": 1 }),
);
let bytes = tsp_envelope(&doc).unwrap();
let parsed: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["type"], vta_sdk::tsp_binding::ENVELOPE_TYPE);
assert_eq!(parsed["document"]["type"], RECORD_QUERY);
}
#[test]
fn permission_denied_is_permanent_and_internal_error_is_transient() {
let denied = classify(&error_doc("permissionDenied"), "registry/record/put").unwrap_err();
assert!(matches!(denied, RegistryError::Permanent(_)));
assert!(!denied.is_retriable());
let internal = classify(&error_doc("internalError"), "registry/record/put").unwrap_err();
assert!(internal.is_retriable());
}
#[test]
fn unsupported_type_is_incompatible_not_a_rejection() {
let skew = classify(&error_doc("unsupportedType"), "registry/record/put").unwrap_err();
assert!(
matches!(skew, RegistryError::Incompatible(_)),
"got {skew:?}"
);
assert!(skew.is_incompatible());
assert!(!skew.is_retriable());
let denied = classify(&error_doc("permissionDenied"), "registry/record/put").unwrap_err();
assert!(!denied.is_incompatible());
}
#[test]
fn a_wrong_response_type_does_not_pass_as_success() {
let wrong = record_doc(RECORD_QUERY, json!({ "records": [] }));
assert!(classify(&wrong, "registry/record/put").is_err());
let right = record_doc(RECORD_PUT, json!({ "ok": true, "created": true }));
assert!(classify(&right, "registry/record/put").is_ok());
}
#[test]
fn published_record_carries_the_query_tuple_and_recognition() {
let mut record = RegistryRecord::fresh_active("did:key:zMember");
record.active_from = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap();
let value = trust_record("did:webvh:vtc", &record);
assert_eq!(value["entity_id"], "did:key:zMember");
assert_eq!(value["authority_id"], "did:webvh:vtc");
assert_eq!(value["action"], RECOGNISE_ACTION);
assert_eq!(value["resource"], TRUST_GRAPH_RESOURCE);
assert_eq!(value["record_type"], "recognition");
assert_eq!(value["recognized"], true);
assert_eq!(value["context"]["status"], "active");
assert_eq!(value["context"]["activeFrom"], "2026-08-01T00:00:00+00:00");
}
#[test]
fn a_departed_member_is_published_as_not_recognised() {
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let to = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap();
let record = RegistryRecord::departed("did:key:zGone", from, Some(to));
let value = trust_record("did:webvh:vtc", &record);
assert_eq!(value["recognized"], false);
assert_eq!(value["context"]["status"], "departed");
assert_eq!(value["context"]["activeTo"], "2026-08-01T00:00:00+00:00");
}
#[test]
fn a_record_without_recognized_reads_as_departed() {
let parsed = registry_record_from(&json!({
"entity_id": "did:key:zMember",
"authority_id": "did:webvh:vtc",
"action": RECOGNISE_ACTION,
"resource": TRUST_GRAPH_RESOURCE,
"record_type": "recognition",
}))
.unwrap();
assert_eq!(parsed.status, RegistryStatus::Departed);
}
#[test]
fn record_round_trips_through_the_wire_shape() {
let record = RegistryRecord::fresh_active("did:key:zMember");
let wire = trust_record("did:webvh:vtc", &record);
let back = registry_record_from(&wire).unwrap();
assert_eq!(back.member_did, record.member_did);
assert_eq!(back.status, RegistryStatus::Active);
assert_eq!(back.active_from.timestamp(), record.active_from.timestamp());
}
fn client_with(authority_did: Option<&str>) -> MessagingRegistryClient {
MessagingRegistryClient::new(
"did:webvh:registry".into(),
authority_did.map(str::to_string),
Arc::new(OnceCell::new()),
Arc::new(LocalSigner::from_ed25519_seed(
"did:webvh:vtc".into(),
&[7u8; 32],
)),
PendingReplies::new(),
None,
None,
)
}
#[test]
fn not_being_ready_is_transient_but_a_real_mismatch_is_permanent() {
let not_ready = ServiceCapabilities::default();
let err = classify_no_match(¬_ready, &[Protocol::Didcomm], "no overlap".into());
assert!(err.is_retriable(), "got {err:?}");
assert!(
err.to_string().contains("still \nstarting")
|| err.to_string().contains("still starting"),
"the message should name the startup race: {err}",
);
let ready = ServiceCapabilities {
didcomm: Some("did:webvh:mediator".into()),
..ServiceCapabilities::default()
};
let err = classify_no_match(&ready, &[Protocol::Tsp], "no overlap".into());
assert!(!err.is_retriable(), "got {err:?}");
}
#[test]
fn the_transport_snapshot_carries_the_did_before_any_call() {
let client = client_with(None);
let snapshot = client.transport();
assert_eq!(snapshot.did.as_deref(), Some("did:webvh:registry"));
assert!(snapshot.advertised.is_empty());
assert_eq!(snapshot.active, None);
}
#[test]
fn a_failed_selection_records_the_peer_set_alongside_the_error() {
let client = client_with(None);
client.record_selection(vec![Protocol::Tsp], None, Some("no overlap".into()));
let snapshot = client.transport();
assert_eq!(snapshot.advertised, vec!["tsp".to_string()]);
assert_eq!(snapshot.active, None);
assert_eq!(snapshot.error.as_deref(), Some("no overlap"));
}
#[test]
fn a_later_success_clears_the_earlier_error() {
let client = client_with(None);
client.record_selection(vec![], None, Some("no overlap".into()));
client.record_selection(vec![Protocol::Didcomm], Some(Protocol::Didcomm), None);
let snapshot = client.transport();
assert_eq!(snapshot.advertised, vec!["didcomm".to_string()]);
assert_eq!(snapshot.active.as_deref(), Some("didcomm"));
assert_eq!(snapshot.error, None);
}
#[test]
fn our_capabilities_offer_rest_only_with_a_url_and_messaging_only_when_up() {
let client = client_with(Some("did:webvh:vtc"));
let caps = client.our_capabilities();
assert_eq!(caps.advertised(), Vec::<Protocol>::new());
}
#[tokio::test]
async fn every_call_refuses_before_setup() {
let client = client_with(None);
let err = client
.delete_member("did:key:zMember")
.await
.expect_err("refuses without an authority DID");
assert!(matches!(err, RegistryError::Permanent(_)));
assert!(err.to_string().contains("vtc_did"));
}
#[tokio::test]
async fn a_record_query_is_signed_for_authentication() {
let client = client_with(Some("did:webvh:vtc"));
let doc = client
.signed_document(RECORD_QUERY, json!({ "limit": 1 }))
.await
.expect("signs");
let doc = serde_json::to_value(&doc).unwrap();
assert_eq!(doc["type"], RECORD_QUERY);
assert_eq!(doc["proof"]["proofPurpose"], "authentication", "{doc}");
assert_eq!(doc["issuer"], "did:webvh:vtc");
assert_eq!(doc["recipient"], client.registry_did.as_str());
assert!(doc.get("issuedAt").is_some(), "{doc}");
let other = client
.signed_document(RECORD_QUERY, json!({ "limit": 1 }))
.await
.unwrap();
assert_ne!(doc["id"], serde_json::to_value(&other).unwrap()["id"]);
}
#[tokio::test]
async fn a_write_is_transient_while_messaging_is_down() {
let client =
client_with(Some("did:webvh:vtc")).with_reply_timeout(Duration::from_millis(50));
let err = client
.round_trip(RECORD_QUERY, json!({ "limit": 1 }), Protocol::Didcomm)
.await
.expect_err("messaging is not running");
assert!(err.is_retriable(), "got {err:?}");
}
}