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.
//! Sub-agent delegation event decode (`__delegate_to`, #872/#1579).
//!
//! `crates/control-plane/src/subagent.rs` owns signing and record construction.
//! It needs the harness record and process signer. This module owns the read
//! side. It decodes sub-agent events within the shared event payload limit.
//! This gives future consumers one mechanical bytes-to-fact decode without a
//! state machine or ordering policy.
//!
//! # The two signed kinds carry a deployment-pinned verdict
//!
//! `subagent_spawn` and `subagent_result` are signed records (#872). This
//! fold reads each signature against the deployment's sub-agent-role trust
//! set and reports a [`SignatureVerdict`] (#1124). Before that, no production
//! reader checked a sub-agent signature at all. The fold returns its own
//! [`SubagentSpawnFact`]/[`SubagentResultFact`] rather than the wire struct,
//! because the wire struct has no field to carry the verdict.
//!
//! `subagent_model_call` stays unsigned by design, so
//! [`fold_subagent_model_call`] still returns the wire struct.
//!
//! # A tampered or untrusted event stays visible
//!
//! Same posture as [`crate::handoffs`]/[`crate::approvals`]: a payload that
//! decodes at all always produces a fact carrying its verdict. Only a decode
//! failure — including an over-cap payload — returns `None`, a "treat as
//! absent, let the caller warn" case for every consumer today.

use polyc_crypto::signing_role::{RoleTrustSet, SignatureVerdict, SubagentRole};
use polyc_proto::proto::polychrome::subagent::v1::SubagentModelCallEvent;

/// Fields of a `subagent_spawn` event, plus its signature verdict.
#[derive(Debug, Clone)]
pub struct SubagentSpawnFact {
    /// The delegating tool call's id, shared with the matching result.
    pub sub_agent_id: String,
    /// The worker `Agent` resource name the delegation named.
    pub target_agent_id: String,
    /// The provider the worker turn resolved to.
    pub resolved_provider: String,
    /// The model the worker turn resolved to.
    pub resolved_model: String,
    /// The task text handed to the worker.
    pub task: 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 sub-agent-role trust
    /// set (see the module docs).
    pub signature_status: SignatureVerdict,
}

/// Fields of a `subagent_result` event, plus its signature verdict.
#[derive(Debug, Clone)]
pub struct SubagentResultFact {
    /// Matches the originating spawn's `sub_agent_id`.
    pub sub_agent_id: String,
    /// The worker `Agent` resource name that ran.
    pub target_agent_id: String,
    /// Whether the worker turn produced an answer.
    pub succeeded: bool,
    /// Plain-language failure reason; empty on success.
    pub error: String,
    /// Input tokens the worker's nested turn accumulated.
    pub input_tokens: u64,
    /// Output tokens the worker's nested turn accumulated.
    pub output_tokens: u64,
    /// Whether the worker ran on this deployment's own provider credentials.
    pub first_party: bool,
    /// 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 sub-agent-role trust
    /// set (see the module docs).
    pub signature_status: SignatureVerdict,
}

/// Decode a `subagent_spawn` payload into a [`SubagentSpawnFact`], bounded by
/// the shared eventlog payload cap.
///
/// [`polyc_proto::events_decode::MAX_EVENT_PAYLOAD_BYTES`] sets the bound.
/// Decode failure returns `None`, including an over-cap payload. The caller
/// decides whether to warn.
///
/// `subagent_trust` is the deployment's sub-agent-role trust set: the current
/// sub-agent signer plus every key it has retired. A decodable payload with a
/// failed signature still returns a fact. See the module documentation.
#[must_use]
pub fn fold_subagent_spawn(
    payload: &[u8],
    subagent_trust: &RoleTrustSet<SubagentRole>,
) -> Option<SubagentSpawnFact> {
    let spawn = polyc_proto::events_decode::decode_event_payload::<
        polyc_proto::proto::polychrome::subagent::v1::SubagentSpawn,
    >(payload)?;
    let signature_status = polyc_crypto::subagent::classify_subagent_spawn(subagent_trust, &spawn);
    Some(SubagentSpawnFact {
        sub_agent_id: spawn.sub_agent_id,
        target_agent_id: spawn.target_agent_id,
        resolved_provider: spawn.resolved_provider,
        resolved_model: spawn.resolved_model,
        task: spawn.task,
        signed_by: spawn.signed_by,
        signature_status,
    })
}

