polyc-facts 2026.8.3

Shared semantic-fold library: decode-to-fact functions reused by every consumer that reads the event log, so a payment receipt or a tool call means the same thing everywhere it's read.
//! The message content-block fold — the variant→block fact extraction
//! `docs/reference/datafusion-fact-model.md` names as the highest-volume slice
//! of the event log: turning a wire [`Message`]'s single content block into
//! a typed fact (a tool call, a tool result, or plain text), independent of
//! any one surface's presentation.
//!
//! A wire [`Message`] carries exactly one content block today
//! ([`Message::content`]). [`fold_message_content`] is the one place that
//! reads it: every value it returns is the FULL, untruncated fact — trace
//! projection's size caps, its `args_json_full` convenience string, and its
//! `""`-for-absent turn-id convention are presentation decisions the caller
//! applies afterward, not part of the fact.

use polyc_proto::proto::polychrome::agent::v1::{
    Message, content, function_result_content, tool_call_content, tool_result_content,
};
use serde_json::Value;

/// One message content-block fact, plus the warnings folding it produced.
///
/// Warnings are returned as data (never through a side channel) so a caller
/// that doesn't want them (for example, a consumer that only needs a tool
/// result's `tool_call_id`) can simply drop them, and a caller that
/// surfaces them (the trace projector) gets the exact same strings whether
/// they call this fold or ran the old inline logic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageContentFold {
    /// The fact extracted from the message's content block.
    pub content: MessageContent,
    /// Warnings raised while folding — never a size to rely on; check
    /// content for emptiness, not this list.
    pub warnings: Vec<String>,
}

/// The fact extracted from a wire [`Message`]'s single content block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageContent {
    /// The block was plain text.
    Text(TextFact),
    /// The block was a tool call emitted by the model.
    ToolCall(ToolCallFact),
    /// The block was the result of a previously executed tool call.
    ToolResult(ToolResultFact),
    /// The message carried no content block, or one this fold does not
    /// expand into a fact in v1 (for example, a thought block). Both cases
    /// project identically downstream, so they share one variant.
    None,
}

/// A text content-block fact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextFact {
    /// Journal position of the event this text was extracted from.
    pub position: u64,
    /// The turn this text belongs to, when the caller supplied one.
    pub turn_id: Option<String>,
    /// Full, untruncated text.
    pub text: String,
    /// Provenance/trust tag of the event this text was extracted from.
    pub trust: String,
}

/// A tool-call content-block fact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCallFact {
    /// Journal position of the event this call was extracted from.
    pub position: u64,
    /// The turn this call belongs to, when the caller supplied one.
    pub turn_id: Option<String>,
    /// Cross-message join key correlating this call to its result,
    /// approval, and any settlement.
    pub tool_call_id: String,
    /// Tool name invoked. Empty when the block carried no `function_call`.
    pub name: String,
    /// Full, untruncated call arguments.
    pub arguments: Value,
    /// Provenance/trust tag of the event this call was extracted from.
    pub trust: String,
}

/// A tool-result content-block fact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolResultFact {
    /// Journal position of the event this result was extracted from.
    pub position: u64,
    /// The turn this result belongs to, when the caller supplied one.
    pub turn_id: Option<String>,
    /// Cross-message join key correlating this result to its originating
    /// call, approval, and any settlement.
    pub tool_call_id: String,
    /// Tool name that produced the result. Empty when the block carried no
    /// `function_result`.
    pub name: String,
    /// Full, untruncated result value.
    pub result: Value,
    /// `true` when the producing tool is first-party and closed-domain
    /// (does not ingest untrusted open-world content) — see
    /// `ToolResultContent.first_party`'s wire doc comment for the
    /// lethal-trifecta quarantine this feeds.
    pub first_party: bool,
    /// Provenance/trust tag of the event this result was extracted from.
    pub trust: String,
}

