use std::path::PathBuf;
use vta_cli_common::commands::approvals::render_model;
use vta_sdk::approvals::{DECLARATIVE_POLICY_ID, synthesize_rego, validate};
use crate::cli_store::CliStore;
use crate::config::AppConfig;
use crate::policy::approvals::{DeclarativeModel, declarative_row, load as load_model};
use crate::policy::storage;
type CliResult = Result<(), Box<dyn std::error::Error>>;
async fn policy_ks(
config_path: Option<PathBuf>,
) -> Result<vti_common::store::KeyspaceHandle, Box<dyn std::error::Error>> {
let config = AppConfig::load(config_path)?;
let cs = CliStore::open(&config).await?;
Ok(cs.keyspace(crate::keyspaces::POLICY)?)
}
pub async fn run_list(config_path: Option<PathBuf>) -> CliResult {
list_on(&policy_ks(config_path).await?).await
}
async fn list_on(ks: &vti_common::store::KeyspaceHandle) -> CliResult {
match load_model(ks).await {
Ok(model) => render_model(&model.rules, &model.approver_sets)?,
Err(e) => {
println!(
"The declarative approvals row is present but unreadable: {e}\n\
Its rules cannot be shown, and `vta approvals remove` cannot edit it.\n\
`vta approvals delete-all` will delete the row outright."
);
}
}
let others: Vec<_> = storage::list_policies(ks)
.await?
.into_iter()
.filter(|p| p.id != DECLARATIVE_POLICY_ID)
.collect();
if !others.is_empty() {
println!("\nOther policy modules (hand-authored Rego — `vta policy list` for detail):");
for p in &others {
println!(
" {}{} priority {}",
p.id,
if p.enabled { "" } else { " (disabled)" },
p.priority
);
}
}
Ok(())
}
pub async fn run_remove(
config_path: Option<PathBuf>,
task_type: String,
contexts: Option<Vec<String>>,
) -> CliResult {
remove_on(&policy_ks(config_path).await?, task_type, contexts).await
}
async fn remove_on(
ks: &vti_common::store::KeyspaceHandle,
task_type: String,
contexts: Option<Vec<String>>,
) -> CliResult {
let existing = storage::get_policy(ks, DECLARATIVE_POLICY_ID).await?;
let mut model = load_model(ks).await?;
let before = model.rules.len();
model.rules.retain(|r| {
r.task_type != task_type || contexts.as_ref().is_some_and(|c| &r.contexts != c)
});
if model.rules.len() == before {
return Err(format!(
"no approval rule for {task_type} — run `vta approvals list` to see what is set"
)
.into());
}
write_model(ks, &model, existing.as_ref()).await?;
println!(
"Removed the approval rule for {task_type}. {} rule(s) remain.",
model.rules.len()
);
Ok(())
}
pub async fn run_delete_all(config_path: Option<PathBuf>) -> CliResult {
delete_all_on(&policy_ks(config_path).await?).await
}
async fn delete_all_on(ks: &vti_common::store::KeyspaceHandle) -> CliResult {
if storage::get_policy(ks, DECLARATIVE_POLICY_ID)
.await?
.is_none()
{
println!("No declarative approvals row — nothing to delete.");
return Ok(());
}
let summary = match load_model(ks).await {
Ok(m) => format!(
"{} rule(s) and {} approver set(s) are gone",
m.rules.len(),
m.approver_sets.len()
),
Err(_) => "its contents were unreadable, so there is nothing to summarise".to_string(),
};
storage::delete_policy(ks, DECLARATIVE_POLICY_ID).await?;
println!("Removed the declarative approvals row: {summary}.");
println!(
"Every task now runs on the caller's own authority. Re-declare what you still \
want with `pnm approvals require …` once the VTA is reachable."
);
Ok(())
}
pub async fn run_policy_list(config_path: Option<PathBuf>, show_module: bool) -> CliResult {
let ks = policy_ks(config_path).await?;
let policies = storage::list_policies(&ks).await?;
if vta_cli_common::render::is_json_output() {
println!("{}", serde_json::to_string_pretty(&policies)?);
return Ok(());
}
if policies.is_empty() {
println!("No policy modules stored.");
return Ok(());
}
for p in &policies {
println!(
"{}{} priority {} v{}",
p.id,
if p.enabled { "" } else { " (disabled)" },
p.priority,
p.version
);
if let Some(d) = &p.description {
println!(" {d}");
}
if !p.applies_to.is_empty() {
println!(" contexts {}", p.applies_to.join(", "));
}
if show_module {
for line in p.module.lines() {
println!(" | {line}");
}
}
}
Ok(())
}
pub async fn run_policy_delete(config_path: Option<PathBuf>, id: String) -> CliResult {
if id == DECLARATIVE_POLICY_ID {
return Err(format!(
"`{id}` is the declarative approvals row, not a hand-authored module — use \
`vta approvals remove <task-uri>` to drop one rule, or `vta approvals delete-all` \
to drop the row and every approver set with it"
)
.into());
}
policy_delete_on(&policy_ks(config_path).await?, id).await
}
async fn policy_delete_on(ks: &vti_common::store::KeyspaceHandle, id: String) -> CliResult {
if storage::get_policy(ks, &id).await?.is_none() {
return Err(format!("no policy module `{id}` — run `vta policy list` to see them").into());
}
storage::delete_policy(ks, &id).await?;
println!("Deleted policy module `{id}`.");
Ok(())
}
async fn write_model(
ks: &vti_common::store::KeyspaceHandle,
model: &DeclarativeModel,
existing: Option<&crate::policy::types::PolicyModule>,
) -> Result<(), Box<dyn std::error::Error>> {
validate(&model.rules, &model.approver_sets)
.map_err(|e| format!("the resulting rules are invalid: {e}"))?;
let now = chrono::Utc::now().to_rfc3339();
let created_at = existing.map(|p| p.created_at.as_str()).unwrap_or(&now);
let version = existing.map(|p| p.version.saturating_add(1)).unwrap_or(1);
let row = declarative_row(model, version, &now, created_at);
crate::policy::engine::compile(&row.module, DECLARATIVE_POLICY_ID)
.map_err(|e| format!("the regenerated policy module does not compile: {e}"))?;
debug_assert_eq!(row.module, synthesize_rego(&model.rules));
storage::store_policy(ks, &row).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use vta_sdk::approvals::ApprovalRule;
use vti_common::config::StoreConfig;
use vti_common::store::{KeyspaceHandle, Store};
const POLICY_UPSERT: &str = "https://trusttasks.org/spec/policy/upsert/0.1";
const ACL_GRANT: &str = "https://trusttasks.org/spec/acl/grant/0.1";
async fn ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store.keyspace(crate::keyspaces::POLICY).expect("keyspace");
(ks, dir)
}
async fn seed(ks: &KeyspaceHandle, rules: Vec<ApprovalRule>, sets: &[(&str, &[&str])]) {
let model = DeclarativeModel {
rules,
approver_sets: sets
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
)
})
.collect(),
};
let row = declarative_row(&model, 1, "2026-08-10T00:00:00Z", "2026-08-10T00:00:00Z");
storage::store_policy(ks, &row).await.expect("seed");
}
#[tokio::test]
async fn removing_the_rule_that_gates_the_gate() {
let (ks, _d) = ks().await;
seed(
&ks,
vec![
ApprovalRule::consent(POLICY_UPSERT, "ops"),
ApprovalRule::reauth(ACL_GRANT),
],
&[("ops", &["did:key:zGoneForever"])],
)
.await;
remove_on(&ks, POLICY_UPSERT.to_string(), None)
.await
.expect("the wedging rule comes out");
let after = load_model(&ks).await.expect("row still readable");
assert!(
after.rule_for(POLICY_UPSERT, "default").is_none(),
"the rule that gated policy/upsert must be gone"
);
assert!(
after.rule_for(ACL_GRANT, "default").is_some(),
"the surgical fix must leave every other control standing"
);
assert!(
after.approver_sets.contains_key("ops"),
"approver sets survive a rule removal — they are not what wedged us"
);
let row = storage::get_policy(&ks, DECLARATIVE_POLICY_ID)
.await
.unwrap()
.expect("row still present");
assert_eq!(row.module, synthesize_rego(&after.rules));
assert_eq!(row.version, 2, "an offline edit must advance the version");
assert_eq!(
row.created_at, "2026-08-10T00:00:00Z",
"created_at belongs to the row, not to this edit"
);
assert!(
crate::policy::engine::compile(&row.module, DECLARATIVE_POLICY_ID).is_ok(),
"a module that will not compile is skipped at load — which would \
silently un-gate every rule it still names"
);
}
#[tokio::test]
async fn delete_all_drops_the_row_and_its_sets() {
let (ks, _d) = ks().await;
seed(
&ks,
vec![ApprovalRule::consent(POLICY_UPSERT, "ops")],
&[("ops", &["did:key:zApprover"])],
)
.await;
delete_all_on(&ks).await.expect("delete-all");
assert!(
storage::get_policy(&ks, DECLARATIVE_POLICY_ID)
.await
.unwrap()
.is_none()
);
let after = load_model(&ks).await.expect("a missing row reads as empty");
assert!(after.rules.is_empty());
assert!(after.approver_sets.is_empty());
}
#[tokio::test]
async fn removing_the_last_rule_leaves_a_usable_row() {
let (ks, _d) = ks().await;
seed(&ks, vec![ApprovalRule::reauth(ACL_GRANT)], &[]).await;
remove_on(&ks, ACL_GRANT.to_string(), None).await.unwrap();
let row = storage::get_policy(&ks, DECLARATIVE_POLICY_ID)
.await
.unwrap()
.expect("the row survives its last rule");
assert!(crate::policy::engine::compile(&row.module, DECLARATIVE_POLICY_ID).is_ok());
assert!(load_model(&ks).await.unwrap().rules.is_empty());
}
#[tokio::test]
async fn a_scoped_removal_leaves_the_unscoped_rule() {
let (ks, _d) = ks().await;
let mut scoped = ApprovalRule::reauth(ACL_GRANT);
scoped.contexts = vec!["acme".into()];
seed(&ks, vec![ApprovalRule::reauth(ACL_GRANT), scoped], &[]).await;
remove_on(&ks, ACL_GRANT.to_string(), Some(vec!["acme".into()]))
.await
.unwrap();
let after = load_model(&ks).await.unwrap();
assert_eq!(after.rules.len(), 1);
assert!(
after.rules[0].contexts.is_empty(),
"the unscoped rule must survive a scoped removal"
);
}
#[tokio::test]
async fn removing_an_absent_rule_is_an_error() {
let (ks, _d) = ks().await;
seed(&ks, vec![ApprovalRule::reauth(ACL_GRANT)], &[]).await;
let err = remove_on(&ks, POLICY_UPSERT.to_string(), None)
.await
.expect_err("no such rule");
assert!(
err.to_string().contains("vta approvals list"),
"the error should point at the command that shows what IS set, got: {err}"
);
assert_eq!(
load_model(&ks).await.unwrap().rules.len(),
1,
"a failed removal must not have written anything"
);
}
#[tokio::test]
async fn policy_delete_removes_a_hand_authored_module_only() {
let (ks, _d) = ks().await;
seed(&ks, vec![ApprovalRule::reauth(ACL_GRANT)], &[]).await;
storage::store_policy(
&ks,
&crate::policy::types::PolicyModule {
id: "operator-deny-all".into(),
name: "deny all".into(),
description: None,
module: "package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"deny\"}"
.into(),
applies_to: vec![],
priority: 500,
enabled: true,
version: 1,
created_at: "2026-08-10T00:00:00Z".into(),
updated_at: "2026-08-10T00:00:00Z".into(),
ext: serde_json::Value::Null,
},
)
.await
.unwrap();
policy_delete_on(&ks, "operator-deny-all".into())
.await
.expect("the deny-all module comes out");
assert!(
storage::get_policy(&ks, "operator-deny-all")
.await
.unwrap()
.is_none()
);
assert!(
storage::get_policy(&ks, DECLARATIVE_POLICY_ID)
.await
.unwrap()
.is_some(),
"the declarative row is not this command's business"
);
}
#[tokio::test]
async fn an_unparseable_row_can_still_be_seen_and_cleared() {
let (ks, _d) = ks().await;
storage::store_policy(
&ks,
&crate::policy::types::PolicyModule {
id: DECLARATIVE_POLICY_ID.into(),
name: "corrupt".into(),
description: None,
module: "package vta.policy".into(),
applies_to: vec![],
priority: 100,
enabled: true,
version: 1,
created_at: "2026-08-10T00:00:00Z".into(),
updated_at: "2026-08-10T00:00:00Z".into(),
ext: serde_json::json!({ "openvtc.approvals": "not-an-object" }),
},
)
.await
.unwrap();
list_on(&ks)
.await
.expect("list must report an unreadable row, not fail on it");
delete_all_on(&ks)
.await
.expect("delete-all clears a corrupt row");
assert!(
storage::get_policy(&ks, DECLARATIVE_POLICY_ID)
.await
.unwrap()
.is_none()
);
}
}