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.
//! Commit scoping, and the searchable message projection built on it.
//!
//! This module deliberately owns a POLICY the rest of this crate leaves to
//! its callers. Every other fold here answers "what does this event say";
//! `crate::attribution`'s own doc is explicit that consumers "disagree on what
//! 'committed' means — full `turn_start`+`turn_complete`, `turn_complete`-only,
//! or no scoping at all", and that the choice "stays with the caller".
//!
//! That was right while commit scoping was one caller's opinion. It stopped
//! being right once two surfaces needed the SAME answer: history navigation
//! and participation-scoped search must agree on which turns are searchable,
//! or the same question returns different results depending on which one is
//! asked (`docs/proposals/participation-scoped-agent-search.md`). Two
//! implementations of a rule that must match is the shape that drifts.
//!
//! So the rule lives here, once: **a turn is committed when BOTH its
//! `turn_start` and `turn_complete` markers are present.** The narrower
//! variants the attribution doc mentions remain the caller's business; what
//! moved inward is this one, for the consumers that must not disagree.
//!
//! # What this module does not own
//!
//! Excision, which is [`crate::excision`]'s job, and which callers apply
//! BEFORE projecting: [`committed_message_facts`] reads whatever events it is
//! given. That split is deliberate — a caller typically needs the excised
//! position set for its own purposes (the history navigator indexes recorded
//! tool results by call id *before* stripping, so an excised result can still
//! answer an honest "this was removed" instead of "no such result"), and
//! folding excision into this call would hide the set it needs.
//!
//! Presentation is likewise not owned here: no truncation, no snippet
//! windowing, no ranking. The facts come out whole and the caller shapes them.

use std::collections::{HashMap, HashSet};

use polyc_eventlog::Event;
use polyc_proto::proto::polychrome::agent::v1::Message;
use polyc_proto::{events_decode::decode_event_payload, kinds};

use crate::message_content::{MessageContent, fold_message_content};

/// One committed, searchable message: the text a person or the model actually
/// said, with enough coordinates to locate it again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommittedMessageFact {
    /// The committed turn this message belongs to.
    pub turn_id: String,
    /// Ordinal within the replayed sequence, NOT a journal position.
    ///
    /// Journal append order is conversation order, so an index into the
    /// replayed slice is a monotonic within-conversation ordinal — which is
    /// what newest-first tie-breaking needs. It counts every replayed event,
    /// including the ones this projection skips, so it stays stable when a
    /// caller has stripped excised events in place (their slots survive as
    /// inert stand-ins, by [`crate::strip_excised`]'s own contract).
    pub ordinal: u64,
    /// The message's full, untruncated text.
    pub text: String,
}

/// The set of fully-committed turn ids in a replayed log: a turn id counts as
/// committed once BOTH its `turn_start` and `turn_complete` markers are
/// present.
///
/// The single definition of "committed" for every consumer that must agree on
/// it — see this module's own doc for why that is a policy this crate owns
/// rather than leaves to callers.
#[must_use]
pub fn committed_turn_ids<'a>(events: impl IntoIterator<Item = &'a Event>) -> HashSet<uuid::Uuid> {
    let mut markers: HashMap<uuid::Uuid, (bool, bool)> = HashMap::new();
    for ev in events {
        let (base, turn_id) = kinds::parse(&ev.kind);
        if let Some(id) = turn_id {
            let entry = markers.entry(id).or_default();
            if base == kinds::TURN_START {
                entry.0 = true;
            }
            if base == kinds::TURN_COMPLETE {
                entry.1 = true;
            }
        }
    }
    markers
        .into_iter()
        .filter_map(|(id, (start, complete))| (start && complete).then_some(id))
        .collect()
}

