klieo-provenance 3.5.0

Append-only Merkle hash chain with signed evidence bundles for agent and audit provenance.
Documentation
//! Canonical [`Episode`] → [`ProvenanceChain`] projection.
//!
//! Builds a Merkle [`ProvenanceChain`] from an agent run's recorded
//! [`Episode`] stream. The chain captures the *shape* of work (LLM call,
//! tool call, bus publish, …); the raw [`Episode`] is SHA-256-hashed into
//! `payload_hash_hex` so the chain is tamper-evident against the agent's
//! event log without storing the body inline.
//!
//! # Security: PII redaction at the trust boundary
//!
//! A signed [`crate::EvidenceBundle`] is WORM-stored once emitted, so
//! anything written into it is impossible to redact after the fact. Raw
//! `Episode::Failed { error }` text frequently contains stack traces,
//! file paths, request bodies, and accidentally-captured secrets
//! (database URLs with embedded credentials, bearer tokens echoed back in
//! a tool error). This projection therefore **never** copies the raw
//! error string into the chain — it collapses it to a fixed
//! [`error_category`] code (CWE-532 sensitive-data-in-log / CWE-200
//! information-exposure). Callers that need the raw text for triage must
//! log it via an access-controlled sink at the call site, with a shorter
//! retention than the bundle's.

use klieo_core::Episode;
use sha2::{Digest, Sha256};

use crate::chain::{ProvenanceChain, ProvenanceEvent, ProvenanceEventKind};
use crate::error::ProvenanceError;

/// Collapse a raw error string into a fixed, PII-free category code.
///
/// Returns one of a closed set: `"tool_timeout"`, `"cancelled"`,
/// `"max_steps"`, `"llm_error"`, `"tool_error"`, `"unknown"`. The raw input
/// is never returned, so the result is always safe to embed in a signed
/// bundle.
#[must_use]
pub fn error_category(error: &str) -> &'static str {
    let lower = error.to_ascii_lowercase();
    if lower.contains("timeout") || lower.contains("timed out") {
        "tool_timeout"
    } else if lower.contains("cancel") {
        "cancelled"
    } else if lower.contains("max step") || lower.contains("max_steps") {
        "max_steps"
    } else if lower.contains("llm") || lower.contains("model") {
        "llm_error"
    } else if lower.contains("tool") {
        "tool_error"
    } else {
        "unknown"
    }
}

/// Map one [`Episode`] to a [`ProvenanceEvent`] stamped with `actor` and
/// `run_ref`.
///
/// `Episode::Failed` is redacted to a category code (see module-level
/// security note); all other variants project their non-sensitive
/// structured fields into `metadata`. Returns
/// [`ProvenanceError::Serialization`] if the episode cannot be serialised
/// for the payload hash — the chain must never commit to empty bytes in
/// place of the real payload.
pub fn episode_to_event(
    actor: &str,
    run_ref: &str,
    episode: &Episode,
) -> Result<ProvenanceEvent, ProvenanceError> {
    let (kind, metadata) = classify_episode(episode);
    let payload_bytes = serde_json::to_vec(episode).map_err(ProvenanceError::Serialization)?;
    let payload_hash_hex = hex::encode(Sha256::digest(payload_bytes));
    Ok(ProvenanceEvent {
        kind,
        actor: actor.into(),
        resource_ref: run_ref.into(),
        payload_hash_hex,
        metadata,
    })
}

/// Build a Merkle [`ProvenanceChain`] over an agent run's `episodes`,
/// stamping every entry with `actor`.
///
/// `Episode::Failed` error text is redacted to a category code before it
/// enters the chain. Propagates [`ProvenanceError::Serialization`] from
/// [`episode_to_event`].
pub fn chain_from_episodes(
    scope: impl Into<String>,
    actor: impl Into<String>,
    episodes: &[Episode],
) -> Result<ProvenanceChain, ProvenanceError> {
    let scope = scope.into();
    let actor = actor.into();
    let mut chain = ProvenanceChain::new(scope.clone());
    for episode in episodes {
        chain.append(episode_to_event(&actor, &scope, episode)?);
    }
    Ok(chain)
}

