polyc-eventlog 2026.7.1

Append-only conversation event log on a commonware-storage journal.
Documentation
//! Trust/provenance capability tags and the "lethal trifecta" detector.
//!
//! Every conversation [`Event`] carries a [`TrustTag`] assigned
//! at ingress: an authenticated principal's own message is `trusted_user`,
//! while tool output, fetched content, and otherwise untrusted inbound bodies
//! are `quarantined_content`. The tag is a CaMeL-style provenance capability —
//! it travels with the event through the durable log and is the substrate a
//! data-flow security policy reasons over.
//!
//! [`trifecta_legs`] computes the **"lethal trifecta"** state for a
//! conversation: the simultaneous presence of private-data access, untrusted
//! content in the context window, and an external-communication capability.
//! Each leg alone is safe; all three together give a prompt-injection payload
//! both the data to steal and the channel to exfiltrate it. The untrusted
//! leg is read straight from the event trust tags; the other two legs are
//! supplied by the caller (deriving them from the live tool catalog, and
//! acting on a live trifecta, is deliberately left to later enforcement work).

use crate::Event;

/// Trust/provenance capability tag attached to every conversation event at
/// ingress.
///
/// The on-disk encoding is the single discriminant byte (see
/// [`TrustTag::as_u8`]); the values are stable and mirrored by the
/// `polychrome.events.v1.TrustTag` proto enum so the wire/forensics layer
/// shares one vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum TrustTag {
    /// Not yet classified — a control/marker event with no external content
    /// provenance (turn markers, usage, model scaffolding). Neither a trusted
    /// principal message nor untrusted content.
    #[default]
    Unspecified = 0,
    /// An authenticated principal's own message (the user themself).
    TrustedUser = 1,
    /// Tool output, fetched web content, or otherwise untrusted inbound
    /// content. Its mere presence in the log satisfies the trifecta's
    /// untrusted-content-in-context leg.
    QuarantinedContent = 2,
}

impl TrustTag {
    /// The stable discriminant byte, as stored in the event log codec.
    #[must_use]
    pub const fn as_u8(self) -> u8 {
        self as u8
    }

    /// Recover a [`TrustTag`] from its discriminant byte, or `None` if the
    /// byte names no known tag (a corrupt or tampered log entry).
    #[must_use]
    pub const fn from_u8(byte: u8) -> Option<Self> {
        match byte {
            0 => Some(Self::Unspecified),
            1 => Some(Self::TrustedUser),
            2 => Some(Self::QuarantinedContent),
            _ => None,
        }
    }

    /// Whether this tag marks untrusted content (the trifecta's
    /// untrusted-content-in-context leg).
    #[must_use]
    pub const fn is_quarantined(self) -> bool {
        matches!(self, Self::QuarantinedContent)
    }

    /// Stable lowercase label for forensics rendering and logs.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Unspecified => "unspecified",
            Self::TrustedUser => "trusted_user",
            Self::QuarantinedContent => "quarantined_content",
        }
    }
}

/// The three independent capability legs whose simultaneous presence in one
/// conversation forms the "lethal trifecta" data-exfiltration path.
///
/// Any single leg alone is safe. All three together mean an injected payload
/// in the untrusted content can read the private data and reach an external
/// channel to leak it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TrifectaLegs {
    /// The conversation can read private/sensitive data.
    pub private_data_access: bool,
    /// Untrusted (`quarantined_content`) content is in the context window.
    pub untrusted_content_in_context: bool,
    /// The conversation holds a capability to communicate externally.
    pub external_comms_capability: bool,
}

impl TrifectaLegs {
    /// `true` only when all three legs hold simultaneously — i.e. a live
    /// exfiltration path exists and the turn should be downgraded.
    #[must_use]
    pub const fn is_live(self) -> bool {
        self.private_data_access
            && self.untrusted_content_in_context
            && self.external_comms_capability
    }
}

/// Capabilities granted to a turn that cannot yet be derived from the tagged
/// event substrate alone — they require knowledge of the live tool catalog.
///
/// This foundation reads the untrusted-content leg from the event trust tags
/// directly; classifying which granted tools constitute private-data access
/// or an external-comms channel is the deferred enforcement work, so those two
/// legs are supplied explicitly here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GrantedCapabilities {
    /// A granted tool can read private/sensitive data.
    pub private_data_access: bool,
    /// A granted tool can communicate externally.
    pub external_comms: bool,
}

/// Compute the [`TrifectaLegs`] for a conversation from its tagged event log
/// and the capabilities granted to the turn.
///
/// The untrusted-content leg is read directly from the event trust tags (any
/// [`TrustTag::QuarantinedContent`] event means untrusted content is in the
/// context window). The private-data-access and external-comms legs come from
/// `granted`, since classifying a tool's capabilities — and enforcing a
/// downgrade when the result is live — is the deferred work this foundation
/// sets up rather than performs.
#[must_use]
pub fn trifecta_legs(events: &[Event], granted: GrantedCapabilities) -> TrifectaLegs {
    TrifectaLegs {
        private_data_access: granted.private_data_access,
        untrusted_content_in_context: any_untrusted(events),
        external_comms_capability: granted.external_comms,
    }
}

/// Whether any event in `events` carries untrusted ([`TrustTag::QuarantinedContent`])
/// provenance — the durable form of the trifecta's untrusted-content-in-context
/// leg, read straight from the trust tags.
///
/// The control plane uses this over a conversation's *full* durable log to seed
/// the agent's enforcement gate, so untrusted content that history compaction
/// folded out of the projected transcript (and is therefore invisible to the
/// agent's structural in-memory check) still keeps the leg live. See the gate's
/// `untrusted_context_seed` plumbing.
#[must_use]
pub fn any_untrusted(events: &[Event]) -> bool {
    events.iter().any(|event| event.trust.is_quarantined())
}