/// Fold a wire [`Message`]'s content block into a fact.
///
/// `position` and `trust` are the caller's journal position and
/// provenance/trust tag for the event this message was decoded from;
/// `turn_id` is the turn the caller has already resolved for that event
/// (`None` when the event could not be attached to any turn). All three are
/// carried onto the returned [`TextFact`]/[`ToolCallFact`]/[`ToolResultFact`]
/// because they are facts about the extracted text, call, or result, not
/// something the caller re-derives — every fact struct carries the identity
/// key (`position`, `turn_id`, `trust`) the fact model requires.
///
/// A call or result extracted with `turn_id: None` cannot join any turn's
/// step list (and a settlement resolved through it would be unreachable
/// too), so folding one raises a warning naming the orphaned position —
/// callers that don't track turns (for example, a scan that only wants a
/// result's `tool_call_id`) are free to discard [`MessageContentFold::warnings`].
#[must_use]
pub fn fold_message_content(
    message: &Message,
    position: u64,
    turn_id: Option<&str>,
    trust: &str,
) -> MessageContentFold {
    let mut warnings = Vec::new();
    let Some(block) = message.content.as_option() else {
        return MessageContentFold {
            content: MessageContent::None,
            warnings,
        };
    };
    let content = match &block.r#type {
        Some(content::Type::Text(t)) => MessageContent::Text(TextFact {
            position,
            turn_id: turn_id.map(str::to_owned),
            text: t.text.clone(),
            trust: trust.to_owned(),
        }),
        Some(content::Type::ToolCall(tc)) => {
            let (name, arguments) = match &tc.r#type {
                Some(tool_call_content::Type::FunctionCall(fc)) => {
                    let arguments = fc
                        .arguments
                        .as_option()
                        .map_or(Value::Null, struct_to_value);
                    (fc.name.clone(), arguments)
                }
                _ => (String::new(), Value::Null),
            };
            if turn_id.is_none() {
                warnings.push(format!(
                    "pos {position}: tool_call with no turn suffix (orphaned, not attached to any turn)"
                ));
            }
            MessageContent::ToolCall(ToolCallFact {
                position,
                turn_id: turn_id.map(str::to_owned),
                tool_call_id: tc.id.clone(),
                name,
                arguments,
                trust: trust.to_owned(),
            })
        }
        Some(content::Type::ToolResult(tr)) => {
            let (name, result) = match &tr.r#type {
                Some(tool_result_content::Type::FunctionResult(fr)) => {
                    let result = match &fr.result {
                        Some(function_result_content::Result::Response(s)) => struct_to_value(s),
                        _ => Value::Null,
                    };
                    (fr.name.clone(), result)
                }
                _ => (String::new(), Value::Null),
            };
            if turn_id.is_none() {
                warnings.push(format!(
                    "pos {position}: tool_result with no turn suffix (orphaned, not attached to any turn)"
                ));
            }
            MessageContent::ToolResult(ToolResultFact {
                position,
                turn_id: turn_id.map(str::to_owned),
                tool_call_id: tr.call_id.clone(),
                name,
                result,
                first_party: tr.first_party,
                trust: trust.to_owned(),
            })
        }
        // Thought blocks and other content are not expanded as a fact in v1.
        _ => MessageContent::None,
    };
    MessageContentFold { content, warnings }
}

