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.
//! Handoff-family event decode (`handoff`/`handoff_denied`, #1579).
//!
//! Previously, only `crates/query/src/decode/handoffs.rs` decoded these kinds.
//! The forensics collectors called the same protocol and crypto primitives
//! directly. The trace projector rendered only the kind base. Trace is now a
//! second reader, so the shared fact extraction belongs here.
//!
//! # `signature_status` is a deployment-pinned verdict, not a self-check
//!
//! [`polyc_crypto::handoff::classify_handoff`] and
//! [`classify_handoff_denied`](polyc_crypto::handoff::classify_handoff_denied)
//! read a payload against the deployment's own handoff-role trust set
//! (#1124). The verdict answers two questions at once. The signature must
//! check out against the encoded public key the payload carries
//! (`signed_by`). That key must also belong to the deployment's handoff role.
//! A payload that checks out under a key the deployment never held reads as
//! [`SignatureVerdict::Untrusted`], never as verified.
//!
//! # A tampered or untrusted event stays visible
//!
//! This matches [`crate::approvals`] and [`crate::grant_replays`]. Every
//! decodable payload produces a [`HandoffFact`] with its verdict. This keeps
//! forged, tampered, and foreign-signed events visible. Only malformed payloads
//! return `None`.
//!
//! # What stays out of this fact
//!
//! Every fact exposes `signed_by`. `polyc-query` limits it to Fleet scope in a
//! view. This fold does not enforce that query-layer policy. Each surface also
//! chooses how to render `HandoffDenied::allowed`.

use polyc_crypto::signing_role::{HandoffRole, RoleTrustSet, SignatureVerdict};
use polyc_proto::proto::polychrome::handoff::v1::{Handoff, HandoffDenied};

/// One decoded handoff-family fact, discriminated by which of the two
/// signed proto shapes it came from.
#[derive(Debug, Clone)]
pub enum HandoffFact {
    /// A `handoff` event: the parent asked to transfer to a child agent.
    Handoff(HandoffSpawnFact),
    /// A `handoff_denied` event: the transfer was refused.
    Denied(HandoffDeniedFact),
}

/// Fields of a `handoff` event.
#[derive(Debug, Clone)]
pub struct HandoffSpawnFact {
    /// The child conversation this transfer names.
    pub child_conversation_id: String,
    /// The child agent identifier the transfer named.
    pub child_agent_id: String,
    /// How many parent-transcript messages were carried into the child.
    pub carried_count: u32,
    /// The freeform reason given for the requested transfer.
    pub reason: String,
    /// The embedded ed25519 public key the event claims to be signed by.
    pub signed_by: Vec<u8>,
    /// How the signature reads against the deployment's handoff-role trust
    /// set (see the module docs).
    pub signature_status: SignatureVerdict,
}

/// Fields of a `handoff_denied` event.
#[derive(Debug, Clone)]
pub struct HandoffDeniedFact {
    /// The parent's bound `Agent` resource name at request time.
    pub parent_agent_id: String,
    /// The child agent identifier the transfer named and was refused.
    pub child_agent_id: String,
    /// The freeform reason given for the requested transfer.
    pub reason: String,
    /// Plain-language explanation of why the transfer was refused.
    pub denial_reason: String,
    /// The parent's declared `canHandoffTo` allowlist at request time.
    pub allowed: Vec<String>,
    /// The embedded ed25519 public key the event claims to be signed by.
    pub signed_by: Vec<u8>,
    /// How the signature reads against the deployment's handoff-role trust
    /// set (see the module docs).
    pub signature_status: SignatureVerdict,
}

