use std::error::Error as StdError;
use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::ceremony::Ceremony;
use crate::error::{ErrorPayload, RejectReason};
use crate::payload::Payload;
use crate::proof::Proof;
use crate::type_uri::TypeUri;
pub type ErrorResponse = TrustTask<ErrorPayload>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TrustTask<P> {
pub id: String,
#[serde(rename = "threadId", default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(
rename = "parentThreadId",
default,
skip_serializing_if = "Option::is_none"
)]
pub parent_thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ceremony: Option<Ceremony>,
#[serde(rename = "type")]
pub type_uri: TypeUri,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipient: Option<String>,
#[serde(rename = "issuedAt", default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<DateTime<Utc>>,
#[serde(rename = "expiresAt", default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
pub payload: P,
#[serde(rename = "@context", default, skip_serializing_if = "Option::is_none")]
pub context: Option<JsonLdContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proof: Option<Proof>,
#[serde(flatten)]
pub extra: std::collections::BTreeMap<String, Value>,
}
impl<P> TrustTask<P> {
pub fn new(id: impl Into<String>, type_uri: TypeUri, payload: P) -> Self {
Self {
id: id.into(),
thread_id: None,
parent_thread_id: None,
ceremony: None,
type_uri,
issuer: None,
recipient: None,
issued_at: None,
expires_at: None,
payload,
context: None,
proof: None,
extra: Default::default(),
}
}
pub fn for_payload(id: impl Into<String>, payload: P) -> Self
where
P: Payload,
{
Self::new(id, P::type_uri(), payload)
}
pub fn enforce_audience_binding(&self) -> Result<(), RejectReason>
where
P: Payload,
{
if self.proof.is_some() && self.recipient.is_none() && !P::IS_BEARER {
return Err(RejectReason::MalformedRequest {
reason: "proof present with no in-band recipient on a non-bearer specification \
(SPEC §4.8.2 audience binding)"
.to_string(),
});
}
Ok(())
}
pub fn enforce_spec_policy(&self) -> Result<(), RejectReason>
where
P: Payload,
{
if self.recipient.is_none() && P::IS_RECIPIENT_REQUIRED {
return Err(RejectReason::MalformedRequest {
reason: "specification declares recipient REQUIRED but the document \
carries no in-band recipient"
.to_string(),
});
}
if self.proof.is_none() && P::IS_PROOF_REQUIRED {
return Err(RejectReason::ProofRequired);
}
self.enforce_audience_binding()
}
pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
matches!(self.expires_at, Some(t) if t <= now)
}
pub fn validate_basic(&self, now: DateTime<Utc>, my_vid: &str) -> Result<(), RejectReason> {
if let Some(expires_at) = self.expires_at {
if expires_at <= now {
return Err(RejectReason::Expired { expires_at });
}
}
if let Some(recipient) = self.recipient.as_deref() {
if recipient != my_vid {
return Err(RejectReason::WrongRecipient {
in_band: recipient.to_string(),
expected: my_vid.to_string(),
});
}
}
Ok(())
}
pub fn reject_with(
&self,
id: impl Into<String>,
payload: impl Into<ErrorPayload>,
) -> ErrorResponse {
self.reject_with_recipient(id, payload, self.issuer.clone())
}
pub fn reject_with_recipient(
&self,
id: impl Into<String>,
payload: impl Into<ErrorPayload>,
recipient: Option<String>,
) -> ErrorResponse {
let thread_id = self.thread_id.clone().or_else(|| Some(self.id.clone()));
let mut payload = payload.into();
if payload.in_response_to.is_none() {
payload.in_response_to = Some(crate::InResponseTo {
type_uri: self.type_uri.to_string(),
id: match &payload.code {
crate::TrustTaskCode::Standard(crate::StandardCode::IdentityMismatch) => None,
_ => Some(self.id.clone()),
},
});
}
ErrorResponse {
id: id.into(),
thread_id,
ceremony: self.ceremony.clone(),
parent_thread_id: self.parent_thread_id.clone(),
type_uri: trust_task_error_type_uri(),
issuer: self.recipient.clone(),
recipient,
issued_at: Some(Utc::now()),
expires_at: None,
payload,
context: None,
proof: None,
extra: Default::default(),
}
}
pub fn respond_with<R>(&self, id: impl Into<String>, payload: R) -> TrustTask<R> {
let thread_id = self.thread_id.clone().or_else(|| Some(self.id.clone()));
TrustTask {
id: id.into(),
thread_id,
ceremony: self.ceremony.clone(),
parent_thread_id: self.parent_thread_id.clone(),
type_uri: self.type_uri.with_response(),
issuer: self.recipient.clone(),
recipient: self.issuer.clone(),
issued_at: Some(Utc::now()),
expires_at: None,
payload,
context: None,
proof: None,
extra: Default::default(),
}
}
}
pub fn trust_task_error_type_uri() -> TypeUri {
TypeUri::canonical("trust-task-error", 0, 5)
.expect("trust-task-error/0.5 is a valid framework Type URI")
}
impl fmt::Display for ErrorResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} [{}]", self.payload, self.id)
}
}
impl StdError for ErrorResponse {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.payload)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JsonLdContext {
Single(String),
Multiple(Vec<Value>),
Object(serde_json::Map<String, Value>),
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct KycHandoff {
subject: String,
result: String,
level: String,
}
#[test]
fn parses_spec_example_one() {
let json = r#"{
"id": "4f3c9e2a-1b81-4d3e-9b51-7a3c89e3d1f2",
"type": "https://trusttasks.org/spec/kyc-handoff/1.0",
"issuer": "did:web:verifier.example",
"recipient": "did:web:bank.example",
"issuedAt": "2026-04-12T09:31:00Z",
"expiresAt": "2027-04-12T09:31:00Z",
"payload": {
"subject": "did:key:z6Mk...",
"result": "passed",
"level": "LOA2"
}
}"#;
let doc: TrustTask<KycHandoff> = serde_json::from_str(json).unwrap();
assert_eq!(doc.id, "4f3c9e2a-1b81-4d3e-9b51-7a3c89e3d1f2");
assert_eq!(doc.type_uri.slug(), "kyc-handoff");
assert_eq!(doc.issuer.as_deref(), Some("did:web:verifier.example"));
assert_eq!(doc.payload.level, "LOA2");
assert!(doc.thread_id.is_none());
assert!(doc.proof.is_none());
assert!(doc.extra.is_empty());
}
#[test]
fn parent_thread_id_is_carried_onto_responses() {
const PARENT: &str = "urn:uuid:9b1d3f60-52a8-4c17-8e44-1d9c7b05f3ae";
let mut req = TrustTask::new(
"req-1",
"https://trusttasks.org/spec/acl/grant/0.1".parse().unwrap(),
serde_json::json!({}),
);
req.thread_id = Some("inner-1".into());
req.parent_thread_id = Some(PARENT.into());
req.issuer = Some("did:web:org.example".into());
req.recipient = Some("did:web:maintainer.example".into());
let ok = req.respond_with("resp-1", serde_json::json!({}));
assert_eq!(ok.parent_thread_id.as_deref(), Some(PARENT));
assert_eq!(ok.thread_id.as_deref(), Some("inner-1"));
let err = req.reject_with(
"err-1",
ErrorPayload::new(crate::TrustTaskCode::from(crate::StandardCode::TaskFailed)),
);
assert_eq!(err.parent_thread_id.as_deref(), Some(PARENT));
}
#[test]
fn parent_thread_id_is_omitted_from_the_wire_when_unset() {
let req = TrustTask::new(
"req-1",
"https://trusttasks.org/spec/acl/grant/0.1".parse().unwrap(),
serde_json::json!({}),
);
let wire = serde_json::to_string(&req).unwrap();
assert!(!wire.contains("parentThreadId"), "wire: {wire}");
}
#[test]
fn parent_thread_id_round_trips_under_its_wire_name() {
let json = serde_json::json!({
"id": "req-1",
"type": "https://trusttasks.org/spec/acl/grant/0.1",
"threadId": "inner-1",
"parentThreadId": "outer-1",
"payload": {}
});
let doc: TrustTask<serde_json::Value> = serde_json::from_value(json).unwrap();
assert_eq!(doc.parent_thread_id.as_deref(), Some("outer-1"));
let back = serde_json::to_value(&doc).unwrap();
assert_eq!(back["parentThreadId"], "outer-1");
}
#[test]
fn round_trips_minimum_document() {
let doc = TrustTask::new(
"abc",
TypeUri::canonical("kyc-handoff", 1, 0).unwrap(),
KycHandoff {
subject: "did:key:z6Mk".to_string(),
result: "passed".to_string(),
level: "LOA2".to_string(),
},
);
let json = serde_json::to_value(&doc).unwrap();
assert!(json.get("threadId").is_none());
assert!(json.get("issuer").is_none());
assert!(json.get("@context").is_none());
assert!(json.get("proof").is_none());
let back: TrustTask<KycHandoff> = serde_json::from_value(json).unwrap();
assert_eq!(back, doc);
}
#[test]
fn preserves_unknown_top_level_members() {
let json = r#"{
"id": "x",
"type": "https://trusttasks.org/spec/kyc-handoff/1.0",
"payload": {"subject":"s","result":"passed","level":"LOA1"},
"x-experimental": "kept"
}"#;
let doc: TrustTask<KycHandoff> = serde_json::from_str(json).unwrap();
assert_eq!(
doc.extra.get("x-experimental").and_then(Value::as_str),
Some("kept")
);
let rendered = serde_json::to_value(&doc).unwrap();
assert_eq!(
rendered.get("x-experimental").and_then(Value::as_str),
Some("kept")
);
}
#[test]
fn detects_expiry() {
let mut doc = TrustTask::new(
"abc",
TypeUri::canonical("kyc-handoff", 1, 0).unwrap(),
serde_json::json!({}),
);
let expiry: DateTime<Utc> = "2026-04-12T09:31:00Z".parse().unwrap();
doc.expires_at = Some(expiry);
let before: DateTime<Utc> = "2026-04-12T09:00:00Z".parse().unwrap();
let after: DateTime<Utc> = "2026-04-12T10:00:00Z".parse().unwrap();
assert!(!doc.is_expired_at(before));
assert!(doc.is_expired_at(after));
assert!(doc.is_expired_at(expiry));
}
}