use crate::types::{ListenExecutionContext, ToolResult, ToolTier};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditProvenance {
pub provider: String,
pub model: String,
}
impl AuditProvenance {
#[must_use]
pub fn new(provider: impl Into<String>, model: impl Into<String>) -> Self {
Self {
provider: provider.into(),
model: model.into(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ToolAuditOutcome {
Blocked {
reason: String,
},
RequiresConfirmation {
description: String,
listen_context: Option<ListenExecutionContext>,
},
Cached {
result: ToolResult,
},
Replayed {
result: ToolResult,
},
Invalidated {
reason: String,
},
Completed {
result: ToolResult,
},
PersistenceFailed {
result: Option<ToolResult>,
error: String,
},
}
impl ToolAuditOutcome {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Blocked { .. } => "blocked",
Self::RequiresConfirmation { .. } => "requires_confirmation",
Self::Cached { .. } => "cached",
Self::Replayed { .. } => "replayed",
Self::Invalidated { .. } => "invalidated",
Self::Completed { .. } => "completed",
Self::PersistenceFailed { .. } => "persistence_failed",
}
}
#[must_use]
pub const fn result(&self) -> Option<&ToolResult> {
match self {
Self::Cached { result } | Self::Replayed { result } | Self::Completed { result } => {
Some(result)
}
Self::PersistenceFailed { result, .. } => result.as_ref(),
Self::Blocked { .. } | Self::RequiresConfirmation { .. } | Self::Invalidated { .. } => {
None
}
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ToolAuditRecord {
pub tool_call_id: String,
pub tool_name: String,
pub display_name: String,
pub tier: ToolTier,
pub requested_input: serde_json::Value,
pub effective_input: serde_json::Value,
pub turn: usize,
pub provenance: AuditProvenance,
pub outcome: ToolAuditOutcome,
#[serde(with = "time::serde::rfc3339")]
pub recorded_at: OffsetDateTime,
}
#[derive(Clone, Debug)]
pub struct ToolAuditRecordParams {
pub tool_call_id: String,
pub tool_name: String,
pub display_name: String,
pub tier: ToolTier,
pub requested_input: serde_json::Value,
pub effective_input: serde_json::Value,
pub turn: usize,
pub provenance: AuditProvenance,
pub outcome: ToolAuditOutcome,
}
impl ToolAuditRecord {
#[must_use]
pub fn new(params: ToolAuditRecordParams) -> Self {
let ToolAuditRecordParams {
tool_call_id,
tool_name,
display_name,
tier,
requested_input,
effective_input,
turn,
provenance,
outcome,
} = params;
Self {
tool_call_id,
tool_name,
display_name,
tier,
requested_input,
effective_input,
turn,
provenance,
outcome,
recorded_at: OffsetDateTime::now_utc(),
}
}
#[must_use]
pub const fn outcome_kind(&self) -> &'static str {
self.outcome.kind()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_record(outcome: ToolAuditOutcome) -> ToolAuditRecord {
ToolAuditRecord::new(ToolAuditRecordParams {
tool_call_id: "call_1".into(),
tool_name: "read_file".into(),
display_name: "Read File".into(),
tier: ToolTier::Observe,
requested_input: serde_json::json!({"path": "/tmp/x"}),
effective_input: serde_json::json!({"path": "/tmp/x"}),
turn: 2,
provenance: AuditProvenance::new("anthropic", "claude-sonnet-4-5-20250929"),
outcome,
})
}
#[test]
fn outcome_kind_matches_variant() {
assert_eq!(
ToolAuditOutcome::Blocked {
reason: "no".into(),
}
.kind(),
"blocked",
);
assert_eq!(
ToolAuditOutcome::RequiresConfirmation {
description: "pls".into(),
listen_context: None,
}
.kind(),
"requires_confirmation",
);
assert_eq!(
ToolAuditOutcome::Cached {
result: ToolResult::success("ok"),
}
.kind(),
"cached",
);
assert_eq!(
ToolAuditOutcome::Replayed {
result: ToolResult::success("ok"),
}
.kind(),
"replayed",
);
assert_eq!(
ToolAuditOutcome::Invalidated {
reason: "expired".into(),
}
.kind(),
"invalidated",
);
assert_eq!(
ToolAuditOutcome::Completed {
result: ToolResult::success("ok"),
}
.kind(),
"completed",
);
assert_eq!(
ToolAuditOutcome::PersistenceFailed {
result: None,
error: "boom".into(),
}
.kind(),
"persistence_failed",
);
}
#[test]
fn outcome_result_accessor() {
let ok = ToolResult::success("ok");
assert!(
ToolAuditOutcome::Blocked { reason: "n".into() }
.result()
.is_none()
);
assert_eq!(
ToolAuditOutcome::Completed { result: ok.clone() }
.result()
.map(|r| r.output.as_str()),
Some("ok"),
);
assert_eq!(
ToolAuditOutcome::PersistenceFailed {
result: Some(ok),
error: "e".into(),
}
.result()
.map(|r| r.output.as_str()),
Some("ok"),
);
}
#[test]
fn record_round_trips_through_json() {
let record = sample_record(ToolAuditOutcome::Completed {
result: ToolResult::success("hello"),
});
let json = serde_json::to_string(&record).unwrap();
let back: ToolAuditRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.tool_call_id, "call_1");
assert_eq!(back.outcome_kind(), "completed");
assert_eq!(back.provenance.provider, "anthropic");
assert_eq!(back.provenance.model, "claude-sonnet-4-5-20250929");
}
#[test]
fn every_outcome_serialises_with_snake_case_tag() {
let record = sample_record(ToolAuditOutcome::Blocked {
reason: "policy".into(),
});
let json = serde_json::to_value(&record).unwrap();
assert_eq!(json["outcome"]["kind"], "blocked");
assert_eq!(json["outcome"]["reason"], "policy");
}
}