polyc-facts 2026.8.3

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-receipt fold — the SINGLE definition of "a receipt that
//! counts", shared by every receipt-summing call site (the dashboard spend
//! rollup, the committed-spend budget gate, wallet history, and the trace
//! projector's payment step) so none can drift on which receipts they
//! trust.
//!
//! `docs/reference/datafusion-fact-model.md` names the payment-receipt fold as
//! the concrete example of what happens without one: the trace projector
//! used to read `payment_receipt`/`outbound_payment_receipt` payloads with a
//! raw, unverified `serde_json::from_slice` while every accounting path
//! verified the signature first — the same fact, folded twice, one
//! (silently) wrong. Moving the fold here and routing every consumer,
//! including the trace projector, through it closes that gap by
//! construction: there is exactly one place that decides whether a payment
//! receipt settled.

use polyc_eventlog::Event;
use polyc_proto::kinds;

/// The signature-verified, **trusted-signer allow-listed** receipts of kind
/// `base` in a replayed partition, in journal order.
///
/// Filters to `base` and drops any entry whose signature does not verify, OR
/// whose embedded `signed_by` key is not in `trusted_signers`, OR (v2
/// receipts only) whose signed `kind` disagrees with `base`, the physical
/// event kind it is filed under — a forged/tampered payload, one self-signed
/// by an unknown key, or a validly-signed receipt re-filed under the other
/// direction's kind, is silently skipped rather than counted. A v1 receipt
/// (no signed `kind`, `VerifiedReceipt::kind` empty) has nothing to
/// cross-check and is kept as before. Every dollar-counting consumer (the
/// spend rollup, the committed-spend budget gate, wallet history, the trace
/// projector's payment step) reads receipts through this fold, or through
/// [`verified_outbound_receipts`], so none can drift on which receipts they
/// trust.
pub fn verified_receipts<'a>(
    events: &'a [Event],
    base: &'a str,
    trusted_signers: &'a [Vec<u8>],
) -> impl Iterator<Item = polyc_crypto::approval::VerifiedReceipt> + 'a {
    events.iter().filter_map(move |ev| {
        let (event_base, _) = kinds::parse(&ev.kind);
        if event_base != base {
            return None;
        }
        let receipt = polyc_crypto::approval::verify_signed_receipt(&ev.payload, trusted_signers)?;
        // v1 receipts sign no `kind` (empty string) — nothing to cross-check,
        // unchanged behavior. v2 receipts sign the kind they were filed
        // under; a signature that verifies but names the OTHER direction's
        // kind means a valid receipt was re-filed under the wrong physical
        // event, and must be dropped exactly like a signature failure —
        // direction is derived from the physical kind downstream (see
        // `polyc_query::decode::payments`), so a silent mismatch here would
        // misclassify a payment's direction.
        if !receipt.kind.is_empty() && receipt.kind != event_base {
            return None;
        }
        Some(receipt)
    })
}

