use serde::{Deserialize, Serialize};
use crate::determinism::ReplayMetadata;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceEnvelope {
pub v: u32,
pub ts: String,
pub event: EvidenceEvent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceEventType {
Decision,
Alert,
Degradation,
Transition,
ReplayMarker,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceReason {
pub code: String,
pub human: String,
pub severity: EvidenceSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceSeverity {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceTrace {
pub root_request_id: String,
pub parent_event_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceRedaction {
pub policy_version: String,
pub contains_sensitive_source: bool,
pub transforms_applied: Vec<RedactionTransform>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RedactionTransform {
HashSha256,
TruncatePreview,
Drop,
PathTokenize,
MaskPartial,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EvidencePayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub query_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub query_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lag_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dropped_count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceEvent {
pub event_id: String,
#[serde(rename = "type")]
pub event_type: EvidenceEventType,
pub project_key: String,
pub instance_id: String,
pub trace: EvidenceTrace,
pub reason: EvidenceReason,
pub replay: ReplayMetadata,
pub redaction: EvidenceRedaction,
pub payload: EvidencePayload,
}
pub trait EvidenceSink: Send {
fn write(&mut self, envelope: &EvidenceEnvelope) -> Result<(), EvidenceWriteError>;
}
#[derive(Debug)]
pub enum EvidenceWriteError {
Serialization(serde_json::Error),
Io(std::io::Error),
}
impl std::fmt::Display for EvidenceWriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Serialization(e) => write!(f, "evidence serialization error: {e}"),
Self::Io(e) => write!(f, "evidence I/O error: {e}"),
}
}
}
impl std::error::Error for EvidenceWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialization(e) => Some(e),
Self::Io(e) => Some(e),
}
}
}
impl From<serde_json::Error> for EvidenceWriteError {
fn from(e: serde_json::Error) -> Self {
Self::Serialization(e)
}
}
impl From<std::io::Error> for EvidenceWriteError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub struct VecWriter {
entries: Vec<EvidenceEnvelope>,
}
impl VecWriter {
#[must_use]
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
#[must_use]
pub fn entries(&self) -> &[EvidenceEnvelope] {
&self.entries
}
#[must_use]
pub const fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn to_jsonl(&self) -> Result<String, serde_json::Error> {
let mut output = String::new();
for entry in &self.entries {
let line = serde_json::to_string(entry)?;
output.push_str(&line);
output.push('\n');
}
Ok(output)
}
}
impl Default for VecWriter {
fn default() -> Self {
Self::new()
}
}
impl EvidenceSink for VecWriter {
fn write(&mut self, envelope: &EvidenceEnvelope) -> Result<(), EvidenceWriteError> {
let _json = serde_json::to_string(envelope)?;
self.entries.push(envelope.clone());
Ok(())
}
}
pub struct NoopWriter;
impl EvidenceSink for NoopWriter {
fn write(&mut self, _envelope: &EvidenceEnvelope) -> Result<(), EvidenceWriteError> {
Ok(())
}
}
#[must_use]
pub fn wrap_envelope(event: EvidenceEvent) -> EvidenceEnvelope {
let ts = format_iso8601_now();
EvidenceEnvelope { v: 1, ts, event }
}
fn format_iso8601_now() -> String {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let remaining = secs % 86400;
let hours = remaining / 3600;
let minutes = (remaining % 3600) / 60;
let seconds = remaining % 60;
let (year, month, day) = days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let era_days = days + 719_468; let era = era_days / 146_097;
let day_of_era = era_days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let mp = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { year + 1 } else { year };
(year, month, day)
}
#[must_use]
pub fn redaction_none() -> EvidenceRedaction {
EvidenceRedaction {
policy_version: "v1".to_string(),
contains_sensitive_source: false,
transforms_applied: vec![RedactionTransform::Drop],
}
}
#[must_use]
pub fn redaction_query_hashed() -> EvidenceRedaction {
EvidenceRedaction {
policy_version: "v1".to_string(),
contains_sensitive_source: true,
transforms_applied: vec![
RedactionTransform::HashSha256,
RedactionTransform::TruncatePreview,
],
}
}
#[cfg(test)]
mod tests {
use crate::determinism::ReplayMetadata;
use super::*;
fn sample_event() -> EvidenceEvent {
EvidenceEvent {
event_id: "01HQXYZ1234567890ABCDEFGHIJ".to_string(),
event_type: EvidenceEventType::Decision,
project_key: "/data/projects/test".to_string(),
instance_id: "01HQXYZ9876543210ABCDEFGHIJ".to_string(),
trace: EvidenceTrace {
root_request_id: "01HQXYZREQUEST00000000000A".to_string(),
parent_event_id: None,
},
reason: EvidenceReason {
code: "search.phase.initial_complete".to_string(),
human: "Initial search phase completed".to_string(),
severity: EvidenceSeverity::Info,
},
replay: ReplayMetadata::live(),
redaction: redaction_none(),
payload: EvidencePayload::default(),
}
}
#[test]
fn evidence_envelope_serde_roundtrip() {
let event = sample_event();
let envelope = wrap_envelope(event);
assert_eq!(envelope.v, 1);
assert!(!envelope.ts.is_empty());
let json = serde_json::to_string(&envelope).unwrap();
let decoded: EvidenceEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.v, 1);
assert_eq!(decoded.event.event_type, EvidenceEventType::Decision);
}
#[test]
fn evidence_event_type_serde() {
for event_type in [
EvidenceEventType::Decision,
EvidenceEventType::Alert,
EvidenceEventType::Degradation,
EvidenceEventType::Transition,
EvidenceEventType::ReplayMarker,
] {
let json = serde_json::to_string(&event_type).unwrap();
let decoded: EvidenceEventType = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, event_type);
}
}
#[test]
fn evidence_severity_serde() {
for severity in [
EvidenceSeverity::Info,
EvidenceSeverity::Warn,
EvidenceSeverity::Error,
] {
let json = serde_json::to_string(&severity).unwrap();
let decoded: EvidenceSeverity = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, severity);
}
}
#[test]
fn redaction_transform_serde() {
for transform in [
RedactionTransform::HashSha256,
RedactionTransform::TruncatePreview,
RedactionTransform::Drop,
RedactionTransform::PathTokenize,
RedactionTransform::MaskPartial,
] {
let json = serde_json::to_string(&transform).unwrap();
let decoded: RedactionTransform = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, transform);
}
}
#[test]
fn vec_writer_collects() {
let mut writer = VecWriter::new();
assert!(writer.is_empty());
let event = sample_event();
let envelope = wrap_envelope(event);
writer.write(&envelope).unwrap();
assert_eq!(writer.len(), 1);
assert_eq!(writer.entries()[0].v, 1);
}
#[test]
fn vec_writer_to_jsonl() {
let mut writer = VecWriter::new();
let event1 = sample_event();
writer.write(&wrap_envelope(event1)).unwrap();
let mut event2 = sample_event();
event2.event_type = EvidenceEventType::Alert;
writer.write(&wrap_envelope(event2)).unwrap();
let jsonl = writer.to_jsonl().unwrap();
let lines: Vec<&str> = jsonl.lines().collect();
assert_eq!(lines.len(), 2);
for line in &lines {
let _: EvidenceEnvelope = serde_json::from_str(line).unwrap();
}
}
#[test]
fn noop_writer_discards() {
let mut writer = NoopWriter;
let event = sample_event();
let envelope = wrap_envelope(event);
writer.write(&envelope).unwrap();
}
#[test]
fn evidence_with_deterministic_replay() {
let mut event = sample_event();
event.replay = ReplayMetadata::deterministic(42, 16, 100);
let envelope = wrap_envelope(event);
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("\"deterministic\""));
assert!(json.contains("\"seed\":42"));
assert!(json.contains("\"tick_ms\":16"));
assert!(json.contains("\"frame_seq\":100"));
}
#[test]
fn evidence_with_query_redaction() {
let mut event = sample_event();
event.redaction = redaction_query_hashed();
event.payload.query_hash = Some("a".repeat(64));
event.payload.query_preview = Some("how does search work".to_string());
let envelope = wrap_envelope(event);
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("hash_sha256"));
assert!(json.contains("truncate_preview"));
assert!(json.contains("\"contains_sensitive_source\":true"));
}
#[test]
fn redaction_none_has_drop() {
let r = redaction_none();
assert!(!r.contains_sensitive_source);
assert_eq!(r.transforms_applied, vec![RedactionTransform::Drop]);
}
#[test]
fn evidence_payload_default_empty() {
let payload = EvidencePayload::default();
let json = serde_json::to_string(&payload).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn evidence_payload_with_fields() {
let payload = EvidencePayload {
lag_ms: Some(42),
notes: Some("test note".to_string()),
..EvidencePayload::default()
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"lag_ms\":42"));
assert!(json.contains("\"notes\":\"test note\""));
assert!(!json.contains("query_hash"));
}
#[test]
fn format_iso8601_produces_valid_format() {
let ts = format_iso8601_now();
assert_eq!(ts.len(), 20);
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
}
#[test]
fn evidence_write_error_display() {
let err = EvidenceWriteError::Io(std::io::Error::other("test error"));
let msg = err.to_string();
assert!(msg.contains("evidence I/O error"));
}
}