use serde_json::{Map, Value as JsonValue};
use vti_common::error::AppError;
use super::facts::Purpose;
use super::verdict::Verdict;
use super::verify::VerifiedFacts;
#[derive(Debug, Clone, PartialEq)]
pub enum EffectPlan {
Project { fields: Map<String, JsonValue> },
Admit {
subject: String,
role: String,
obligations: Vec<String>,
},
Depart {
subject: String,
disposition: Option<String>,
},
Remint { subject: String, role: String },
NoStateChange,
}
pub fn plan(
verified: &VerifiedFacts,
verdict: &Verdict,
pii_whitelist: &[String],
) -> Result<EffectPlan, AppError> {
let Verdict::Allow(allow) = verdict else {
return Ok(EffectPlan::NoStateChange);
};
let facts = verified.facts();
let subject = facts.subject.did.clone();
match facts.purpose {
Purpose::Directory => Ok(EffectPlan::Project {
fields: project_fields(
verified,
allow.fields.as_deref().unwrap_or(&[]),
pii_whitelist,
),
}),
Purpose::Join => Ok(EffectPlan::Admit {
subject,
role: required_role(allow.role.as_deref(), "join")?,
obligations: allow.obligations.clone(),
}),
Purpose::Leave => Ok(EffectPlan::Depart {
subject,
disposition: allow.disposition.clone(),
}),
Purpose::RoleChange => Ok(EffectPlan::Remint {
subject,
role: required_role(allow.role.as_deref(), "role-change")?,
}),
}
}
fn required_role(role: Option<&str>, purpose: &str) -> Result<String, AppError> {
role.map(str::to_string).ok_or_else(|| {
AppError::Internal(format!("{purpose} allow is missing the required `role`"))
})
}
fn project_fields(
verified: &VerifiedFacts,
requested: &[String],
whitelist: &[String],
) -> Map<String, JsonValue> {
let facts = verified.facts();
let mut source = facts
.state
.subject_member
.as_ref()
.and_then(|m| serde_json::to_value(m).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
source.insert("did".into(), JsonValue::String(facts.subject.did.clone()));
requested
.iter()
.filter(|f| whitelist.iter().any(|w| w == *f))
.filter_map(|f| source.get(f).map(|v| (f.clone(), v.clone())))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ceremony::facts::{Actor, Context, Evidence, Facts, MemberState, State, Subject};
use crate::ceremony::verdict::Allow;
use serde_json::json;
fn facts(purpose: Purpose, member: Option<MemberState>) -> Facts {
Facts {
purpose,
now: "2026-05-30T12:00:00Z".parse().unwrap(),
actor: Actor {
did: "did:key:zViewer".into(),
role: Some("member".into()),
authenticated: true,
},
subject: Subject {
did: "did:key:zTarget".into(),
},
context: Context {
community_did: "did:webvh:acme.example".into(),
channel: "rest".into(),
member_count: 10,
},
evidence: Evidence::default(),
state: State {
subject_member: member,
},
}
}
fn member() -> MemberState {
MemberState {
role: "member".into(),
status: "active".into(),
joined_at: "2026-03-03T00:00:00Z".parse().unwrap(),
personhood: None,
}
}
fn verified(facts: Facts) -> VerifiedFacts {
VerifiedFacts::assemble(facts).expect("verified")
}
fn allow(a: Allow) -> Verdict {
Verdict::Allow(a)
}
#[test]
fn deny_plans_no_state_change() {
let v = verified(facts(Purpose::Join, None));
let verdict = Verdict::default_deny();
assert_eq!(plan(&v, &verdict, &[]).unwrap(), EffectPlan::NoStateChange);
}
#[test]
fn join_allow_plans_admit() {
let v = verified(facts(Purpose::Join, None));
let verdict = allow(Allow {
role: Some("member".into()),
obligations: vec!["reciprocate_vmc".into()],
..Default::default()
});
assert_eq!(
plan(&v, &verdict, &[]).unwrap(),
EffectPlan::Admit {
subject: "did:key:zTarget".into(),
role: "member".into(),
obligations: vec!["reciprocate_vmc".into()],
}
);
}
#[test]
fn join_allow_without_role_errors() {
let v = verified(facts(Purpose::Join, None));
let verdict = allow(Allow::default());
let err = plan(&v, &verdict, &[]).expect_err("join needs a role");
assert!(matches!(err, AppError::Internal(_)), "got {err:?}");
}
#[test]
fn leave_allow_plans_depart() {
let v = verified(facts(Purpose::Leave, Some(member())));
let verdict = allow(Allow {
disposition: Some("revoke-vmc".into()),
..Default::default()
});
assert_eq!(
plan(&v, &verdict, &[]).unwrap(),
EffectPlan::Depart {
subject: "did:key:zTarget".into(),
disposition: Some("revoke-vmc".into()),
}
);
}
#[test]
fn directory_projection_intersects_fields_with_whitelist() {
let v = verified(facts(Purpose::Directory, Some(member())));
let verdict = allow(Allow {
fields: Some(vec![
"did".into(),
"role".into(),
"joined_at".into(),
"status".into(),
]),
..Default::default()
});
let whitelist = vec!["did".to_string(), "role".to_string()];
match plan(&v, &verdict, &whitelist).unwrap() {
EffectPlan::Project { fields } => {
let got = JsonValue::Object(fields);
assert_eq!(
got,
json!({ "did": "did:key:zTarget", "role": "member" }),
"status + joined_at must be dropped by the PII boundary",
);
}
other => panic!("expected Project, got {other:?}"),
}
}
#[test]
fn directory_projection_omits_absent_facts() {
let v = verified(facts(Purpose::Directory, Some(member())));
let verdict = allow(Allow {
fields: Some(vec!["did".into(), "extensions".into()]),
..Default::default()
});
let whitelist = vec!["did".to_string(), "extensions".to_string()];
match plan(&v, &verdict, &whitelist).unwrap() {
EffectPlan::Project { fields } => {
assert_eq!(
JsonValue::Object(fields),
json!({ "did": "did:key:zTarget" })
);
}
other => panic!("expected Project, got {other:?}"),
}
}
#[test]
fn role_change_allow_plans_remint() {
let v = verified(facts(Purpose::RoleChange, Some(member())));
let verdict = allow(Allow {
role: Some("moderator".into()),
..Default::default()
});
assert_eq!(
plan(&v, &verdict, &[]).unwrap(),
EffectPlan::Remint {
subject: "did:key:zTarget".into(),
role: "moderator".into(),
}
);
}
}