polyc-facts 2026.10.2

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.
//! `summary`, `summary_gate_rejected`, and `summary_gate_admitted` decode.
//!
//! These three payloads decoded to meaning in `crates/control-plane`'s
//! `grpc::projection` alone, because until now only Control read them. The
//! conversation-trace fold reads all three, and it lives here, so the decode
//! moves to the crate both callers can reach rather than the trace reaching
//! upward into a container.
//!
//! The line this module draws is [`crate::usage`]'s: a fold is the decode
//! primitive and returns a `Result`; what a caller does with an `Err` is the
//! caller's own policy. Control's summary reader warns and ignores; the
//! trace fold emits a `decode_failed` warning row and keeps the position.
//! Neither policy belongs here.

use polyc_proto::proto::polychrome::events::v1::{
    SummaryEvent, SummaryGateAdmittedEvent, SummaryGateRejectedEvent,
};

/// Decoded `summary` fact: the summary text and the position it covers
/// through.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SummaryFact {
    /// The summary text as recorded.
    pub text: String,
    /// The last source position this summary covers.
    pub covers_through_position: u64,
}

impl From<SummaryEvent> for SummaryFact {
    fn from(event: SummaryEvent) -> Self {
        Self {
            text: event.text,
            covers_through_position: event.covers_through_position,
        }
    }
}

/// Decoded `summary_gate_rejected` or `summary_gate_admitted` fact.
///
/// One type for both kinds because they carry the same two fields. The
/// KIND is what separates a rejection from a forced admission, and the
/// caller already knows which one it read — carrying a discriminator here
/// would invite a reader to trust the payload over the kind it arrived
/// under.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SummaryGateFact {
    /// The identifiers a faithful summary was expected to restate.
    pub dropped_identifiers: Vec<String>,
    /// The rejection count this record carries.
    ///
    /// `dropped_count` on a rejection, `reject_count` on an admission. The
    /// two proto fields are distinct names for the same quantity at
    /// different moments, so one field holds whichever the kind supplied.
    pub count: u32,
}

/// Decodes a `summary` event payload into its [`SummaryFact`].
///
/// # Errors
///
/// Returns the underlying decode error on a structurally malformed payload.
pub fn fold_summary_event(payload: &[u8]) -> Result<SummaryFact, buffa::DecodeError> {
    polyc_proto::events_decode::try_decode_event_payload::<SummaryEvent>(payload)
        .map(SummaryFact::from)
}

/// Decodes a `summary_gate_rejected` event payload.
///
/// # Errors
///
/// Returns the underlying decode error on a structurally malformed payload.
pub fn fold_summary_gate_rejected_event(
    payload: &[u8],
) -> Result<SummaryGateFact, buffa::DecodeError> {
    polyc_proto::events_decode::try_decode_event_payload::<SummaryGateRejectedEvent>(payload).map(
        |event| SummaryGateFact {
            dropped_identifiers: event.dropped_identifiers,
            count: event.dropped_count,
        },
    )
}

/// Decodes a `summary_gate_admitted` event payload.
///
/// # Errors
///
/// Returns the underlying decode error on a structurally malformed payload.
pub fn fold_summary_gate_admitted_event(
    payload: &[u8],
) -> Result<SummaryGateFact, buffa::DecodeError> {
    polyc_proto::events_decode::try_decode_event_payload::<SummaryGateAdmittedEvent>(payload).map(
        |event| SummaryGateFact {
            dropped_identifiers: event.dropped_identifiers,
            count: event.reject_count,
        },
    )
}

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

    use super::*;
    use buffa::Message as _;

    #[test]
    fn a_summary_payload_decodes_to_its_text_and_coverage() {
        let payload = SummaryEvent {
            text: "the conversation so far".to_owned(),
            covers_through_position: 42,
            ..Default::default()
        }
        .encode_to_vec();

        assert_eq!(
            fold_summary_event(&payload).unwrap(),
            SummaryFact {
                text: "the conversation so far".to_owned(),
                covers_through_position: 42,
            }
        );
    }

    /// An empty payload is a clean decode, not a failure.
    ///
    /// Proto3 elides all-default scalars, so an empty summary encodes to
    /// zero bytes. A fold that treated empty as malformed would report a
    /// decode failure for a record that is merely empty, and Control's
    /// reader would warn about a corrupt payload that is not corrupt.
    #[test]
    fn an_empty_payload_decodes_to_an_empty_fact() {
        assert_eq!(fold_summary_event(&[]).unwrap(), SummaryFact::default());
        assert_eq!(
            fold_summary_gate_rejected_event(&[]).unwrap(),
            SummaryGateFact::default()
        );
    }

    #[test]
    fn a_malformed_payload_returns_its_error() {
        // A wire type the message does not declare.
        let corrupt = [0xffu8, 0xff, 0xff, 0xff];
        assert!(fold_summary_event(&corrupt).is_err());
        assert!(fold_summary_gate_rejected_event(&corrupt).is_err());
        assert!(fold_summary_gate_admitted_event(&corrupt).is_err());
    }

    /// The two gate kinds read their own count field.
    ///
    /// `summary_gate_rejected` carries `dropped_count` and
    /// `summary_gate_admitted` carries `reject_count`. Reading one field
    /// name for both would report zero for one of the two kinds, and the
    /// zero would look like a real count.
    #[test]
    fn each_gate_kind_reads_the_count_its_own_payload_carries() {
        let rejected = SummaryGateRejectedEvent {
            dropped_identifiers: vec!["invoice-7".to_owned()],
            dropped_count: 3,
            ..Default::default()
        }
        .encode_to_vec();
        assert_eq!(
            fold_summary_gate_rejected_event(&rejected).unwrap(),
            SummaryGateFact {
                dropped_identifiers: vec!["invoice-7".to_owned()],
                count: 3,
            }
        );

        let admitted = SummaryGateAdmittedEvent {
            dropped_identifiers: vec!["invoice-7".to_owned()],
            reject_count: 5,
            ..Default::default()
        }
        .encode_to_vec();
        assert_eq!(
            fold_summary_gate_admitted_event(&admitted).unwrap(),
            SummaryGateFact {
                dropped_identifiers: vec!["invoice-7".to_owned()],
                count: 5,
            }
        );
    }
}