use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::agent_events::{AgentLifecycleState, AgentTerminalOutcome};
use super::{
AuthorityDecider, AuthorityDiagnostic, AuthorityRequirement, RUN_AUTHORITY_RECEIPT_SCHEMA,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityReceiptStage {
Startup,
Discovery,
NeedsApproval,
ApprovalDecision,
Blocked,
Ready,
Terminal,
Stopped,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityReceiptStatus {
Preparing,
NeedsApproval,
Blocked,
Ready,
Completed,
Failed,
Stopped,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ReceiptedAuthority {
pub fingerprint: String,
pub requirement: AuthorityRequirement,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DeniedAuthority {
pub authority: ReceiptedAuthority,
pub reason: String,
pub decider: AuthorityDecider,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PolicyDecisionEvidence {
pub requirement_fingerprint: String,
pub action: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_rule_id: Option<String>,
pub risk_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denial_gate: Option<crate::agent_events::DenialGate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_network_targets: Vec<String>,
pub policy_decision: serde_json::Value,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RunAuthorityReceipt {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub verifier_baselines: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub verifier_observations:
BTreeMap<String, crate::verifier_provenance::VerifierLoaderObservation>,
pub schema: String,
pub stage: AuthorityReceiptStage,
pub status: AuthorityReceiptStatus,
pub intent_id: String,
pub plan_fingerprint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub lease_fingerprint: Option<String>,
pub observed_at_ms: u64,
pub requested: Vec<ReceiptedAuthority>,
pub granted: Vec<ReceiptedAuthority>,
pub used: Vec<ReceiptedAuthority>,
pub denied: Vec<DeniedAuthority>,
pub unused: Vec<ReceiptedAuthority>,
pub deciders: BTreeMap<String, AuthorityDecider>,
pub policy_decisions: Vec<PolicyDecisionEvidence>,
pub diagnostics: Vec<AuthorityDiagnostic>,
pub executor_invoked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_terminal: Option<AgentTerminalOutcome>,
}
impl RunAuthorityReceipt {
pub(crate) fn startup(
intent_id: String,
plan_fingerprint: String,
requested: Vec<ReceiptedAuthority>,
observed_at_ms: u64,
) -> Self {
Self {
verifier_baselines: BTreeMap::new(),
verifier_observations: BTreeMap::new(),
schema: RUN_AUTHORITY_RECEIPT_SCHEMA.to_string(),
stage: AuthorityReceiptStage::Startup,
status: AuthorityReceiptStatus::Preparing,
intent_id,
plan_fingerprint,
lease_fingerprint: None,
observed_at_ms,
requested,
granted: Vec::new(),
used: Vec::new(),
denied: Vec::new(),
unused: Vec::new(),
deciders: BTreeMap::new(),
policy_decisions: Vec::new(),
diagnostics: Vec::new(),
executor_invoked: false,
agent_terminal: None,
}
}
pub(super) fn apply_agent_terminal(
&mut self,
mut terminal: AgentTerminalOutcome,
executor_succeeded: bool,
) {
let lifecycle = terminal.kind.lifecycle_state();
(self.stage, self.status) = match lifecycle {
AgentLifecycleState::Cancelled | AgentLifecycleState::Stopped => (
AuthorityReceiptStage::Stopped,
AuthorityReceiptStatus::Stopped,
),
AgentLifecycleState::Completed if executor_succeeded => (
AuthorityReceiptStage::Terminal,
AuthorityReceiptStatus::Completed,
),
_ => (
AuthorityReceiptStage::Terminal,
AuthorityReceiptStatus::Failed,
),
};
terminal.message = None;
terminal.detail = None;
self.agent_terminal = Some(terminal);
}
}
pub trait AuthorityReceiptSink: Send + Sync {
fn persistent_uri(&self) -> Option<String> {
None
}
fn persist(&self, receipt: &RunAuthorityReceipt) -> Result<(), String>;
}
#[derive(Debug)]
pub struct NdjsonAuthorityReceiptSink {
path: PathBuf,
writer: Mutex<()>,
}
impl NdjsonAuthorityReceiptSink {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
writer: Mutex::new(()),
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl AuthorityReceiptSink for NdjsonAuthorityReceiptSink {
fn persistent_uri(&self) -> Option<String> {
Some(self.path.to_string_lossy().into_owned())
}
fn persist(&self, receipt: &RunAuthorityReceipt) -> Result<(), String> {
let _guard = self
.writer
.lock()
.map_err(|_| "authority receipt writer lock is poisoned".to_string())?;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"create authority receipt directory '{}': {error}",
parent.display()
)
})?;
}
let mut line = crate::canonical_json::of(receipt)
.map_err(|error| format!("encode authority receipt: {error}"))?;
line.push('\n');
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|error| {
format!("open authority receipt '{}': {error}", self.path.display())
})?;
file.write_all(line.as_bytes())
.and_then(|()| file.sync_all())
.map_err(|error| {
format!(
"persist authority receipt '{}': {error}",
self.path.display()
)
})
}
}
#[derive(Debug, Default)]
pub struct MemoryAuthorityReceiptSink {
receipts: Mutex<Vec<RunAuthorityReceipt>>,
}
impl MemoryAuthorityReceiptSink {
pub fn receipts(&self) -> Vec<RunAuthorityReceipt> {
self.receipts
.lock()
.expect("memory authority receipt sink poisoned")
.clone()
}
}
impl AuthorityReceiptSink for MemoryAuthorityReceiptSink {
fn persist(&self, receipt: &RunAuthorityReceipt) -> Result<(), String> {
self.receipts
.lock()
.map_err(|_| "memory authority receipt sink poisoned".to_string())?
.push(receipt.clone());
Ok(())
}
}