polyc-facts 2026.9.0

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.
//! Paused-turn narration withholding (`#743`), applied at read time.
//!
//! A turn that pauses for a human must not let its own model-authored
//! narration reach a client as a status claim: "I've removed it" is false
//! while the removal is still waiting on someone. Before D5 that was enforced
//! in memory — a turn was one atomic batch, so marking the messages
//! `internal_only` at the pause reached the durable copy on the way out.
//!
//! D5 makes a turn a sequence of durable steps. An earlier step is committed,
//! and unmarked, before anything knows a later step will pause, and the
//! journal is append-only, so nothing can go back and mark it.
//!
//! So the turn's terminal batch appends a **content-free marker**
//! ([`polyc_proto::kinds::TURN_TEXT_WITHHELD`]) and the read path applies it.
//! That is the same shape [`crate::excision`] uses for taint, and for the same
//! reason: an append-only log cannot be rewritten, so a later record has to
//! change how an earlier one reads.
//!
//! The marker carries no text. It names a turn and an intent, and every one of
//! that turn's events already carries its turn id, so nothing more is needed.
//! A marker that embedded the narration would durably duplicate the very thing
//! being redacted.
//!
//! Deferring the narration to the terminal batch instead was considered and
//! rejected: journal order is transcript order, so it would have reordered
//! every multi-step turn, landing narration after the tool traffic it
//! introduced — in the durable transcript, the user-visible history, and the
//! prompt fed back on resume.

use std::collections::BTreeSet;

use polyc_eventlog_model::Event;
use polyc_proto::kinds;
use polyc_proto::proto::polychrome::agent::v1::{Message, content};

/// Every turn whose narration a [`kinds::TURN_TEXT_WITHHELD`] marker withholds.
///
/// Reads only the kind tag, so a malformed or empty payload cannot change the
/// answer, and an untagged marker contributes nothing.
#[must_use]
pub fn withheld_turn_ids(events: &[Event]) -> BTreeSet<uuid::Uuid> {
    events
        .iter()
        .filter_map(|ev| {
            let (base, turn) = kinds::parse(&ev.kind);
            (base == kinds::TURN_TEXT_WITHHELD)
                .then_some(turn)
                .flatten()
        })
        .collect()
}

/// Marks every withheld turn's model-authored text `internal_only`.
///
/// Applied before any projection, so the durable read path and the delivery
/// path get the same answer from one place.
///
/// Only `model`-role text is touched. A tool result, a user message, and the
/// model's own tool calls all stay exactly as they were: the rule is about a
/// false status claim, not about hiding what a turn did. The message is
/// re-encoded rather than blanked, so the transcript stays readable to an
/// admin and to the resumed prompt, which ignores `internal_only` by design.
pub fn withhold_paused_turn_text(events: &mut [Event], withheld: &BTreeSet<uuid::Uuid>) {
    withhold_each(events.iter_mut(), withheld);
}

/// [`withheld_turn_ids`] over events that still carry their journal position.
///
/// The position is irrelevant to the rule — the turn id rides the kind tag —
/// so this exists only so a caller holding the positioned shape does not have
/// to copy it.
#[must_use]
pub fn withheld_turn_ids_positioned(events: &[(u64, Event)]) -> BTreeSet<uuid::Uuid> {
    events
        .iter()
        .filter_map(|(_, ev)| {
            let (base, turn) = kinds::parse(&ev.kind);
            (base == kinds::TURN_TEXT_WITHHELD)
                .then_some(turn)
                .flatten()
        })
        .collect()
}

/// [`withhold_paused_turn_text`] over events that still carry their position.
pub fn withhold_paused_turn_text_positioned(
    events: &mut [(u64, Event)],
    withheld: &BTreeSet<uuid::Uuid>,
) {
    withhold_each(events.iter_mut().map(|(_, ev)| ev), withheld);
}

/// The one implementation both shapes share.
fn withhold_each<'a>(events: impl Iterator<Item = &'a mut Event>, withheld: &BTreeSet<uuid::Uuid>) {
    if withheld.is_empty() {
        return;
    }
    for ev in events {
        let (base, turn) = kinds::parse(&ev.kind);
        if base != kinds::OUTPUT_MSG || !turn.is_some_and(|t| withheld.contains(&t)) {
            continue;
        }
        let Some(mut message) =
            polyc_proto::events_decode::try_decode_event_payload::<Message>(&ev.payload).ok()
        else {
            continue;
        };
        let is_model_text = message.role == "model"
            && matches!(
                message.content.as_option().and_then(|c| c.r#type.as_ref()),
                Some(content::Type::Text(_))
            );
        if !is_model_text || message.internal_only {
            continue;
        }
        message.internal_only = true;
        ev.payload = buffa::Message::encode_to_vec(&message);
    }
}

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

    use super::*;
    use polyc_proto::proto::polychrome::agent::v1::{Content, TextContent};

    fn text(turn: &uuid::Uuid, body: &str, role: &str) -> Event {
        let message = Message {
            role: role.to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::Text(Box::new(TextContent {
                    text: body.to_owned(),
                    __buffa_unknown_fields: buffa::UnknownFields::default(),
                }))),
                __buffa_unknown_fields: buffa::UnknownFields::default(),
            }),
            internal_only: false,
            __buffa_unknown_fields: buffa::UnknownFields::default(),
        };
        Event::new(
            kinds::tagged(kinds::OUTPUT_MSG, turn),
            buffa::Message::encode_to_vec(&message),
        )
    }

    fn decoded(ev: &Event) -> Message {
        polyc_proto::events_decode::try_decode_event_payload::<Message>(&ev.payload)
            .expect("the rewritten payload still decodes")
    }

    #[test]
    fn a_marker_names_its_turn_and_carries_nothing() {
        let turn = uuid::Uuid::now_v7();
        let marker = Event::new(kinds::tagged(kinds::TURN_TEXT_WITHHELD, &turn), Vec::new());
        assert!(
            marker.payload.is_empty(),
            "the marker must carry no content of its own"
        );
        assert_eq!(withheld_turn_ids(&[marker]), [turn].into_iter().collect());
    }

    #[test]
    fn only_the_named_turns_model_text_is_withheld() {
        let paused = uuid::Uuid::now_v7();
        let other = uuid::Uuid::now_v7();
        let mut events = vec![
            text(&paused, "pending your approval", "model"),
            text(&paused, "a tool said so", "tool"),
            text(&other, "an unrelated turn", "model"),
        ];
        withhold_paused_turn_text(&mut events, &[paused].into_iter().collect());

        assert!(
            decoded(&events[0]).internal_only,
            "the paused turn's model narration is withheld"
        );
        assert!(
            !decoded(&events[1]).internal_only,
            "a tool result is not narration and stays visible"
        );
        assert!(
            !decoded(&events[2]).internal_only,
            "another turn's narration is untouched"
        );
        assert_eq!(
            decoded(&events[0]).content.as_option().and_then(|c| {
                match c.r#type.as_ref() {
                    Some(content::Type::Text(t)) => Some(t.text.clone()),
                    _ => None,
                }
            }),
            Some("pending your approval".to_owned()),
            "withholding marks the message; it never blanks the text an admin \
             and the resumed prompt still need"
        );
    }

    #[test]
    fn no_marker_changes_nothing() {
        let turn = uuid::Uuid::now_v7();
        let mut events = vec![text(&turn, "ordinary narration", "model")];
        let before = events[0].payload.clone();
        withhold_paused_turn_text(&mut events, &BTreeSet::new());
        assert_eq!(events[0].payload, before);
    }
}