use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Purpose {
Join,
Leave,
RoleChange,
Directory,
}
impl Purpose {
pub fn as_str(self) -> &'static str {
match self {
Purpose::Join => "join",
Purpose::Leave => "leave",
Purpose::RoleChange => "role-change",
Purpose::Directory => "directory",
}
}
pub fn rego_package(self) -> &'static str {
match self {
Purpose::Join => "vtc.join",
Purpose::Leave => "vtc.removal",
Purpose::RoleChange => "vtc.role_change",
Purpose::Directory => "vtc.directory",
}
}
pub fn decision_query(self) -> String {
format!("data.{}.decision", self.rego_package())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Facts {
pub purpose: Purpose,
pub now: DateTime<Utc>,
pub actor: Actor,
pub subject: Subject,
pub context: Context,
pub evidence: Evidence,
pub state: State,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Actor {
pub did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub authenticated: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Subject {
pub did: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Context {
pub community_did: String,
pub channel: String,
pub member_count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Evidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invitation: Option<Invitation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presentation: Option<Presentation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request: Option<JsonValue>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Invitation {
pub verified: bool,
pub issuer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer_role: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
pub consumed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Presentation {
pub verified: bool,
pub holder: String,
#[serde(default)]
pub credentials: Vec<Credential>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Credential {
#[serde(rename = "type")]
pub credential_type: String,
pub issuer: String,
pub issuer_trusted: bool,
pub status: CredentialStatus,
#[serde(default)]
pub holder_bound: bool,
#[serde(default)]
pub claims: JsonValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CredentialStatus {
Valid,
Revoked,
Suspended,
Unknown,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct State {
pub subject_member: Option<MemberState>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemberState {
pub role: String,
pub status: String,
pub joined_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personhood: Option<JsonValue>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn join_facts_match_design_example() {
let facts = Facts {
purpose: Purpose::Join,
now: "2026-05-30T12:00:00Z".parse().unwrap(),
actor: Actor {
did: "did:key:z6MkHuman".into(),
role: None,
authenticated: true,
},
subject: Subject {
did: "did:key:z6MkHuman".into(),
},
context: Context {
community_did: "did:webvh:acme.example".into(),
channel: "rest".into(),
member_count: 1421,
},
evidence: Evidence {
invitation: None,
presentation: Some(Presentation {
verified: true,
holder: "did:key:z6MkHuman".into(),
credentials: vec![Credential {
credential_type: "WitnessCredential".into(),
issuer: "did:webvh:notary.example".into(),
issuer_trusted: true,
status: CredentialStatus::Valid,
holder_bound: true,
claims: json!({ "kind": "proximity" }),
valid_until: None,
}],
}),
request: Some(json!({ "agreements": {} })),
},
state: State {
subject_member: None,
},
};
let expected = json!({
"purpose": "join",
"now": "2026-05-30T12:00:00Z",
"actor": { "did": "did:key:z6MkHuman", "authenticated": true },
"subject": { "did": "did:key:z6MkHuman" },
"context": { "community_did": "did:webvh:acme.example", "channel": "rest", "member_count": 1421 },
"evidence": {
"presentation": {
"verified": true,
"holder": "did:key:z6MkHuman",
"credentials": [
{ "type": "WitnessCredential", "issuer": "did:webvh:notary.example", "issuer_trusted": true, "status": "valid", "holder_bound": true, "claims": { "kind": "proximity" } }
]
},
"request": { "agreements": {} }
},
"state": { "subject_member": null }
});
assert_eq!(serde_json::to_value(&facts).unwrap(), expected);
let parsed: Facts = serde_json::from_value(expected).unwrap();
assert_eq!(parsed, facts);
}
#[test]
fn directory_facts_omit_unused_evidence_slots() {
let facts = Facts {
purpose: Purpose::Directory,
now: "2026-05-30T12:00:00Z".parse().unwrap(),
actor: Actor {
did: "did:key:z6MkViewer".into(),
role: Some("member".into()),
authenticated: true,
},
subject: Subject {
did: "did:key:z6MkTarget".into(),
},
context: Context {
community_did: "did:webvh:acme.example".into(),
channel: "rest".into(),
member_count: 1421,
},
evidence: Evidence {
invitation: None,
presentation: None,
request: Some(json!({ "fields_requested": ["did", "role", "joined_at"] })),
},
state: State {
subject_member: Some(MemberState {
role: "member".into(),
status: "active".into(),
joined_at: "2026-03-03T00:00:00Z".parse().unwrap(),
personhood: None,
}),
},
};
let wire = serde_json::to_value(&facts).unwrap();
assert!(wire["evidence"].get("invitation").is_none());
assert!(wire["evidence"].get("presentation").is_none());
assert_eq!(wire["actor"]["role"], "member");
assert_eq!(wire["state"]["subject_member"]["role"], "member");
assert_eq!(
wire["evidence"]["request"]["fields_requested"],
json!(["did", "role", "joined_at"])
);
}
#[test]
fn purpose_wire_strings_match_catalog() {
for (purpose, wire) in [
(Purpose::Join, "join"),
(Purpose::Leave, "leave"),
(Purpose::RoleChange, "role-change"),
(Purpose::Directory, "directory"),
] {
assert_eq!(serde_json::to_value(purpose).unwrap(), json!(wire));
assert_eq!(purpose.as_str(), wire);
let parsed: Purpose = serde_json::from_value(json!(wire)).unwrap();
assert_eq!(parsed, purpose);
}
}
#[test]
fn credential_status_is_lowercase() {
assert_eq!(
serde_json::to_value(CredentialStatus::Valid).unwrap(),
json!("valid")
);
assert_eq!(
serde_json::to_value(CredentialStatus::Revoked).unwrap(),
json!("revoked")
);
let parsed: CredentialStatus = serde_json::from_value(json!("unknown")).unwrap();
assert_eq!(parsed, CredentialStatus::Unknown);
}
}