use serde::{Deserialize, Serialize};
use crate::models::{GovernedAction, namespace::GovernanceLevel};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GovernanceRefusal {
pub action: GovernedAction,
pub denied_level: GovernanceLevel,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
pub reason: String,
}
impl GovernanceRefusal {
#[must_use]
pub fn new(
action: GovernedAction,
denied_level: GovernanceLevel,
agent_id: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
action,
denied_level,
agent_id: agent_id.into(),
namespace: None,
owner: None,
reason: reason.into(),
}
}
#[must_use]
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
#[must_use]
pub fn with_owner(mut self, owner: impl Into<String>) -> Self {
self.owner = Some(owner.into());
self
}
}
impl std::fmt::Display for GovernanceRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = crate::governance::deny_message(
self.action.as_str(),
crate::governance::DenyGate::Governance,
&self.reason,
);
f.write_str(&msg)
}
}
impl std::error::Error for GovernanceRefusal {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_matches_canonical_deny_message_shape() {
let r = GovernanceRefusal::new(
GovernedAction::Store,
GovernanceLevel::Owner,
"ai:bob",
"caller 'ai:bob' is not the owner ('ai:alice')",
);
assert_eq!(
r.to_string(),
"store denied by governance: caller 'ai:bob' is not the owner ('ai:alice')",
);
}
#[test]
fn display_starts_with_canonical_deny_prefix() {
let r = GovernanceRefusal::new(
GovernedAction::Delete,
GovernanceLevel::Registered,
"anon:x",
"not a registered agent",
);
let s = r.to_string();
assert!(
s.contains("denied by governance"),
"canonical prefix missing: {s}",
);
assert!(s.starts_with("delete"), "action verb missing: {s}");
}
#[test]
fn builder_records_namespace_and_owner() {
let r = GovernanceRefusal::new(
GovernedAction::Promote,
GovernanceLevel::Owner,
"ai:bob",
"caller 'ai:bob' is not the owner ('ai:alice')",
)
.with_namespace("team/prod")
.with_owner("ai:alice");
assert_eq!(r.namespace.as_deref(), Some("team/prod"));
assert_eq!(r.owner.as_deref(), Some("ai:alice"));
assert_eq!(r.agent_id, "ai:bob");
assert_eq!(r.denied_level, GovernanceLevel::Owner);
}
#[test]
fn serde_roundtrip_preserves_all_fields() {
let r = GovernanceRefusal::new(
GovernedAction::Store,
GovernanceLevel::Owner,
"ai:bob",
"owner-level refusal",
)
.with_namespace("ns")
.with_owner("ai:alice");
let json = serde_json::to_string(&r).expect("ser");
let back: GovernanceRefusal = serde_json::from_str(&json).expect("deser");
assert_eq!(back, r);
}
#[test]
fn serde_skips_none_optional_fields_for_compact_wire() {
let r = GovernanceRefusal::new(
GovernedAction::Reflect,
GovernanceLevel::Any,
"ai:x",
"trivially allowed in this fixture",
);
let json = serde_json::to_string(&r).expect("ser");
assert!(!json.contains("namespace"));
assert!(!json.contains("owner"));
}
#[test]
fn error_trait_impl_allows_anyhow_chain() {
let r = GovernanceRefusal::new(
GovernedAction::Delete,
GovernanceLevel::Owner,
"ai:x",
"not the owner",
);
let any: anyhow::Error = anyhow::Error::new(r.clone());
let back = any
.downcast_ref::<GovernanceRefusal>()
.expect("typed refusal must survive anyhow round-trip");
assert_eq!(back, &r);
}
}