fn struct_to_value(s: &impl serde::Serialize) -> Value {
    serde_json::to_value(s).unwrap_or(Value::Null)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]

    use buffa_types::google::protobuf::Struct;
    use polyc_proto::proto::polychrome::agent::v1::{
        Content, FunctionCallContent, FunctionResultContent, TextContent, ToolCallContent,
        ToolResultContent,
    };
    use serde_json::json;

    use super::*;

    fn text_message(text: &str) -> Message {
        Message {
            role: "model".into(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::Text(Box::new(TextContent {
                    text: text.to_owned(),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        }
    }

    fn tool_call_message_fixture(id: &str, name: &str, args_json: &str) -> Message {
        let arguments = serde_json::from_str::<Struct>(args_json)
            .map(buffa::MessageField::some)
            .unwrap_or_default();
        Message {
            role: "model".into(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::ToolCall(Box::new(ToolCallContent {
                    id: id.to_owned(),
                    r#type: Some(tool_call_content::Type::FunctionCall(Box::new(
                        FunctionCallContent {
                            name: name.to_owned(),
                            arguments,
                            ..Default::default()
                        },
                    ))),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        }
    }

    fn tool_result_message_fixture(id: &str, result_json: &str) -> Message {
        let response = serde_json::from_str::<Struct>(result_json)
            .ok()
            .map(|s| function_result_content::Result::Response(Box::new(s)));
        Message {
            role: "tool".into(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::ToolResult(Box::new(ToolResultContent {
                    call_id: id.to_owned(),
                    first_party: true,
                    r#type: Some(tool_result_content::Type::FunctionResult(Box::new(
                        FunctionResultContent {
                            result: response,
                            ..Default::default()
                        },
                    ))),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        }
    }

    #[test]
    fn text_block_folds_to_text_fact() {
        let msg = text_message("hello");
        let folded = fold_message_content(&msg, 1, Some("turn-1"), "trusted_user");
        assert!(folded.warnings.is_empty());
        match folded.content {
            MessageContent::Text(t) => {
                assert_eq!(t.position, 1);
                assert_eq!(t.turn_id.as_deref(), Some("turn-1"));
                assert_eq!(t.text, "hello");
                assert_eq!(t.trust, "trusted_user");
            }
            other => panic!("expected Text, got {other:?}"),
        }
    }

    #[test]
    fn tool_call_folds_full_untruncated_arguments() {
        let msg = tool_call_message_fixture("call-1", "search", r#"{"q":"a"}"#);
        let folded = fold_message_content(&msg, 7, Some("turn-1"), "trusted_user");
        assert!(folded.warnings.is_empty());
        match folded.content {
            MessageContent::ToolCall(c) => {
                assert_eq!(c.position, 7);
                assert_eq!(c.turn_id.as_deref(), Some("turn-1"));
                assert_eq!(c.tool_call_id, "call-1");
                assert_eq!(c.name, "search");
                assert_eq!(c.arguments, json!({"q": "a"}));
                assert_eq!(c.trust, "trusted_user");
            }
            other => panic!("expected ToolCall, got {other:?}"),
        }
    }

    #[test]
    fn tool_call_with_no_turn_warns_orphaned() {
        let msg = tool_call_message_fixture("call-1", "search", r#"{}"#);
        let folded = fold_message_content(&msg, 3, None, "trusted_user");
        assert_eq!(
            folded.warnings,
            vec!["pos 3: tool_call with no turn suffix (orphaned, not attached to any turn)"]
        );
    }

    #[test]
    fn tool_result_folds_full_untruncated_result_and_first_party() {
        let msg = tool_result_message_fixture("call-1", r#"{"hits":1}"#);
        let folded = fold_message_content(&msg, 9, Some("turn-1"), "trusted_user");
        assert!(folded.warnings.is_empty());
        match folded.content {
            MessageContent::ToolResult(r) => {
                assert_eq!(r.position, 9);
                assert_eq!(r.turn_id.as_deref(), Some("turn-1"));
                assert_eq!(r.tool_call_id, "call-1");
                // google.protobuf.Struct's number_value is a double, so an
                // integer JSON literal round-trips as a float.
                assert_eq!(r.result, json!({"hits": 1.0}));
                assert!(r.first_party);
                assert_eq!(r.trust, "trusted_user");
            }
            other => panic!("expected ToolResult, got {other:?}"),
        }
    }

    #[test]
    fn tool_result_with_no_turn_warns_orphaned() {
        let msg = tool_result_message_fixture("call-1", r#"{}"#);
        let folded = fold_message_content(&msg, 4, None, "trusted_user");
        assert_eq!(
            folded.warnings,
            vec!["pos 4: tool_result with no turn suffix (orphaned, not attached to any turn)"]
        );
    }

    #[test]
    fn empty_content_folds_to_none() {
        let msg = Message {
            role: "model".into(),
            ..Default::default()
        };
        let folded = fold_message_content(&msg, 0, Some("turn-1"), "trusted_user");
        assert!(folded.warnings.is_empty());
        assert_eq!(folded.content, MessageContent::None);
    }
}