openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Would-have structural validation (D-04 / D-18).
//!
//! A trim that removes older history can leave a `tool_result` in the retained
//! tail whose matching `tool_use` was in the removed region. Such a request would
//! be rejected by the provider (a 400: an orphaned `tool_result`), so the
//! would-have is **structurally invalid** — it could not have been sent, and its
//! (possibly positive) net must not be credited as a saving. The engine records
//! `skipped_invalid` for it.
//!
//! This validator only *inspects* the retained tail — it never builds the trimmed
//! body (observe-only). It is deterministic and network-free.

use std::collections::HashSet;

use serde_json::Value;

/// Would a history trim that retains exactly `retained` be structurally valid?
///
/// Valid iff every `tool_use_id` referenced by a `tool_result` in the retained
/// tail is produced by a `tool_use` **also** in the retained tail. A referenced id
/// that is missing means its `tool_use` was in the removed region → orphaned →
/// invalid.
pub fn history_trim_is_valid(retained: &[Value]) -> bool {
    let produced = tool_use_ids(retained);
    for message in retained {
        for block in content_blocks(message) {
            if block_type(block) == Some("tool_result") {
                if let Some(id) = block.get("tool_use_id").and_then(Value::as_str) {
                    if !produced.contains(id) {
                        return false; // a tool_result whose tool_use was trimmed away
                    }
                }
            }
        }
    }
    true
}

/// Collect the ids of every `tool_use` block across `messages`.
fn tool_use_ids(messages: &[Value]) -> HashSet<&str> {
    let mut ids = HashSet::new();
    for message in messages {
        for block in content_blocks(message) {
            if block_type(block) == Some("tool_use") {
                if let Some(id) = block.get("id").and_then(Value::as_str) {
                    ids.insert(id);
                }
            }
        }
    }
    ids
}

/// The `content` blocks of a message, or an empty slice when `content` is a bare
/// string (a plain-text message carries no tool blocks).
fn content_blocks(message: &Value) -> &[Value] {
    message
        .get("content")
        .and_then(Value::as_array)
        .map(Vec::as_slice)
        .unwrap_or(&[])
}

/// A block's `type` discriminator (`tool_use`, `tool_result`, `text`, …).
fn block_type(block: &Value) -> Option<&str> {
    block.get("type").and_then(Value::as_str)
}

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

    fn tool_use(id: &str) -> Value {
        json!({ "role": "assistant", "content": [ { "type": "tool_use", "id": id, "name": "search", "input": {} } ] })
    }

    fn tool_result(id: &str) -> Value {
        json!({ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": id, "content": "ok" } ] })
    }

    #[test]
    fn matched_tool_pair_in_the_tail_is_valid() {
        let retained = vec![tool_use("toolu_1"), tool_result("toolu_1")];
        assert!(history_trim_is_valid(&retained));
    }

    #[test]
    fn orphaned_tool_result_is_invalid() {
        // The tool_use (toolu_9) is NOT in the retained tail — it was trimmed away,
        // leaving a dangling tool_result → structurally invalid.
        let retained = vec![tool_result("toolu_9")];
        assert!(!history_trim_is_valid(&retained));
    }

    #[test]
    fn plain_text_history_is_always_valid() {
        let retained = vec![
            json!({ "role": "user", "content": "hi" }),
            json!({ "role": "assistant", "content": "hello" }),
        ];
        assert!(history_trim_is_valid(&retained));
    }

    #[test]
    fn a_result_before_its_use_in_the_tail_is_still_valid() {
        // Order within the tail does not matter — both blocks are present.
        let retained = vec![tool_result("toolu_2"), tool_use("toolu_2")];
        assert!(history_trim_is_valid(&retained));
    }
}