use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use tracing::{info, warn};
use trust_tasks_rs::specs::vtc::members::authority_reduced_notice::v0_1 as notice;
use vti_common::capability_client::build_document;
use crate::acl::VtcAclEntry;
use crate::acl::admin_consent::Agreement;
use crate::error::AppError;
use crate::server::AppState;
pub const NOTICE_TYPE: &str = <notice::Payload as trust_tasks_rs::Payload>::TYPE_URI;
#[must_use]
pub fn code_for(prior: &VtcAclEntry, after: Option<&VtcAclEntry>) -> &'static str {
match after {
None => "revoked",
Some(a)
if crate::routes::acl::role_string(a) != crate::routes::acl::role_string(prior)
|| a.role != prior.role =>
{
"demoted"
}
Some(_) => "narrowed",
}
}
pub async fn send(
state: &AppState,
prior: &VtcAclEntry,
after: Option<&VtcAclEntry>,
agreement: Agreement,
decided_by: &str,
decided_at: DateTime<Utc>,
reason: Option<&str>,
) {
if let Err(e) = try_send(
state, prior, after, agreement, decided_by, decided_at, reason,
)
.await
{
warn!(
error = %e,
subject = %prior.did,
decided_by,
"authority-reduced notice could not be queued"
);
}
}
async fn try_send(
state: &AppState,
prior: &VtcAclEntry,
after: Option<&VtcAclEntry>,
agreement: Agreement,
decided_by: &str,
decided_at: DateTime<Utc>,
reason: Option<&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 payload = notice_payload(prior, after, agreement, decided_by, decided_at, reason)?;
#[cfg(any(test, feature = "test-support"))]
record_for_test(&prior.did, &payload);
let doc = build_document(&vtc_did, &prior.did, NOTICE_TYPE, payload);
let mut doc_value = serde_json::to_value(&doc)
.map_err(|e| AppError::Internal(format!("serialise authority-reduced notice: {e}")))?;
signer.sign_operational_doc(&mut doc_value).await?;
let deliver_by = if after.is_none() {
crate::server::REMOVAL_NOTICE_DELIVER_BY
} else {
crate::server::EXCHANGE_DELIVER_BY
};
crate::member_push::push_trust_task(state, &prior.did, doc_value, deliver_by).await?;
info!(
subject = %prior.did,
decided_by,
code = code_for(prior, after),
agreement = agreement.wire(),
"authority-reduced notice queued"
);
Ok(())
}
#[cfg(any(test, feature = "test-support"))]
static SENT: std::sync::LazyLock<std::sync::Mutex<Vec<(String, Value)>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(Vec::new()));
#[cfg(any(test, feature = "test-support"))]
fn record_for_test(subject: &str, payload: &Value) {
SENT.lock()
.unwrap_or_else(|p| p.into_inner())
.push((subject.to_string(), payload.clone()));
}
#[cfg(any(test, feature = "test-support"))]
pub fn sent_for_test(subject: &str) -> Vec<Value> {
SENT.lock()
.unwrap_or_else(|p| p.into_inner())
.iter()
.filter(|(s, _)| s == subject)
.map(|(_, p)| p.clone())
.collect()
}
pub(crate) fn notice_payload(
prior: &VtcAclEntry,
after: Option<&VtcAclEntry>,
agreement: Agreement,
decided_by: &str,
decided_at: DateTime<Utc>,
reason: Option<&str>,
) -> Result<Value, AppError> {
let mut v = json!({
"did": prior.did,
"code": code_for(prior, after),
"previousRole": crate::routes::acl::role_string(prior),
"agreement": agreement.wire(),
"decidedAt": decided_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"decidedBy": decided_by,
});
if let Some(a) = after {
v["resultingRole"] = json!(crate::routes::acl::role_string(a));
}
if let Some(r) = reason.filter(|r| !r.trim().is_empty()) {
v["reason"] = json!(r);
}
let _checked: notice::Payload = serde_json::from_value(v.clone()).map_err(|e| {
AppError::Internal(format!(
"authority-reduced notice payload rejected by its own schema: {e}"
))
})?;
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::VtcRole;
fn entry(role: VtcRole, scopes: &[&str]) -> VtcAclEntry {
VtcAclEntry {
did: "did:key:zCarol".into(),
role: role.clone(),
label: None,
admin: crate::acl::legacy_seed_authority(&role, scopes),
delegated_by: None,
created_at: 0,
created_by: "did:key:zDana".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
}
}
fn at() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-10-03T09:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn each_code_follows_from_the_entry_before_and_after() {
let admin = entry(VtcRole::Admin, &[]);
assert_eq!(code_for(&admin, None), "revoked");
assert_eq!(
code_for(&admin, Some(&entry(VtcRole::Member, &[]))),
"demoted"
);
let mut narrower = entry(VtcRole::Admin, &[]);
narrower.admin.capabilities = crate::acl::CapabilityScope::Listed {
grants: vec![crate::acl::CapabilityGrant {
capability: crate::acl::Capability::AuditRead,
resource: None,
additive: false,
}],
};
assert_eq!(code_for(&admin, Some(&narrower)), "narrowed");
}
#[test]
fn the_payload_validates_against_the_published_schema() {
use trust_tasks_rs::validate::ValidatedPayload;
let admin = entry(VtcRole::Admin, &[]);
let member = entry(VtcRole::Member, &[]);
for (after, agreement, reason) in [
(None, Agreement::Unopposed, Some("compromised")),
(Some(&member), Agreement::Consented, None),
(Some(&admin), Agreement::StepUpOnly, Some(" ")),
] {
let p = notice_payload(&admin, after, agreement, "did:key:zDana", at(), reason)
.expect("payload builds");
notice::Payload::validate_value(&p)
.unwrap_or_else(|e| panic!("payload rejected by its own schema: {e}\n{p:#}"));
assert_eq!(p.get("resultingRole").is_none(), after.is_none());
}
let p = notice_payload(
&admin,
None,
Agreement::StepUpOnly,
"did:key:zDana",
at(),
None,
)
.unwrap();
assert_eq!(p["agreement"], "unopposed");
assert!(p.get("reason").is_none());
}
}