use serde_json::{Value as JsonValue, json};
use tracing::warn;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::policy::{
PolicyPurpose, compile as compile_policy, evaluate as evaluate_policy, get_active_policy_id,
get_policy,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PublishOnJoinDecision {
PublishOnJoin,
SkipPublishOnJoin,
}
pub async fn evaluate_publish_on_join(
policies_ks: &KeyspaceHandle,
active_policies_ks: &KeyspaceHandle,
) -> Result<PublishOnJoinDecision, AppError> {
let Some(id) = get_active_policy_id(active_policies_ks, PolicyPurpose::Registry).await? else {
warn!("no active registry.rego — defaulting to publish_on_join=true");
return Ok(PublishOnJoinDecision::PublishOnJoin);
};
let policy = get_policy(policies_ks, id)
.await?
.ok_or_else(|| AppError::Internal(format!("active registry policy {id} not found")))?;
let compiled = compile_policy(&policy.rego_source, policy.id)?;
let result = evaluate_policy(
&compiled,
"data.vtc.registry.publish_on_join",
JsonValue::Object(Default::default()),
)?;
let publish = result
.pointer("/result/0/expressions/0/value")
.and_then(|v| v.as_bool())
.unwrap_or(true);
Ok(if publish {
PublishOnJoinDecision::PublishOnJoin
} else {
PublishOnJoinDecision::SkipPublishOnJoin
})
}
pub async fn read_min_disposition(
policies_ks: &KeyspaceHandle,
active_policies_ks: &KeyspaceHandle,
) -> Option<String> {
let active_id = get_active_policy_id(active_policies_ks, PolicyPurpose::Registry)
.await
.ok()
.flatten()?;
let policy = get_policy(policies_ks, active_id).await.ok().flatten()?;
let compiled = compile_policy(&policy.rego_source, policy.id).ok()?;
let result = evaluate_policy(
&compiled,
"data.vtc.registry.min_disposition",
JsonValue::Object(Default::default()),
)
.ok()?;
result
.pointer("/result/0/expressions/0/value")
.and_then(|v| v.as_str())
.map(str::to_string)
}
fn severity(s: &str) -> u8 {
match s {
"historical" => 3,
"tombstone" => 2,
"purge" => 1,
_ => 1,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClampOutcome {
pub effective: String,
pub clamped: bool,
pub min_floor: Option<String>,
}
pub fn clamp_disposition(requested: &str, floor: Option<&str>) -> ClampOutcome {
let Some(floor) = floor else {
return ClampOutcome {
effective: requested.to_string(),
clamped: false,
min_floor: None,
};
};
if severity(requested) >= severity(floor) {
ClampOutcome {
effective: requested.to_string(),
clamped: false,
min_floor: Some(floor.to_string()),
}
} else {
ClampOutcome {
effective: floor.to_string(),
clamped: true,
min_floor: Some(floor.to_string()),
}
}
}
pub fn is_rtbf_purge(actor_did: &str, target_did: &str, disposition: &str) -> bool {
actor_did == target_did && disposition == "purge"
}
pub fn registry_input(member_did: &str, action: &str) -> JsonValue {
json!({
"member": { "did": member_did },
"action": action,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::{Policy, PolicyPurpose, set_active_policy_id, store_policy};
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn temp_keyspaces() -> (KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let policies = store.keyspace("policies").unwrap();
let active = store.keyspace("active_policies").unwrap();
(policies, active, dir)
}
async fn install_registry_policy(
policies: &KeyspaceHandle,
active: &KeyspaceHandle,
source: &str,
) {
use sha2::{Digest, Sha256};
let sha: [u8; 32] = Sha256::digest(source.as_bytes()).into();
let id = uuid::Uuid::new_v4();
let policy = Policy {
id,
purpose: PolicyPurpose::Registry,
rego_source: source.into(),
sha256: sha,
activated_at: Some(chrono::Utc::now()),
author_did: "did:key:test".into(),
created_at: chrono::Utc::now(),
version: 1,
};
store_policy(policies, &policy).await.unwrap();
set_active_policy_id(active, PolicyPurpose::Registry, id)
.await
.unwrap();
}
#[tokio::test]
async fn publish_on_join_defaults_to_publish_when_no_policy() {
let (policies, active, _dir) = temp_keyspaces().await;
let outcome = evaluate_publish_on_join(&policies, &active).await.unwrap();
assert_eq!(outcome, PublishOnJoinDecision::PublishOnJoin);
}
#[tokio::test]
async fn publish_on_join_reads_true_from_default_policy() {
let (policies, active, _dir) = temp_keyspaces().await;
let src = "\
package vtc.registry
import rego.v1
default publish_on_join := true
";
install_registry_policy(&policies, &active, src).await;
let outcome = evaluate_publish_on_join(&policies, &active).await.unwrap();
assert_eq!(outcome, PublishOnJoinDecision::PublishOnJoin);
}
#[tokio::test]
async fn publish_on_join_returns_skip_when_policy_says_false() {
let (policies, active, _dir) = temp_keyspaces().await;
let src = "\
package vtc.registry
import rego.v1
default publish_on_join := false
";
install_registry_policy(&policies, &active, src).await;
let outcome = evaluate_publish_on_join(&policies, &active).await.unwrap();
assert_eq!(outcome, PublishOnJoinDecision::SkipPublishOnJoin);
}
#[tokio::test]
async fn min_disposition_returns_none_when_no_policy() {
let (policies, active, _dir) = temp_keyspaces().await;
let got = read_min_disposition(&policies, &active).await;
assert!(got.is_none());
}
#[tokio::test]
async fn min_disposition_reads_from_policy() {
let (policies, active, _dir) = temp_keyspaces().await;
let src = "\
package vtc.registry
import rego.v1
default min_disposition := \"tombstone\"
";
install_registry_policy(&policies, &active, src).await;
let got = read_min_disposition(&policies, &active).await;
assert_eq!(got.as_deref(), Some("tombstone"));
}
#[test]
fn clamp_disposition_below_floor_bumps_up_to_floor() {
let out = clamp_disposition("purge", Some("tombstone"));
assert_eq!(out.effective, "tombstone");
assert!(out.clamped);
let out = clamp_disposition("purge", Some("historical"));
assert_eq!(out.effective, "historical");
assert!(out.clamped);
let out = clamp_disposition("tombstone", Some("historical"));
assert_eq!(out.effective, "historical");
assert!(out.clamped);
}
#[test]
fn clamp_disposition_at_or_above_floor_passes_through() {
let out = clamp_disposition("tombstone", Some("tombstone"));
assert_eq!(out.effective, "tombstone");
assert!(!out.clamped);
let out = clamp_disposition("historical", Some("tombstone"));
assert_eq!(out.effective, "historical");
assert!(!out.clamped);
let out = clamp_disposition("purge", Some("purge"));
assert_eq!(out.effective, "purge");
assert!(!out.clamped);
let out = clamp_disposition("tombstone", Some("purge"));
assert_eq!(out.effective, "tombstone");
assert!(!out.clamped);
}
#[test]
fn clamp_with_no_floor_returns_requested_verbatim() {
let out = clamp_disposition("purge", None);
assert_eq!(out.effective, "purge");
assert!(!out.clamped);
assert!(out.min_floor.is_none());
}
#[test]
fn is_rtbf_purge_detects_self_purge() {
assert!(is_rtbf_purge("did:key:zMember", "did:key:zMember", "purge"));
}
#[test]
fn is_rtbf_purge_rejects_admin_force_purge() {
assert!(!is_rtbf_purge("did:key:zAdmin", "did:key:zMember", "purge"));
}
#[test]
fn is_rtbf_purge_rejects_non_purge_dispositions() {
assert!(!is_rtbf_purge(
"did:key:zMember",
"did:key:zMember",
"tombstone"
));
assert!(!is_rtbf_purge(
"did:key:zMember",
"did:key:zMember",
"historical"
));
}
}