/// Project a replayed log into its committed, searchable message facts.
///
/// Only events belonging to a COMMITTED turn are projected: an interrupted or
/// rolled-back turn is skipped entirely, so no surface can surface text the
/// conversation never committed.
///
/// Only `user_msg` and `output_msg` events carrying a TEXT content block
/// produce a fact. A tool call, a tool result, and a reasoning thought each
/// yield nothing — text is never synthesized from a structured block, and a
/// message whose text is empty is dropped rather than returned blank.
///
/// An internal-only message produces NOTHING. It is scaffolding a person was
/// never shown, or narration a withholding marker retracted — and a withheld
/// turn's narration is MARKED, not blanked
/// ([`crate::withhold_paused_turn_text`] sets exactly that flag), so a caller
/// that received the text without the flag would show what the marker
/// retracted. That is `#2702`: the agent's own history search read back a
/// paused turn's narration because this fold handed it over silently.
///
/// Dropped HERE rather than left to each caller. Both callers want it dropped,
/// and a rule every caller must remember is a rule a third caller forgets — in
/// this case by adding a consumer that compiles and leaks. A caller that ever
/// needs to render what the model saw asks for it explicitly, with a function
/// that says so in its name.
///
/// Excision is the caller's to apply first; see this module's doc.
#[must_use]
pub fn committed_message_facts(events: &[Event]) -> Vec<CommittedMessageFact> {
    let committed = committed_turn_ids(events);
    events
        .iter()
        .enumerate()
        .filter_map(|(ordinal, ev)| {
            let (base, turn_id) = kinds::parse(&ev.kind);
            if base != kinds::USER_MSG && base != kinds::OUTPUT_MSG {
                return None;
            }
            // Dropped before decoding: an uncommitted turn's bytes never need
            // to be looked at.
            let turn_id = turn_id.filter(|id| committed.contains(id))?;
            let msg = decode_event_payload::<Message>(&ev.payload)?;
            let MessageContent::Text(text) =
                fold_message_content(&msg, ordinal as u64, None, "").content
            else {
                return None;
            };
            if text.text.is_empty() || msg.internal_only {
                return None;
            }
            Some(CommittedMessageFact {
                turn_id: turn_id.to_string(),
                ordinal: ordinal as u64,
                text: text.text,
            })
        })
        .collect()
}

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

    use super::*;
    use buffa::Message as _;
    use polyc_proto::proto::polychrome::agent::v1::{
        Content, TextContent, ToolCallContent, content,
    };

    fn turn() -> uuid::Uuid {
        uuid::Uuid::parse_str("01950000-0000-7000-8000-00000000aaaa").expect("uuid")
    }

    fn tagged(base: &str) -> String {
        format!("{base}:{}", turn().simple())
    }

    fn text_event(base: &str, text: &str) -> Event {
        let msg = Message {
            role: "user".to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::Text(Box::new(TextContent {
                    text: text.to_owned(),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        };
        Event::new(tagged(base), msg.encode_to_vec())
    }

    fn committed_log(text: &str) -> Vec<Event> {
        vec![
            Event::new(tagged(kinds::TURN_START), Vec::new()),
            text_event(kinds::USER_MSG, text),
            Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()),
        ]
    }

    /// The commit rule, in both directions: a turn missing either marker
    /// contributes nothing, and the same events with both markers project.
    #[test]
    fn only_turns_with_both_markers_project() {
        let committed = committed_message_facts(&committed_log("hello"));
        assert_eq!(committed.len(), 1);
        assert_eq!(committed[0].text, "hello");
        assert_eq!(committed[0].turn_id, turn().to_string());

        // Same message, no `turn_complete`: the turn never committed, so its
        // text must not be searchable.
        let interrupted = vec![
            Event::new(tagged(kinds::TURN_START), Vec::new()),
            text_event(kinds::USER_MSG, "hello"),
        ];
        assert!(
            committed_message_facts(&interrupted).is_empty(),
            "an interrupted turn's text must never surface"
        );

        // And a `turn_complete` with no start is equally uncommitted — the
        // rule is both markers, not either.
        let headless = vec![
            text_event(kinds::USER_MSG, "hello"),
            Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()),
        ];
        assert!(committed_message_facts(&headless).is_empty());
    }

    /// `ordinal` counts every replayed event, including the ones this
    /// projection skips — that is what keeps it aligned with a caller's own
    /// index into the same slice after excision stripping leaves inert
    /// stand-ins in place.
    #[test]
    fn ordinal_counts_skipped_events_too() {
        let mut events = committed_log("first");
        events.push(text_event(kinds::OUTPUT_MSG, "second"));
        events.push(Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()));

        let facts = committed_message_facts(&events);
        let ordinals: Vec<u64> = facts.iter().map(|f| f.ordinal).collect();
        assert_eq!(
            ordinals,
            vec![1, 3],
            "positions 0 and 2 are markers — skipped, but still counted"
        );
    }

    /// Text is never synthesized from a structured block, and an empty text
    /// block is dropped rather than returned blank.
    #[test]
    fn only_text_blocks_produce_facts() {
        let tool_call = Message {
            role: "model".to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::ToolCall(Box::new(ToolCallContent {
                    id: "call-1".to_owned(),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        };
        let thought = Message {
            role: "model".to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::Thought(Box::default())),
                ..Default::default()
            }),
            ..Default::default()
        };

        let events = vec![
            Event::new(tagged(kinds::TURN_START), Vec::new()),
            Event::new(tagged(kinds::OUTPUT_MSG), tool_call.encode_to_vec()),
            Event::new(tagged(kinds::OUTPUT_MSG), thought.encode_to_vec()),
            text_event(kinds::OUTPUT_MSG, ""),
            Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()),
        ];

        assert!(
            committed_message_facts(&events).is_empty(),
            "a tool call, a reasoning thought, and an empty text block each yield nothing"
        );
    }

    /// Only chat kinds are considered: a committed turn's usage or approval
    /// records are not searchable prose even though they carry the turn tag.
    #[test]
    fn non_chat_kinds_are_skipped() {
        let mut events = committed_log("hello");
        events.insert(2, Event::new(tagged(kinds::USAGE), b"whatever".to_vec()));

        let facts = committed_message_facts(&events);
        assert_eq!(facts.len(), 1, "only the user message projects");
        assert_eq!(facts[0].text, "hello");
    }
}