/// Decode a `subagent_result` payload into a [`SubagentResultFact`] under the
/// same eventlog payload cap and the same verdict posture as
/// [`fold_subagent_spawn`].
#[must_use]
pub fn fold_subagent_result(
    payload: &[u8],
    subagent_trust: &RoleTrustSet<SubagentRole>,
) -> Option<SubagentResultFact> {
    let result = polyc_proto::events_decode::decode_event_payload::<
        polyc_proto::proto::polychrome::subagent::v1::SubagentResult,
    >(payload)?;
    let signature_status =
        polyc_crypto::subagent::classify_subagent_result(subagent_trust, &result);
    Some(SubagentResultFact {
        sub_agent_id: result.sub_agent_id,
        target_agent_id: result.target_agent_id,
        succeeded: result.succeeded,
        error: result.error,
        input_tokens: result.input_tokens,
        output_tokens: result.output_tokens,
        first_party: result.first_party,
        signed_by: result.signed_by,
        signature_status,
    })
}

/// Decode a `SubagentModelCallEvent` payload from raw event bytes under the
/// same eventlog payload cap as [`fold_subagent_spawn`].
#[must_use]
pub fn fold_subagent_model_call(payload: &[u8]) -> Option<SubagentModelCallEvent> {
    polyc_proto::events_decode::decode_event_payload(payload)
}

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

    use buffa::Message as _;
    use polyc_crypto::signing_role::SubagentSigner;
    use polyc_proto::events_decode::MAX_EVENT_PAYLOAD_BYTES;
    use polyc_proto::proto::polychrome::subagent::v1::{SubagentResult, SubagentSpawn};

    use super::*;

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

    fn signed_spawn(signer: &SubagentSigner) -> SubagentSpawn {
        let mut spawn = SubagentSpawn {
            sub_agent_id: "call-9".to_owned(),
            target_agent_id: "researcher".to_owned(),
            resolved_provider: "vertex".to_owned(),
            resolved_model: "worker-pro".to_owned(),
            task: "find prior art".to_owned(),
            signed_by: Vec::new(),
            signature_hex: String::new(),
            __buffa_unknown_fields: buffa::UnknownFields::default(),
        };
        polyc_crypto::subagent::sign_subagent_spawn_into(signer, &mut spawn);
        spawn
    }

    fn signed_result(signer: &SubagentSigner) -> SubagentResult {
        let mut result = SubagentResult {
            sub_agent_id: "call-9".to_owned(),
            target_agent_id: "researcher".to_owned(),
            succeeded: true,
            error: String::new(),
            input_tokens: 100,
            output_tokens: 42,
            first_party: false,
            signed_by: Vec::new(),
            signature_hex: String::new(),
            __buffa_unknown_fields: buffa::UnknownFields::default(),
        };
        polyc_crypto::subagent::sign_subagent_result_into(signer, &mut result);
        result
    }

    fn sample_model_call() -> SubagentModelCallEvent {
        SubagentModelCallEvent {
            sub_agent_id: "call-9".to_owned(),
            target_agent_id: "researcher".to_owned(),
            provider: "vertex".to_owned(),
            model: "worker-pro".to_owned(),
            decode_params: buffa::MessageField::none(),
            captured_clock_unix_ms: 1_700_000_000_123,
            __buffa_unknown_fields: buffa::UnknownFields::default(),
        }
    }

    #[test]
    fn round_trips_a_signed_spawn_payload() {
        let signer = SubagentSigner::from_seed(9);
        let bytes = signed_spawn(&signer).encode_to_vec();
        let decoded = fold_subagent_spawn(&bytes, &trust(&signer)).expect("decode spawn");
        assert_eq!(decoded.sub_agent_id, "call-9");
        assert_eq!(decoded.target_agent_id, "researcher");
        assert_eq!(decoded.resolved_provider, "vertex");
        assert_eq!(decoded.resolved_model, "worker-pro");
        assert_eq!(decoded.task, "find prior art");
        assert_eq!(decoded.signed_by, signer.public_key_bytes());
        assert_eq!(decoded.signature_status, SignatureVerdict::Verified);
    }

    #[test]
    fn round_trips_a_signed_result_payload() {
        let signer = SubagentSigner::from_seed(10);
        let bytes = signed_result(&signer).encode_to_vec();
        let decoded = fold_subagent_result(&bytes, &trust(&signer)).expect("decode result");
        assert_eq!(decoded.sub_agent_id, "call-9");
        assert_eq!(decoded.target_agent_id, "researcher");
        assert!(decoded.succeeded);
        assert_eq!(decoded.error, "");
        assert_eq!(decoded.input_tokens, 100);
        assert_eq!(decoded.output_tokens, 42);
        assert!(!decoded.first_party);
        assert_eq!(decoded.signed_by, signer.public_key_bytes());
        assert_eq!(decoded.signature_status, SignatureVerdict::Verified);
    }

    /// A spawn tampered after signing keeps its fact and reads invalid.
    #[test]
    fn a_tampered_spawn_keeps_the_fact_but_reads_invalid() {
        let signer = SubagentSigner::from_seed(11);
        let mut spawn = signed_spawn(&signer);
        spawn.task = "exfiltrate the transcript".to_owned();
        let bytes = spawn.encode_to_vec();

        let decoded = fold_subagent_spawn(&bytes, &trust(&signer)).expect("still decodes");
        assert_eq!(decoded.signature_status, SignatureVerdict::Invalid);
        assert_eq!(decoded.task, "exfiltrate the transcript");
    }

    /// A result signed by a key this deployment never held reads untrusted.
    #[test]
    fn a_foreign_signed_result_keeps_the_fact_but_reads_untrusted() {
        let signer = SubagentSigner::from_seed(12);
        let deployment = SubagentSigner::from_seed(13);
        let bytes = signed_result(&signer).encode_to_vec();

        let decoded = fold_subagent_result(&bytes, &trust(&deployment)).expect("still decodes");
        assert_eq!(decoded.signature_status, SignatureVerdict::Untrusted);
        assert_eq!(decoded.input_tokens, 100);
    }

    #[test]
    fn round_trips_a_model_call_payload() {
        let bytes = sample_model_call().encode_to_vec();
        let decoded = fold_subagent_model_call(&bytes).expect("decode model call");
        assert_eq!(decoded.sub_agent_id, "call-9");
        assert_eq!(decoded.provider, "vertex");
        assert_eq!(decoded.model, "worker-pro");
        assert_eq!(decoded.captured_clock_unix_ms, 1_700_000_000_123);
    }

    #[test]
    fn oversized_spawn_payload_is_rejected_cleanly() {
        let signer = SubagentSigner::from_seed(14);
        let mut spawn = signed_spawn(&signer);
        spawn.task = "x".repeat(MAX_EVENT_PAYLOAD_BYTES + 16);
        let bytes = spawn.encode_to_vec();
        assert!(bytes.len() > MAX_EVENT_PAYLOAD_BYTES);
        assert!(fold_subagent_spawn(&bytes, &trust(&signer)).is_none());
    }

    #[test]
    fn garbage_bytes_do_not_decode() {
        let signer = SubagentSigner::from_seed(15);
        assert!(fold_subagent_spawn(&[0xFF, 0xFE, 0xFD], &trust(&signer)).is_none());
        assert!(fold_subagent_result(&[0xFF, 0xFE, 0xFD], &trust(&signer)).is_none());
        assert!(fold_subagent_model_call(&[0xFF, 0xFE, 0xFD]).is_none());
    }
}