/// [`any_untrusted`] over position-carrying events, excluding the journal
/// positions a verified taint-excision marker covers (`#590`).
///
/// Excision recovers a conversation's grants by changing the *input* to this
/// derivation, never the rule: a quarantined event at an excised position
/// simply stops feeding the seed, exactly as if compaction had never folded
/// it in. The caller (the control plane) verifies the markers and expands
/// their scope into `excised` — this helper is pure set exclusion, so the
/// monotonicity of the taint model is untouched. An empty `excised` set is
/// byte-for-byte [`any_untrusted`].
#[must_use]
pub fn any_untrusted_excluding(
    events: &[(u64, Event)],
    excised: &std::collections::BTreeSet<u64>,
) -> bool {
    events
        .iter()
        .any(|(pos, event)| !excised.contains(pos) && event.trust.is_quarantined())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Event;

    // Foundation TDD #1: an event created from tool output is tagged
    // `quarantined_content`, and a user's own message `trusted_user`.
    #[test]
    fn ingress_tags_tool_output_quarantined_and_user_trusted() {
        let tool_output = Event::quarantined("tool_result", b"<fetched page>".to_vec());
        assert_eq!(tool_output.trust, TrustTag::QuarantinedContent);
        assert!(tool_output.trust.is_quarantined());

        let user_message = Event::trusted("user_msg", b"summarize my inbox".to_vec());
        assert_eq!(user_message.trust, TrustTag::TrustedUser);
        assert!(!user_message.trust.is_quarantined());

        // An unclassified control event carries neither trust leg.
        let marker = Event::new("turn_start", Vec::new());
        assert_eq!(marker.trust, TrustTag::Unspecified);
    }

    // Foundation TDD #2: the lethal-trifecta state is live only when all three
    // legs hold simultaneously over the tagged event set.
    #[test]
    fn trifecta_is_live_only_when_all_three_legs_hold() {
        let clean = [Event::trusted("user_msg", b"hi".to_vec())];
        let tainted = [
            Event::trusted("user_msg", b"hi".to_vec()),
            Event::quarantined("output_msg", b"<tool result>".to_vec()),
        ];
        let both = GrantedCapabilities {
            private_data_access: true,
            external_comms: true,
        };

        // All three legs present -> live.
        assert!(trifecta_legs(&tainted, both).is_live());

        // Drop any single leg -> not live.
        assert!(
            !trifecta_legs(&clean, both).is_live(),
            "no untrusted content in context"
        );
        assert!(
            !trifecta_legs(
                &tainted,
                GrantedCapabilities {
                    private_data_access: true,
                    external_comms: false,
                }
            )
            .is_live(),
            "no external comms capability"
        );
        assert!(
            !trifecta_legs(
                &tainted,
                GrantedCapabilities {
                    private_data_access: false,
                    external_comms: true,
                }
            )
            .is_live(),
            "no private data access"
        );
        assert!(
            !trifecta_legs(&tainted, GrantedCapabilities::default()).is_live(),
            "no capabilities granted"
        );

        // The untrusted-content leg is read directly from the event trust tags.
        assert!(trifecta_legs(&tainted, both).untrusted_content_in_context);
        assert!(!trifecta_legs(&clean, both).untrusted_content_in_context);
    }

    // #590: an excised quarantined position stops feeding the seed; the
    // derivation rule itself never weakens (pure set exclusion), and an
    // empty exclusion set is exactly `any_untrusted`.
    #[test]
    fn excluded_positions_recover_the_seed() {
        use std::collections::BTreeSet;
        let events: Vec<(u64, Event)> = vec![
            (0, Event::trusted("user_msg", b"hi".to_vec())),
            (
                1,
                Event::quarantined("output_msg", b"<fetched page>".to_vec()),
            ),
            (2, Event::new("turn_complete", Vec::new())),
        ];
        let none = BTreeSet::new();
        assert!(any_untrusted_excluding(&events, &none), "taint present");

        let excised: BTreeSet<u64> = [1].into();
        assert!(
            !any_untrusted_excluding(&events, &excised),
            "excising the quarantined position re-derives the seed clean"
        );

        // Excising an unrelated position changes nothing (fail closed), and
        // fresh untrusted content after an excision re-taints as before.
        let wrong: BTreeSet<u64> = [0, 2].into();
        assert!(any_untrusted_excluding(&events, &wrong));
        let mut later = events;
        later.push((3, Event::quarantined("output_msg", b"<new fetch>".to_vec())));
        assert!(any_untrusted_excluding(&later, &excised));
    }

    // `any_untrusted` is the durable seed the control plane reads over the full
    // log: true iff some event is quarantined, regardless of trusted/marker
    // events around it.
    #[test]
    fn any_untrusted_detects_a_single_quarantined_event() {
        let clean = [
            Event::trusted("user_msg", b"hi".to_vec()),
            Event::new("turn_start", Vec::new()),
        ];
        assert!(!any_untrusted(&clean));
        assert!(!any_untrusted(&[]));

        let tainted = [
            Event::trusted("user_msg", b"hi".to_vec()),
            Event::new("turn_start", Vec::new()),
            Event::quarantined("output_msg", b"<fetched page>".to_vec()),
        ];
        assert!(any_untrusted(&tainted));
    }
}