use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::Write;
use std::sync::Arc;
pub mod reason_codes {
pub const P_POLICY_ALLOW: &str = "P_POLICY_ALLOW";
pub const P_POLICY_DENY: &str = "P_POLICY_DENY";
pub const P_TOOL_DENIED: &str = "P_TOOL_DENIED";
pub const P_TOOL_NOT_ALLOWED: &str = "P_TOOL_NOT_ALLOWED";
pub const P_ARG_SCHEMA: &str = "P_ARG_SCHEMA";
pub const P_RATE_LIMIT: &str = "P_RATE_LIMIT";
pub const P_TOOL_DRIFT: &str = "P_TOOL_DRIFT";
pub const P_MANDATE_REQUIRED: &str = "P_MANDATE_REQUIRED";
pub const P_MANDATE_VALID: &str = "P_MANDATE_VALID";
pub const M_EXPIRED: &str = "M_EXPIRED";
pub const M_NOT_YET_VALID: &str = "M_NOT_YET_VALID";
pub const M_NONCE_REPLAY: &str = "M_NONCE_REPLAY";
pub const M_ALREADY_USED: &str = "M_ALREADY_USED";
pub const M_MAX_USES_EXCEEDED: &str = "M_MAX_USES_EXCEEDED";
pub const M_TOOL_NOT_IN_SCOPE: &str = "M_TOOL_NOT_IN_SCOPE";
pub const M_KIND_MISMATCH: &str = "M_KIND_MISMATCH";
pub const M_AUDIENCE_MISMATCH: &str = "M_AUDIENCE_MISMATCH";
pub const M_ISSUER_NOT_TRUSTED: &str = "M_ISSUER_NOT_TRUSTED";
pub const M_TRANSACTION_REF_MISMATCH: &str = "M_TRANSACTION_REF_MISMATCH";
pub const M_NOT_FOUND: &str = "M_NOT_FOUND";
pub const M_REVOKED: &str = "M_REVOKED";
pub const S_DB_ERROR: &str = "S_DB_ERROR";
pub const S_INTERNAL_ERROR: &str = "S_INTERNAL_ERROR";
pub const T_TIMEOUT: &str = "T_TIMEOUT";
pub const T_EXEC_ERROR: &str = "T_EXEC_ERROR";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
Allow,
Deny,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionEvent {
pub specversion: &'static str,
pub id: String,
#[serde(rename = "type")]
pub event_type: &'static str,
pub source: String,
pub time: String,
pub data: DecisionData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionData {
pub tool: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_classes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub matched_tool_classes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_basis: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_rule: Option<String>,
pub decision: Decision,
pub reason_code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mandate_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mandate_scope_match: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mandate_kind_match: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transaction_ref_match: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authz_latency_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store_latency_ms: Option<u64>,
}
impl DecisionEvent {
pub fn new(source: String, tool_call_id: String, tool: String) -> Self {
Self {
specversion: "1.0",
id: format!("evt_decision_{}", uuid::Uuid::new_v4()),
event_type: "assay.tool.decision",
source,
time: chrono::Utc::now().to_rfc3339(),
data: DecisionData {
tool,
tool_classes: Vec::new(),
matched_tool_classes: Vec::new(),
match_basis: None,
matched_rule: None,
decision: Decision::Error, reason_code: reason_codes::S_INTERNAL_ERROR.to_string(),
reason: Some("Decision not finalized (guard dropped without emit)".to_string()),
tool_call_id,
request_id: None,
mandate_id: None,
use_id: None,
use_count: None,
mandate_scope_match: None,
mandate_kind_match: None,
transaction_ref_match: None,
authz_latency_ms: None,
store_latency_ms: None,
},
}
}
pub fn allow(mut self, reason_code: &str) -> Self {
self.data.decision = Decision::Allow;
self.data.reason_code = reason_code.to_string();
self.data.reason = None;
self
}
pub fn deny(mut self, reason_code: &str, reason: Option<String>) -> Self {
self.data.decision = Decision::Deny;
self.data.reason_code = reason_code.to_string();
self.data.reason = reason;
self
}
pub fn error(mut self, reason_code: &str, reason: Option<String>) -> Self {
self.data.decision = Decision::Error;
self.data.reason_code = reason_code.to_string();
self.data.reason = reason;
self
}
pub fn with_request_id(mut self, id: Option<Value>) -> Self {
self.data.request_id = id;
self
}
pub fn with_mandate(
mut self,
mandate_id: Option<String>,
use_id: Option<String>,
use_count: Option<u32>,
) -> Self {
self.data.mandate_id = mandate_id;
self.data.use_id = use_id;
self.data.use_count = use_count;
self
}
pub fn with_mandate_matches(
mut self,
scope_match: Option<bool>,
kind_match: Option<bool>,
tx_ref_match: Option<bool>,
) -> Self {
self.data.mandate_scope_match = scope_match;
self.data.mandate_kind_match = kind_match;
self.data.transaction_ref_match = tx_ref_match;
self
}
pub fn with_latencies(mut self, authz_ms: Option<u64>, store_ms: Option<u64>) -> Self {
self.data.authz_latency_ms = authz_ms;
self.data.store_latency_ms = store_ms;
self
}
pub fn with_tool_match(
mut self,
tool_classes: Vec<String>,
matched_tool_classes: Vec<String>,
match_basis: Option<String>,
matched_rule: Option<String>,
) -> Self {
self.data.tool_classes = tool_classes;
self.data.matched_tool_classes = matched_tool_classes;
self.data.match_basis = match_basis;
self.data.matched_rule = matched_rule;
self
}
}
pub trait DecisionEmitter: Send + Sync {
fn emit(&self, event: &DecisionEvent);
}
pub struct FileDecisionEmitter {
file: std::sync::Mutex<std::fs::File>,
}
impl FileDecisionEmitter {
pub fn new(path: &std::path::Path) -> std::io::Result<Self> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self {
file: std::sync::Mutex::new(file),
})
}
}
impl DecisionEmitter for FileDecisionEmitter {
fn emit(&self, event: &DecisionEvent) {
if let Ok(json) = serde_json::to_string(event) {
if let Ok(mut f) = self.file.lock() {
let _ = writeln!(f, "{}", json);
}
}
}
}
pub struct NullDecisionEmitter;
impl DecisionEmitter for NullDecisionEmitter {
fn emit(&self, _event: &DecisionEvent) {}
}
pub struct DecisionEmitterGuard {
emitter: Arc<dyn DecisionEmitter>,
event: Option<DecisionEvent>,
}
impl DecisionEmitterGuard {
pub fn new(
emitter: Arc<dyn DecisionEmitter>,
source: String,
tool_call_id: String,
tool: String,
) -> Self {
Self {
emitter,
event: Some(DecisionEvent::new(source, tool_call_id, tool)),
}
}
pub fn set_request_id(&mut self, id: Option<Value>) {
if let Some(ref mut event) = self.event {
event.data.request_id = id;
}
}
pub fn set_mandate_info(
&mut self,
mandate_id: Option<String>,
use_id: Option<String>,
use_count: Option<u32>,
) {
if let Some(ref mut event) = self.event {
event.data.mandate_id = mandate_id;
event.data.use_id = use_id;
event.data.use_count = use_count;
}
}
pub fn set_mandate_matches(
&mut self,
scope_match: Option<bool>,
kind_match: Option<bool>,
tx_ref_match: Option<bool>,
) {
if let Some(ref mut event) = self.event {
event.data.mandate_scope_match = scope_match;
event.data.mandate_kind_match = kind_match;
event.data.transaction_ref_match = tx_ref_match;
}
}
pub fn set_latencies(&mut self, authz_ms: Option<u64>, store_ms: Option<u64>) {
if let Some(ref mut event) = self.event {
event.data.authz_latency_ms = authz_ms;
event.data.store_latency_ms = store_ms;
}
}
pub fn set_tool_match(
&mut self,
tool_classes: Vec<String>,
matched_tool_classes: Vec<String>,
match_basis: Option<String>,
matched_rule: Option<String>,
) {
if let Some(ref mut event) = self.event {
event.data.tool_classes = tool_classes;
event.data.matched_tool_classes = matched_tool_classes;
event.data.match_basis = match_basis;
event.data.matched_rule = matched_rule;
}
}
pub fn emit_allow(mut self, reason_code: &str) {
if let Some(event) = self.event.take() {
self.emitter.emit(&event.allow(reason_code));
}
}
pub fn emit_deny(mut self, reason_code: &str, reason: Option<String>) {
if let Some(event) = self.event.take() {
self.emitter.emit(&event.deny(reason_code, reason));
}
}
pub fn emit_error(mut self, reason_code: &str, reason: Option<String>) {
if let Some(event) = self.event.take() {
self.emitter.emit(&event.error(reason_code, reason));
}
}
pub fn emit_event(mut self, event: DecisionEvent) {
self.event = None; self.emitter.emit(&event);
}
}
impl Drop for DecisionEmitterGuard {
fn drop(&mut self) {
if let Some(event) = self.event.take() {
self.emitter.emit(&event.error(
reason_codes::S_INTERNAL_ERROR,
Some("Decision guard dropped without explicit emit (possible panic or early return)".to_string()),
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingEmitter {
count: AtomicUsize,
last_decision: std::sync::Mutex<Option<Decision>>,
last_reason_code: std::sync::Mutex<Option<String>>,
}
impl CountingEmitter {
fn new() -> Self {
Self {
count: AtomicUsize::new(0),
last_decision: std::sync::Mutex::new(None),
last_reason_code: std::sync::Mutex::new(None),
}
}
}
impl DecisionEmitter for CountingEmitter {
fn emit(&self, event: &DecisionEvent) {
self.count.fetch_add(1, Ordering::SeqCst);
*self.last_decision.lock().unwrap() = Some(event.data.decision);
*self.last_reason_code.lock().unwrap() = Some(event.data.reason_code.clone());
}
}
#[test]
fn test_guard_explicit_allow_emits_once() {
let emitter = Arc::new(CountingEmitter::new());
let guard = DecisionEmitterGuard::new(
emitter.clone(),
"assay://test".to_string(),
"tc_001".to_string(),
"test_tool".to_string(),
);
guard.emit_allow(reason_codes::P_MANDATE_VALID);
assert_eq!(emitter.count.load(Ordering::SeqCst), 1);
assert_eq!(
*emitter.last_decision.lock().unwrap(),
Some(Decision::Allow)
);
}
#[test]
fn test_guard_explicit_deny_emits_once() {
let emitter = Arc::new(CountingEmitter::new());
let guard = DecisionEmitterGuard::new(
emitter.clone(),
"assay://test".to_string(),
"tc_002".to_string(),
"test_tool".to_string(),
);
guard.emit_deny(reason_codes::M_EXPIRED, Some("Mandate expired".to_string()));
assert_eq!(emitter.count.load(Ordering::SeqCst), 1);
assert_eq!(*emitter.last_decision.lock().unwrap(), Some(Decision::Deny));
assert_eq!(
*emitter.last_reason_code.lock().unwrap(),
Some(reason_codes::M_EXPIRED.to_string())
);
}
#[test]
fn test_guard_drop_emits_error() {
let emitter = Arc::new(CountingEmitter::new());
{
let _guard = DecisionEmitterGuard::new(
emitter.clone(),
"assay://test".to_string(),
"tc_003".to_string(),
"test_tool".to_string(),
);
}
assert_eq!(emitter.count.load(Ordering::SeqCst), 1);
assert_eq!(
*emitter.last_decision.lock().unwrap(),
Some(Decision::Error)
);
assert_eq!(
*emitter.last_reason_code.lock().unwrap(),
Some(reason_codes::S_INTERNAL_ERROR.to_string())
);
}
#[test]
fn test_guard_no_double_emit() {
let emitter = Arc::new(CountingEmitter::new());
{
let guard = DecisionEmitterGuard::new(
emitter.clone(),
"assay://test".to_string(),
"tc_004".to_string(),
"test_tool".to_string(),
);
guard.emit_allow(reason_codes::P_POLICY_DENY);
}
assert_eq!(emitter.count.load(Ordering::SeqCst), 1);
}
#[test]
fn test_event_serialization() {
let event = DecisionEvent::new(
"assay://test".to_string(),
"tc_005".to_string(),
"test_tool".to_string(),
)
.allow(reason_codes::P_MANDATE_VALID)
.with_mandate(
Some("sha256:abc".to_string()),
Some("sha256:use".to_string()),
Some(1),
)
.with_mandate_matches(Some(true), Some(true), Some(true));
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("assay.tool.decision"));
assert!(json.contains("tc_005"));
assert!(json.contains("allow"));
}
#[test]
fn test_reason_codes_are_string_constants() {
assert_eq!(reason_codes::P_POLICY_ALLOW, "P_POLICY_ALLOW");
assert_eq!(reason_codes::P_POLICY_DENY, "P_POLICY_DENY");
assert_eq!(reason_codes::M_EXPIRED, "M_EXPIRED");
assert_eq!(reason_codes::S_DB_ERROR, "S_DB_ERROR");
assert_eq!(reason_codes::T_TIMEOUT, "T_TIMEOUT");
}
}