use tracing::{info, warn};
use vta_sdk::protocols::members::{MEMBER_REMOVAL_NOTICE_TYPE, RemovalCode, RemovalNoticeBody};
use vti_common::capability_client::build_document;
use crate::error::AppError;
use crate::server::AppState;
pub async fn send(
state: &AppState,
target_did: &str,
code: RemovalCode,
disposition: &str,
reason: Option<String>,
decided_at: &str,
decided_by: &str,
) {
if let Err(e) = try_send(
state,
target_did,
code,
disposition,
reason,
decided_at,
decided_by,
)
.await
{
warn!(
error = %e,
target = target_did,
?code,
"removal notice could not be queued — the member was removed without being told"
);
}
}
async fn try_send(
state: &AppState,
target_did: &str,
code: RemovalCode,
disposition: &str,
reason: Option<String>,
decided_at: &str,
decided_by: &str,
) -> Result<(), AppError> {
let vtc_did = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| AppError::Internal("VTC DID not configured".into()))?;
let signer = state
.credential_signer
.as_ref()
.ok_or_else(|| AppError::Internal("credential signer not configured".into()))?;
let body = RemovalNoticeBody {
did: target_did.to_string(),
code,
disposition: disposition.to_string(),
reason: reason.filter(|r| !r.trim().is_empty()),
decided_at: decided_at.to_string(),
decided_by: decided_by.to_string(),
};
let payload = serde_json::to_value(&body)
.map_err(|e| AppError::Internal(format!("serialise removal notice: {e}")))?;
let doc = build_document(&vtc_did, target_did, MEMBER_REMOVAL_NOTICE_TYPE, payload);
let mut doc_value = serde_json::to_value(&doc)
.map_err(|e| AppError::Internal(format!("serialise removal-notice document: {e}")))?;
signer.sign_operational_doc(&mut doc_value).await?;
crate::member_push::push_trust_task(
state,
target_did,
doc_value,
crate::server::REMOVAL_NOTICE_DELIVER_BY,
)
.await?;
info!(
target = target_did,
?code,
disposition,
"removal notice queued"
);
Ok(())
}
#[cfg(test)]
fn notice_payload(
target_did: &str,
code: RemovalCode,
disposition: &str,
reason: Option<String>,
decided_at: &str,
decided_by: &str,
) -> serde_json::Value {
serde_json::to_value(RemovalNoticeBody {
did: target_did.to_string(),
code,
disposition: disposition.to_string(),
reason: reason.filter(|r| !r.trim().is_empty()),
decided_at: decided_at.to_string(),
decided_by: decided_by.to_string(),
})
.expect("RemovalNoticeBody serialises")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn payload_carries_every_question_a_removed_member_has() {
let p = notice_payload(
"did:key:zRemoved",
RemovalCode::AdminRemoved,
"tombstone",
Some("Repeated code-of-conduct breach.".into()),
"2026-08-23T09:14:02Z",
"did:key:zAdmin",
);
assert_eq!(
p,
json!({
"did": "did:key:zRemoved",
"code": "adminRemoved",
"disposition": "tombstone",
"reason": "Repeated code-of-conduct breach.",
"decidedAt": "2026-08-23T09:14:02Z",
"decidedBy": "did:key:zAdmin"
}),
"what happened, to what, when, and on whose say-so — camelCase per SPEC §4.10"
);
}
#[test]
fn an_absent_or_blank_reason_is_omitted_rather_than_sent_empty() {
for blank in [None, Some(String::new()), Some(" ".into())] {
let p = notice_payload(
"did:key:zRemoved",
RemovalCode::Purged,
"purge",
blank.clone(),
"2026-08-23T11:02:41Z",
"did:key:zSuper",
);
assert!(
p.get("reason").is_none(),
"blank reason {blank:?} must be absent, not an empty string"
);
}
}
#[test]
fn the_two_removal_codes_are_distinguishable_on_the_wire() {
assert_eq!(
serde_json::to_value(RemovalCode::AdminRemoved).unwrap(),
json!("adminRemoved")
);
assert_eq!(
serde_json::to_value(RemovalCode::Purged).unwrap(),
json!("purged")
);
}
#[test]
fn payload_validates_against_the_published_schema() {
use trust_tasks_rs::validate::ValidatedPayload;
type Notice = trust_tasks_rs::specs::vtc::members::removal_notice::v0_1::Payload;
for (code, reason) in [
(RemovalCode::AdminRemoved, Some("because".to_string())),
(RemovalCode::Purged, None),
] {
let p = notice_payload(
"did:key:zRemoved",
code,
"tombstone",
reason,
"2026-08-23T09:14:02Z",
"did:key:zAdmin",
);
Notice::validate_value(&p)
.unwrap_or_else(|e| panic!("payload rejected by its own schema: {e}\n{p:#}"));
}
}
}