use std::sync::Arc;
use std::time::Duration;
use affinidi_tdk::messaging::{ATM, errors::ATMError, profiles::ATMProfile};
use serde_json::Value;
use tracing::{error, info, warn};
use trust_tasks_rs::TrustTask;
use trust_tasks_tsp::ENVELOPE_TYPE;
use crate::trust_tasks::TaskHandler;
const UNPACK_MAX_ATTEMPTS: u32 = 3;
const UNPACK_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const UNPACK_MAX_BACKOFF: Duration = Duration::from_millis(1_000);
fn is_transient_unpack_error(err: &ATMError) -> bool {
matches!(
err,
ATMError::DIDError(_)
| ATMError::TransportError(_)
| ATMError::Disconnected(_)
| ATMError::TDKError(_)
)
}
async fn retry_transient<T, F, Fut>(
attempts: u32,
initial_backoff: Duration,
max_backoff: Duration,
mut op: F,
) -> Result<T, ATMError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, ATMError>>,
{
let mut backoff = initial_backoff;
let mut attempt = 1;
loop {
match op().await {
Ok(value) => return Ok(value),
Err(err) => {
if attempt >= attempts || !is_transient_unpack_error(&err) {
return Err(err);
}
warn!(
"TSP unpack failed transiently (attempt {attempt}/{attempts}), \
retrying in {backoff:?}: {err}"
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max_backoff);
attempt += 1;
}
}
}
}
fn parse_envelope(payload: &[u8]) -> Result<TrustTask<Value>, String> {
let envelope: Value =
serde_json::from_slice(payload).map_err(|e| format!("invalid TSP envelope JSON: {e}"))?;
match envelope.get("type").and_then(Value::as_str) {
Some(t) if t == ENVELOPE_TYPE => {}
other => return Err(format!("unexpected TSP envelope type: {other:?}")),
}
let document = envelope
.get("document")
.cloned()
.ok_or_else(|| "TSP envelope missing `document`".to_string())?;
serde_json::from_value(document).map_err(|e| format!("invalid Trust Task document: {e}"))
}
fn build_envelope<T: serde::Serialize>(doc: &T) -> Vec<u8> {
let document = serde_json::to_value(doc).unwrap_or_else(|_| serde_json::json!({}));
let envelope = serde_json::json!({ "type": ENVELOPE_TYPE, "document": document });
serde_json::to_vec(&envelope).unwrap_or_default()
}
async fn handle_inbound(tasks: &TaskHandler, sender_did: &str, doc: TrustTask<Value>) -> Vec<u8> {
match tasks.handle(doc, Some(sender_did)).await {
Ok(response) => build_envelope(&response),
Err(err) => build_envelope(&err),
}
}
pub async fn process_tsp_frame(
atm: &Arc<ATM>,
profile: &Arc<ATMProfile>,
tasks: &TaskHandler,
packed: &str,
) {
let alias = &profile.inner.alias;
let unpacked = retry_transient(
UNPACK_MAX_ATTEMPTS,
UNPACK_INITIAL_BACKOFF,
UNPACK_MAX_BACKOFF,
|| async { atm.tsp().unpack(profile, packed).await },
)
.await;
let (payload, sender_did) = match unpacked {
Ok(v) => v,
Err(e) if is_transient_unpack_error(&e) => {
error!(
"[profile = {alias}] TSP unpack still failing after {UNPACK_MAX_ATTEMPTS} \
attempts; the frame is already deleted from the mediator, so a signed \
registry write may have been lost: {e}"
);
return;
}
Err(e) => {
warn!("[profile = {alias}] Dropping unusable TSP frame: {e}");
return;
}
};
let doc = match parse_envelope(&payload) {
Ok(doc) => doc,
Err(e) => {
warn!("[profile = {alias}] Dropping TSP message from {sender_did}: {e}");
return;
}
};
info!(
"[profile = {alias}, type = {}, from = {sender_did}] Trust Task (TSP)",
doc.type_uri.slug()
);
let reply = handle_inbound(tasks, &sender_did, doc).await;
if let Err(e) = atm.tsp().send(profile, &sender_did, &reply).await {
error!("[profile = {alias}] Failed to send TSP response to {sender_did}: {e}");
}
}
pub async fn dispatch_tsp_application(
atm: &Arc<ATM>,
profile: &Arc<ATMProfile>,
tasks: &TaskHandler,
payload: &[u8],
sender_did: &str,
) {
let alias = &profile.inner.alias;
let doc = match parse_envelope(payload) {
Ok(doc) => doc,
Err(e) => {
warn!("[profile = {alias}] Dropping TSP message from {sender_did}: {e}");
return;
}
};
info!(
"[profile = {alias}, type = {}, from = {sender_did}] Trust Task (TSP)",
doc.type_uri.slug()
);
let reply = handle_inbound(tasks, sender_did, doc).await;
if let Err(e) = atm.tsp().send(profile, sender_did, &reply).await {
error!("[profile = {alias}] Failed to send TSP response to {sender_did}: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc_with(type_uri: &str, proof: bool) -> TrustTask<Value> {
let mut doc = TrustTask::new(
uuid::Uuid::new_v4().to_string(),
type_uri.parse().expect("valid type uri"),
serde_json::json!({}),
);
if proof {
doc.proof = Some(
serde_json::from_value(serde_json::json!({
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2026-07-07T00:00:00Z",
"proofPurpose": "authentication",
"verificationMethod": "did:example:admin#key-1",
"proofValue": "z0000"
}))
.expect("valid proof fixture"),
);
}
doc
}
const RECOGNITION: &str = "https://trusttasks.org/spec/registry/recognition/0.1";
#[test]
fn envelope_round_trips() {
let doc = doc_with(RECOGNITION, false);
let bytes = build_envelope(&doc);
let parsed = parse_envelope(&bytes).expect("round-trips");
assert_eq!(parsed.type_uri.slug(), "registry/recognition");
}
use std::sync::atomic::{AtomicU32, Ordering};
const TEST_BACKOFF: Duration = Duration::from_millis(1);
#[test]
fn resolver_failures_are_transient() {
assert!(is_transient_unpack_error(&ATMError::DIDError(
"couldn't resolve TSP VID did:web:peer".into()
)));
assert!(is_transient_unpack_error(&ATMError::TransportError(
"connection reset".into()
)));
assert!(is_transient_unpack_error(&ATMError::TDKError(
"resolver cache miss".into()
)));
}
#[test]
fn crypto_and_parse_failures_are_poison() {
assert!(!is_transient_unpack_error(&ATMError::MsgReceiveError(
"couldn't unpack TSP message: bad signature".into()
)));
assert!(!is_transient_unpack_error(&ATMError::MsgReceiveError(
"couldn't parse TSP envelope: truncated".into()
)));
assert!(!is_transient_unpack_error(&ATMError::SecretsError(
"no Ed25519 authentication key".into()
)));
}
#[tokio::test]
async fn transient_failure_is_retried_until_it_succeeds() {
let calls = AtomicU32::new(0);
let result: Result<&str, ATMError> =
retry_transient(3, TEST_BACKOFF, TEST_BACKOFF, || async {
if calls.fetch_add(1, Ordering::SeqCst) < 2 {
Err(ATMError::DIDError("resolver down".into()))
} else {
Ok("unpacked")
}
})
.await;
assert_eq!(result.expect("succeeds on third attempt"), "unpacked");
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn poison_is_not_retried() {
let calls = AtomicU32::new(0);
let result: Result<&str, ATMError> =
retry_transient(3, TEST_BACKOFF, TEST_BACKOFF, || async {
calls.fetch_add(1, Ordering::SeqCst);
Err(ATMError::MsgReceiveError("bad signature".into()))
})
.await;
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"poison must fail on the first attempt, not burn the retry budget"
);
}
#[tokio::test]
async fn transient_failure_gives_up_after_the_attempt_budget() {
let calls = AtomicU32::new(0);
let result: Result<&str, ATMError> =
retry_transient(3, TEST_BACKOFF, TEST_BACKOFF, || async {
calls.fetch_add(1, Ordering::SeqCst);
Err(ATMError::DIDError("resolver still down".into()))
})
.await;
let err = result.expect_err("gives up");
assert!(is_transient_unpack_error(&err));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn first_attempt_success_does_not_sleep() {
let calls = AtomicU32::new(0);
let result: Result<&str, ATMError> = retry_transient(
3,
Duration::from_secs(30),
Duration::from_secs(30),
|| async {
calls.fetch_add(1, Ordering::SeqCst);
Ok("unpacked")
},
)
.await;
assert!(result.is_ok());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn envelope_rejects_wrong_type() {
let bytes = serde_json::to_vec(&serde_json::json!({
"type": "https://example.com/not-tsp",
"document": {}
}))
.unwrap();
assert!(parse_envelope(&bytes).is_err());
}
}