/// Decode one `handoff`/`handoff_denied` event's payload into its
/// [`HandoffFact`].
///
/// This is the shared decode and verification primitive for query and trace
/// (#1579). It keeps both surfaces consistent.
///
/// `base` should be a kind-base already matched via
/// [`polyc_proto::kinds::parse`] — [`polyc_proto::kinds::HANDOFF`] or
/// [`polyc_proto::kinds::HANDOFF_DENIED`]. Any other value returns `None`
/// without decoding. `None` also covers malformed payloads. The caller decides
/// whether to warn.
///
/// A decodable payload with a failed signature still returns a fact. It carries
/// every field with an
/// [`SignatureVerdict::Invalid`] or [`SignatureVerdict::Untrusted`] status
/// (see the module docs). `handoff_trust` contains the current and retired
/// handoff keys.
#[must_use]
pub fn fold_handoff_event(
    base: &str,
    payload: &[u8],
    handoff_trust: &RoleTrustSet<HandoffRole>,
) -> Option<HandoffFact> {
    if base == polyc_proto::kinds::HANDOFF {
        let h = polyc_proto::events_decode::decode_event_payload::<Handoff>(payload)?;
        let signature_status = polyc_crypto::handoff::classify_handoff(handoff_trust, &h);
        Some(HandoffFact::Handoff(HandoffSpawnFact {
            child_conversation_id: h.child_conversation_id,
            child_agent_id: h.child_agent_id,
            carried_count: h.carried_count,
            reason: h.reason,
            signed_by: h.signed_by,
            signature_status,
        }))
    } else if base == polyc_proto::kinds::HANDOFF_DENIED {
        let d = polyc_proto::events_decode::decode_event_payload::<HandoffDenied>(payload)?;
        let signature_status = polyc_crypto::handoff::classify_handoff_denied(handoff_trust, &d);
        Some(HandoffFact::Denied(HandoffDeniedFact {
            parent_agent_id: d.parent_agent_id,
            child_agent_id: d.child_agent_id,
            reason: d.reason,
            denial_reason: d.denial_reason,
            allowed: d.allowed,
            signed_by: d.signed_by,
            signature_status,
        }))
    } else {
        None
    }
}

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

    use buffa::Message as _;
    use polyc_crypto::signing_role::HandoffSigner;
    use polyc_proto::proto::polychrome::agent::v1::{Content, Message, TextContent, content};

    use super::*;

    /// The deployment trust set that holds exactly `signer`'s key.
    fn trust(signer: &HandoffSigner) -> RoleTrustSet<HandoffRole> {
        RoleTrustSet::current(signer)
    }

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

    fn signed_handoff(signer: &HandoffSigner, child_conversation_id: &str) -> Handoff {
        let mut h = Handoff {
            child_conversation_id: child_conversation_id.to_owned(),
            child_agent_id: "researcher".to_owned(),
            carried_count: 2,
            carried_context: vec![text_msg("user", "find prior art")],
            reason: "delegate research".to_owned(),
            ..Default::default()
        };
        polyc_crypto::handoff::sign_handoff_into(signer, &mut h);
        h
    }

    fn signed_denied(signer: &HandoffSigner) -> HandoffDenied {
        let mut d = HandoffDenied {
            parent_conversation_id: "parent-7".to_owned(),
            parent_agent_id: "assistant".to_owned(),
            child_agent_id: "banned-agent".to_owned(),
            reason: "delegate weird task".to_owned(),
            denial_reason: "this agent can't hand off to that agent".to_owned(),
            allowed: vec!["coding".to_owned(), "research".to_owned()],
            ..Default::default()
        };
        polyc_crypto::handoff::sign_handoff_denied_into(signer, &mut d);
        d
    }

    #[test]
    fn decodes_a_signed_handoff() {
        let signer = HandoffSigner::from_seed(21);
        let handoff = signed_handoff(&signer, "child-a");
        let bytes = handoff.encode_to_vec();

        let fact = fold_handoff_event(polyc_proto::kinds::HANDOFF, &bytes, &trust(&signer))
            .expect("decode");
        let HandoffFact::Handoff(h) = fact else {
            panic!("expected Handoff variant");
        };
        assert_eq!(h.child_conversation_id, "child-a");
        assert_eq!(h.child_agent_id, "researcher");
        assert_eq!(h.carried_count, 2);
        assert_eq!(h.reason, "delegate research");
        assert_eq!(h.signature_status, SignatureVerdict::Verified);
    }

    #[test]
    fn decodes_a_signed_denial() {
        let signer = HandoffSigner::from_seed(22);
        let denied = signed_denied(&signer);
        let bytes = denied.encode_to_vec();

        let fact = fold_handoff_event(polyc_proto::kinds::HANDOFF_DENIED, &bytes, &trust(&signer))
            .expect("decode");
        let HandoffFact::Denied(d) = fact else {
            panic!("expected Denied variant");
        };
        assert_eq!(d.child_agent_id, "banned-agent");
        assert_eq!(d.parent_agent_id, "assistant");
        assert_eq!(d.denial_reason, "this agent can't hand off to that agent");
        assert_eq!(d.allowed, vec!["coding".to_owned(), "research".to_owned()]);
        assert_eq!(d.signature_status, SignatureVerdict::Verified);
    }

    #[test]
    fn tampered_handoff_keeps_the_fact_but_reads_invalid() {
        let signer = HandoffSigner::from_seed(22);
        let mut handoff = signed_handoff(&signer, "child-tampered");
        handoff.child_conversation_id = "child-evil".to_owned();
        let bytes = handoff.encode_to_vec();

        let fact = fold_handoff_event(polyc_proto::kinds::HANDOFF, &bytes, &trust(&signer))
            .expect("still decodes");
        let HandoffFact::Handoff(h) = fact else {
            panic!("expected Handoff variant");
        };
        assert_eq!(h.signature_status, SignatureVerdict::Invalid);
        assert_eq!(h.child_conversation_id, "child-evil");
    }

    /// A handoff signed by a key this deployment never held keeps its fact
    /// and reads untrusted — the #1124 verdict a self-check could not give.
    #[test]
    fn a_foreign_signed_handoff_keeps_the_fact_but_reads_untrusted() {
        let signer = HandoffSigner::from_seed(31);
        let deployment = HandoffSigner::from_seed(32);
        let handoff = signed_handoff(&signer, "child-foreign");
        let bytes = handoff.encode_to_vec();

        let fact = fold_handoff_event(polyc_proto::kinds::HANDOFF, &bytes, &trust(&deployment))
            .expect("still decodes");
        let HandoffFact::Handoff(h) = fact else {
            panic!("expected Handoff variant");
        };
        assert_eq!(h.signature_status, SignatureVerdict::Untrusted);
        assert_eq!(h.child_conversation_id, "child-foreign");
    }

    #[test]
    fn structurally_malformed_payload_returns_none() {
        let signer = HandoffSigner::from_seed(21);
        assert!(
            fold_handoff_event(
                polyc_proto::kinds::HANDOFF,
                &[0xFF, 0xFE, 0xFD],
                &trust(&signer)
            )
            .is_none()
        );
    }

    #[test]
    fn unrelated_kind_base_returns_none() {
        let signer = HandoffSigner::from_seed(21);
        let handoff = signed_handoff(&signer, "child-a");
        let bytes = handoff.encode_to_vec();
        assert!(fold_handoff_event(polyc_proto::kinds::USAGE, &bytes, &trust(&signer)).is_none());
    }
}