polyc-payments 2026.10.1

Machine Payments Protocol (MPP/Tempo) integration for polychrome: the control-plane composition/glue layer over the standalone outbound, inbound, wallet-delegation, egress, and spend-policy primitive crates, plus the payment proxy/wallet views.
//! The guarded outbound transport this crate receives from its composing
//! process.
//!
//! Every trusted-side fetch in [`crate::proxy`] — the paid `paid_fetch` path
//! and the non-paying `web_fetch` path — rides a transport that validates the
//! destination and pins the connection to the address that validation
//! resolved. This crate builds no such transport. It names the shape it needs
//! here and takes an implementation from the process that composes it, so the
//! destination policy, the DNS pin, and the redirect and timeout guards all
//! belong to one composition root instead of to this component.
//!
//! This seam covers the FETCH of the merchant URL, and nothing else. The
//! chain transports are separate, and this crate does not receive those.
//! `PaymentsConfig::resolve_client` (reached from [`crate::proxy::fulfill`])
//! resolves a settlement client per call from the caller's own delegated key,
//! and `PaymentsConfig::resolve_keychain_status` opens a one-shot RPC read.
//! Both build their own transport inside `polyc-payments-client`.
//! `arch-capabilities.toml`'s standing allowance SA-01 records exactly that,
//! against PRV-25 — see docs/decisions/0026-payments-settlement-client-standing-allowance.md.
//!
//! Reading the body is separate: `polyc_capped_body::read_capped_text` caps
//! it, and needs no transport.
//!
//! `polyc-control-plane` implements this over the shared guarded egress
//! client. A test binary composes its own implementation the same way.

use std::time::Duration;

/// Why the guarded transport produced no client for a destination.
#[derive(Debug, thiserror::Error)]
pub enum TransportRefused {
    /// The composing process's destination policy refused the URL. The payload
    /// is that policy's own diagnostic, carried verbatim so the caller words
    /// the reader-facing message from it.
    #[error("{0}")]
    Destination(String),
    /// The guarded client itself could not be built (a fatal misconfiguration
    /// of the composing process). The fetch fails closed rather than running
    /// on an unguarded client.
    #[error("outbound client build failed")]
    ClientBuild,
}

/// The guarded outbound HTTP transport a proxied fetch rides.
///
/// One implementation serves both fetch paths. The paid path takes the
/// transport's own default request budget; the non-paying path asks for a
/// shorter one. Neither path chooses the destination policy, the redirect
/// behavior, or the connect timeout — those belong to the implementation.
/// `Debug` is required so a composing struct that holds one can still derive
/// it; an implementation prints its own destination policy, never a URL.
#[async_trait::async_trait]
pub trait GuardedTransport: Send + Sync + std::fmt::Debug {
    /// Validates `url` against the destination policy and returns a client
    /// pinned to the address that validation resolved, on the transport's own
    /// default request budget.
    ///
    /// The returned client must not follow redirects: the URL was validated,
    /// but a followed redirect would not be.
    ///
    /// # Errors
    ///
    /// Returns [`TransportRefused::Destination`] when the policy refuses the
    /// URL, and [`TransportRefused::ClientBuild`] when the guarded client
    /// cannot be built.
    async fn pinned_client(&self, url: &str) -> Result<reqwest::Client, TransportRefused>;

    /// [`Self::pinned_client`] on a caller-chosen request `timeout`.
    ///
    /// The timeout is the only thing a caller may vary. Every other guard is
    /// identical to [`Self::pinned_client`].
    ///
    /// # Errors
    ///
    /// The same two cases as [`Self::pinned_client`].
    async fn pinned_client_with_timeout(
        &self,
        url: &str,
        timeout: Duration,
    ) -> Result<reqwest::Client, TransportRefused>;
}