/// The signature-verified, trusted-signer allow-listed `outbound_payment_receipt`s
/// in a replayed partition, in journal order.
///
/// The direction the control plane pays a `paid_fetch` on a persona's
/// behalf — see [`verified_receipts`] for the shared trust definition every
/// receipt consumer reads through.
pub fn verified_outbound_receipts<'a>(
    events: &'a [Event],
    trusted_signers: &'a [Vec<u8>],
) -> impl Iterator<Item = polyc_crypto::approval::VerifiedReceipt> + 'a {
    verified_receipts(events, kinds::OUTBOUND_PAYMENT_RECEIPT, trusted_signers)
}

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

    use polyc_crypto::approval::{ApprovalSigner, ReceiptPayload, receipt_payload};

    use super::*;

    /// `amount`/`currency` carry the DIRECTION's production shape, not one
    /// shared shape (#1739): an inbound `payment_receipt` stores a decimal
    /// figure in the settlement currency, an outbound
    /// `outbound_payment_receipt` stores the settlement token's base units.
    /// A fixture that wrote a dollar string under the outbound kind is what
    /// let a bare integer parse look correct against the whole suite.
    fn signed_receipt_of_kind(signer: &ApprovalSigner, kind: &str, tool_call_id: &str) -> Vec<u8> {
        let (amount, currency) = if kind == kinds::OUTBOUND_PAYMENT_RECEIPT {
            ("1500000", "0xtoken")
        } else {
            ("1.50", "USD")
        };
        let (payload, sig, pk) = receipt_payload(
            &ReceiptPayload {
                kind,
                reference: "tx-1",
                amount,
                currency,
                recipient: "0xrecipient",
                method: "tempo",
                timestamp: "2026-07-20T00:00:00Z",
                tool_call_id,
                approval_pos: "1",
                approved_args_hash: "hash",
                subject: "persona-1",
                payer_kind: "linked_wallet",
                paying_account: "0xpayer",
            },
            signer,
        );
        let _ = (sig, pk);
        payload
    }

    fn signed_outbound_receipt(signer: &ApprovalSigner, tool_call_id: &str) -> Vec<u8> {
        signed_receipt_of_kind(signer, kinds::OUTBOUND_PAYMENT_RECEIPT, tool_call_id)
    }

    /// A v1 receipt (no `version`/`kind` field) is legacy-signed over only
    /// the six settlement facts; nothing in the payload names a direction, so
    /// there is nothing to cross-check against the physical event kind. The
    /// fold must keep it exactly as before this fix.
    fn signed_v1_receipt() -> Vec<u8> {
        // Frozen vector signed by `ApprovalSigner::from_seed(99)`. Tests must
        // not turn a role signer back into a generic signing oracle merely to
        // manufacture a legacy schema no production writer may mint now.
        br#"{"reference":"tx-old","amount":"0.02","currency":"USDC","recipient":"0xr","method":"tempo","timestamp":"2026-06-01T00:00:00Z","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"0b2d980be185a6154d34da71e1ab6226d6dfc65bcd331fcf87b1b190765c4734301a3adececb1f2a80cf38007bc37d61dd4768eed4460a8326032b026a043407"}"#.to_vec()
    }

    /// A v2 receipt persisted before payer attribution existed: kind +
    /// binding tuple signed, no payer fields at all. Frozen vector signed by
    /// `ApprovalSigner::from_seed(99)` (same bytes `polyc_crypto`'s
    /// `GOLDEN_V2_RECEIPT` pins) — checked in, never regenerated.
    fn signed_v2_receipt() -> Vec<u8> {
        br#"{"version":2,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"4f73999b3885188a976c4a2da45c0fc5b6917102fd5a17a10efbea7c9dc9c13216602de44b52388f6811d07ad3a9225288528828f05d436e8c1d3241e472c809"}"#.to_vec()
    }

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

    #[test]
    fn verified_receipts_filters_by_kind_base() {
        let signer = ApprovalSigner::from_seed(3);
        let events = vec![
            Event::new(
                "outbound_payment_receipt".to_owned(),
                signed_outbound_receipt(&signer, "call-1"),
            ),
            Event::new("payment_receipt".to_owned(), vec![]),
        ];
        let trusted_signers = vec![signer.public_key_bytes()];
        let receipts: Vec<_> =
            verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
        assert_eq!(receipts.len(), 1);
    }

    /// #1409: a v2 receipt validly signed by a trusted signer, whose signed
    /// `kind` disagrees with the PHYSICAL event kind it is filed under, must
    /// be dropped exactly like a signature failure — direction is derived
    /// from the physical kind downstream, so a mismatch here would
    /// misclassify a payment. A matching receipt is kept.
    #[test]
    fn verified_receipts_drops_signed_kind_mismatched_with_physical_kind() {
        let signer = ApprovalSigner::from_seed(4);
        let trusted_signers = vec![signer.public_key_bytes()];

        // Signed kind says outbound, but physically filed under the inbound
        // `payment_receipt` kind.
        let mismatched = signed_receipt_of_kind(&signer, kinds::OUTBOUND_PAYMENT_RECEIPT, "call-1");
        // Signed kind matches the physical kind it's filed under.
        let matching = signed_receipt_of_kind(&signer, kinds::PAYMENT_RECEIPT, "call-2");

        let events = vec![
            Event::new(kinds::PAYMENT_RECEIPT.to_owned(), mismatched),
            Event::new(kinds::PAYMENT_RECEIPT.to_owned(), matching),
        ];

        let receipts: Vec<_> =
            verified_receipts(&events, kinds::PAYMENT_RECEIPT, &trusted_signers).collect();
        assert_eq!(
            receipts.len(),
            1,
            "the kind-mismatched receipt must be dropped, the matching one kept"
        );
        assert_eq!(receipts[0].tool_call_id, "call-2");
    }

    /// A v1 receipt (no signed `kind`) has nothing to cross-check and must be
    /// unaffected by the kind cross-check — it is kept regardless of which
    /// physical kind it is filed under, same as before this fix.
    #[test]
    fn verified_receipts_keeps_v1_receipt_regardless_of_physical_kind() {
        let signer = ApprovalSigner::from_seed(99);
        let trusted_signers = vec![signer.public_key_bytes()];
        let events = vec![Event::new(
            kinds::OUTBOUND_PAYMENT_RECEIPT.to_owned(),
            signed_v1_receipt(),
        )];

        let receipts: Vec<_> =
            verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
        assert_eq!(
            receipts.len(),
            1,
            "a v1 receipt has no signed kind to cross-check"
        );
        assert_eq!(receipts[0].version, 1);
        assert!(receipts[0].kind.is_empty());
    }

    /// A v2 receipt predates payer attribution: the fold must still keep it
    /// (its signature and binding tuple are unaffected by the v3 field
    /// addition) and report payer as explicit unknown, never inferred.
    #[test]
    fn verified_receipts_folds_frozen_v2_receipt_with_payer_unknown() {
        let signer = ApprovalSigner::from_seed(99);
        let trusted_signers = vec![signer.public_key_bytes()];
        let events = vec![Event::new(
            kinds::OUTBOUND_PAYMENT_RECEIPT.to_owned(),
            signed_v2_receipt(),
        )];

        let receipts: Vec<_> =
            verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
        assert_eq!(receipts.len(), 1, "a frozen v2 receipt must still fold");
        assert_eq!(receipts[0].version, 2);
        assert!(receipts[0].payer_kind.is_empty());
        assert!(receipts[0].paying_account.is_empty());
    }

    /// A v3 receipt folds with its payer attribution intact.
    #[test]
    fn verified_receipts_folds_v3_receipt_with_payer_attribution() {
        let signer = ApprovalSigner::from_seed(5);
        let trusted_signers = vec![signer.public_key_bytes()];
        let events = vec![Event::new(
            kinds::OUTBOUND_PAYMENT_RECEIPT.to_owned(),
            signed_outbound_receipt(&signer, "call-1"),
        )];

        let receipts: Vec<_> =
            verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
        assert_eq!(receipts.len(), 1);
        assert_eq!(receipts[0].payer_kind, "linked_wallet");
        assert_eq!(receipts[0].paying_account, "0xpayer");
    }
}