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.
//! The verified-refusal fold (`#2090`, INV-W5) — the SINGLE definition of "a
//! payment refusal that counts", the `payment_refusal` sibling of
//! [`crate::receipts::verified_receipts`]. Every consumer that wants to know
//! which payment refusals actually happened (the `refusals` query view
//! today; any future forensics/trace consumer) reads through this fold, not
//! a second, independently-written decode of the signed JSON payload.
//!
//! A forged/tampered refusal, or one signed by a key outside
//! `trusted_signers`, is not "a refusal row with bad data" — the fold
//! returns `None` for it and the caller skips it, exactly the way
//! [`verified_receipts`](crate::receipts::verified_receipts) treats an
//! unverifiable receipt.

use polyc_eventlog_model::Event;
use polyc_proto::kinds;

/// The signature-verified, **trusted-signer allow-listed** `payment_refusal`
/// events in a replayed partition, in journal order.
///
/// Drops any entry whose signature does not verify, whose embedded
/// `signed_by` key is not in `trusted_signers`, or whose signed `kind`
/// disagrees with [`kinds::PAYMENT_REFUSAL`] (the physical kind every
/// refusal is filed under — there is only one direction, unlike receipts,
/// but the signed `kind` still guards against a payload re-filed under an
/// unrelated event kind). Every refusal-reading consumer reads through this
/// fold, or through the `refusals` query view built on top of it, so none
/// can drift on which refusals they trust.
pub fn verified_refusals<'a>(
    events: &'a [Event],
    trusted_signers: &'a [Vec<u8>],
) -> impl Iterator<Item = polyc_crypto::approval::VerifiedRefusal> + 'a {
    events.iter().filter_map(move |ev| {
        let (base, _) = kinds::parse(&ev.kind);
        if base != kinds::PAYMENT_REFUSAL {
            return None;
        }
        let refusal = polyc_crypto::approval::verify_signed_refusal(&ev.payload, trusted_signers)?;
        if refusal.kind != kinds::PAYMENT_REFUSAL {
            return None;
        }
        Some(refusal)
    })
}

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

    use polyc_crypto::approval::{ApprovalSigner, RefusalPayload, refusal_payload};

    use super::*;

    fn signed_refusal(signer: &ApprovalSigner, tool_call_id: &str) -> Vec<u8> {
        let (payload, sig, pk) = refusal_payload(
            &RefusalPayload {
                kind: kinds::PAYMENT_REFUSAL,
                reason: "over_spend_cap",
                reason_detail: "payment of 500 base units exceeds the cap of 100",
                merchant_host: "merchant.example",
                requested_base_units: "500",
                permitted_base_units: "100",
                tool_call_id,
                subject: "persona-1",
                timestamp: "1780000000",
            },
            signer,
        );
        let _ = (sig, pk);
        payload
    }

    #[test]
    fn verified_refusals_drops_unknown_signer() {
        let trusted = ApprovalSigner::from_seed(1);
        let untrusted = ApprovalSigner::from_seed(2);
        let events = vec![
            Event::new(
                "payment_refusal:trusted".to_owned(),
                signed_refusal(&trusted, "call-1"),
            ),
            Event::new(
                "payment_refusal:untrusted".to_owned(),
                signed_refusal(&untrusted, "call-2"),
            ),
        ];
        let trusted_signers = vec![trusted.public_key_bytes()];
        let refusals: Vec<_> = verified_refusals(&events, &trusted_signers).collect();
        assert_eq!(refusals.len(), 1);
        assert_eq!(refusals[0].tool_call_id, "call-1");
    }

    #[test]
    fn verified_refusals_filters_by_kind_base() {
        let signer = ApprovalSigner::from_seed(3);
        let events = vec![
            Event::new(
                "payment_refusal".to_owned(),
                signed_refusal(&signer, "call-1"),
            ),
            Event::new("usage".to_owned(), vec![]),
        ];
        let trusted_signers = vec![signer.public_key_bytes()];
        let refusals: Vec<_> = verified_refusals(&events, &trusted_signers).collect();
        assert_eq!(refusals.len(), 1);
    }

    #[test]
    fn verified_refusals_drops_malformed_payload() {
        let events = vec![Event::new(
            kinds::PAYMENT_REFUSAL.to_owned(),
            vec![0xFF, 0xFE, 0xFD],
        )];
        let refusals: Vec<_> = verified_refusals(&events, &[]).collect();
        assert_eq!(refusals.len(), 0);
    }
}