fn classify_episode(episode: &Episode) -> (ProvenanceEventKind, serde_json::Value) {
    match episode {
        Episode::Started { agent } => (
            ProvenanceEventKind::AgentRunStarted,
            serde_json::json!({ "agent": agent }),
        ),
        Episode::LlmCall {
            tokens,
            latency_ms,
            provider,
            model,
            prompt_tokens,
            completion_tokens,
        } => (
            ProvenanceEventKind::LlmCall,
            serde_json::json!({
                "tokens": tokens,
                "latency_ms": latency_ms,
                "provider": provider,
                "model": model,
                "prompt_tokens": prompt_tokens,
                "completion_tokens": completion_tokens,
            }),
        ),
        Episode::ToolCall { name, .. } => (
            ProvenanceEventKind::ToolCall,
            serde_json::json!({ "tool": name }),
        ),
        Episode::BusPublish { subject } => (
            ProvenanceEventKind::Custom("bus_publish".into()),
            serde_json::json!({ "subject": subject }),
        ),
        Episode::BusReceive { subject } => (
            ProvenanceEventKind::Custom("bus_receive".into()),
            serde_json::json!({ "subject": subject }),
        ),
        Episode::BusCausalLink {
            subject,
            caused_by_run,
        } => (
            ProvenanceEventKind::Custom("bus_causal_link".into()),
            serde_json::json!({ "subject": subject, "caused_by_run": caused_by_run }),
        ),
        Episode::Completed => (
            ProvenanceEventKind::AgentRunCompleted,
            serde_json::json!({ "status": "ok" }),
        ),
        Episode::Failed { error } => (
            ProvenanceEventKind::AgentRunCompleted,
            serde_json::json!({ "status": "failed", "error_category": error_category(error) }),
        ),
        Episode::SummaryCheckpoint {
            input_message_count,
            summary_chars,
            latency_ms,
            tokens,
        } => (
            ProvenanceEventKind::Custom("summary_checkpoint".into()),
            serde_json::json!({
                "input_message_count": input_message_count,
                "summary_chars": summary_chars,
                "latency_ms": latency_ms,
                "tokens": tokens,
            }),
        ),
        // `Episode` is `#[non_exhaustive]`; unknown future variants record
        // a generic marker so the chain stays forward-compatible.
        _ => (
            ProvenanceEventKind::Custom("unknown".into()),
            serde_json::json!({}),
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn event_for(episode: &Episode) -> ProvenanceEvent {
        episode_to_event("agent:test", "run-test", episode).expect("serialisable episode")
    }

    #[test]
    fn chain_from_episodes_links_entries_and_stamps_actor() {
        let episodes = [
            Episode::Started {
                agent: "planner".into(),
            },
            Episode::Completed,
        ];
        let chain =
            chain_from_episodes("scope-1", "agent:planner", &episodes).expect("projection ok");

        assert_eq!(chain.entries.len(), 2);
        assert_eq!(
            chain.entries[0].event.kind,
            ProvenanceEventKind::AgentRunStarted
        );
        assert_eq!(
            chain.entries[1].event.kind,
            ProvenanceEventKind::AgentRunCompleted
        );
        for entry in &chain.entries {
            assert_eq!(entry.event.actor, "agent:planner");
        }
        assert_eq!(
            chain.entries[1].previous_hash_hex,
            chain.entries[0].entry_hash_hex
        );
        chain.verify().expect("projected chain verifies end-to-end");
    }

    #[test]
    fn empty_episode_slice_yields_empty_chain() {
        let chain = chain_from_episodes("scope-empty", "agent:none", &[]).expect("projection ok");
        assert_eq!(chain.entries.len(), 0);
        chain.verify().expect("empty chain verifies");
    }

    #[test]
    fn three_entry_chain_links_each_entry_to_its_predecessor() {
        let episodes = [
            Episode::Started {
                agent: "planner".into(),
            },
            Episode::llm_call(7, 12),
            Episode::Completed,
        ];
        let chain = chain_from_episodes("scope-3", "agent:planner", &episodes).expect("ok");
        assert_eq!(chain.entries.len(), 3);
        for i in 1..chain.entries.len() {
            assert_eq!(
                chain.entries[i].previous_hash_hex,
                chain.entries[i - 1].entry_hash_hex,
                "entry {i} must link to entry {}",
                i - 1
            );
        }
        chain.verify().expect("3-entry chain verifies");
    }

    #[test]
    fn tool_call_projects_tool_kind_and_name() {
        let episode = Episode::ToolCall {
            name: "search".into(),
            args: serde_json::json!({ "q": "x" }),
            result: klieo_core::ToolResult::ok(serde_json::json!({ "hits": 0 })),
        };
        let event = event_for(&episode);
        assert_eq!(event.kind, ProvenanceEventKind::ToolCall);
        assert_eq!(event.metadata["tool"], "search");
    }

    #[test]
    fn llm_call_projects_llm_kind_and_token_metadata() {
        let episode = Episode::llm_call(123, 45);
        let event = event_for(&episode);
        assert_eq!(event.kind, ProvenanceEventKind::LlmCall);
        assert_eq!(event.metadata["tokens"], 123);
        assert_eq!(event.metadata["latency_ms"], 45);
    }

    #[test]
    fn bus_publish_projects_custom_kind_and_subject() {
        let episode = Episode::BusPublish {
            subject: "jobs.research".into(),
        };
        let event = event_for(&episode);
        assert_eq!(
            event.kind,
            ProvenanceEventKind::Custom("bus_publish".into())
        );
        assert_eq!(event.metadata["subject"], "jobs.research");
    }

    #[test]
    fn bus_receive_projects_custom_kind_and_subject() {
        let episode = Episode::BusReceive {
            subject: "jobs.result".into(),
        };
        let event = event_for(&episode);
        assert_eq!(
            event.kind,
            ProvenanceEventKind::Custom("bus_receive".into())
        );
        assert_eq!(event.metadata["subject"], "jobs.result");
    }

    #[test]
    fn bus_causal_link_projects_custom_kind_and_caused_by_run() {
        let episode = Episode::BusCausalLink {
            subject: "jobs.research".into(),
            caused_by_run: "run-parent".into(),
        };
        let event = event_for(&episode);
        assert_eq!(
            event.kind,
            ProvenanceEventKind::Custom("bus_causal_link".into())
        );
        assert_eq!(event.metadata["subject"], "jobs.research");
        assert_eq!(event.metadata["caused_by_run"], "run-parent");
    }

    #[test]
    fn summary_checkpoint_projects_custom_kind_and_counters() {
        let episode = Episode::SummaryCheckpoint {
            input_message_count: 9,
            summary_chars: 256,
            latency_ms: 11,
            tokens: 64,
        };
        let event = event_for(&episode);
        assert_eq!(
            event.kind,
            ProvenanceEventKind::Custom("summary_checkpoint".into())
        );
        assert_eq!(event.metadata["input_message_count"], 9);
        assert_eq!(event.metadata["summary_chars"], 256);
    }

    #[test]
    fn single_failed_episode_verifies_with_error_category() {
        let episodes = [Episode::Failed {
            error: "model overloaded".into(),
        }];
        let chain = chain_from_episodes("scope-failed", "agent:test", &episodes).expect("ok");
        assert_eq!(chain.entries.len(), 1);
        assert_eq!(
            chain.entries[0].event.kind,
            ProvenanceEventKind::AgentRunCompleted
        );
        assert_eq!(chain.entries[0].event.metadata["status"], "failed");
        assert_eq!(
            chain.entries[0].event.metadata["error_category"],
            "llm_error"
        );
        chain.verify().expect("failed-only chain verifies");
    }

    #[test]
    fn failed_episode_redacts_raw_error_to_category() {
        let secret = "hunter2";
        let episodes = [Episode::Failed {
            error: format!("DB password: {secret} leaked at /etc/secrets, tool timed out"),
        }];
        let chain = chain_from_episodes("redaction", "tester", &episodes).expect("projection ok");
        let metadata = &chain.entries[0].event.metadata;

        assert_eq!(metadata["status"], "failed");
        assert_eq!(metadata["error_category"], "tool_timeout");
        let serialized = serde_json::to_string(metadata).unwrap();
        assert!(
            !serialized.contains(secret),
            "raw error secret must not enter the chain metadata"
        );
        assert!(!serialized.contains("DB password"));
    }

    #[test]
    fn error_category_maps_known_classes() {
        assert_eq!(error_category("connection timed out"), "tool_timeout");
        assert_eq!(error_category("operation cancelled"), "cancelled");
        assert_eq!(error_category("hit max steps limit"), "max_steps");
        assert_eq!(error_category("model overloaded"), "llm_error");
        assert_eq!(error_category("tool dispatch failed"), "tool_error");
        assert_eq!(error_category("something odd"), "unknown");
    }

    #[test]
    fn serialization_error_chains_its_source() {
        // The payload-hash path maps a serde failure to ProvenanceError::Serialization;
        // the underlying serde_json::Error must remain reachable via source().
        let serde_err = serde_json::from_str::<i32>("not-an-int").unwrap_err();
        let err = ProvenanceError::Serialization(serde_err);
        assert!(
            std::error::Error::source(&err).is_some(),
            "Serialization variant must chain the serde_json::Error as its source"
        );
    }
}