Skip to main content

praxis_policy_core/
host.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// Host-provided services, and the two carriers that hand them to a plugin.
5//
6// Some things a plugin needs cannot be compiled into PPE, because the
7// embedding host already owns them: an HTTP stack with its pool and
8// egress policy, and later a durable effect log. PPE defines the shape,
9// the host installs an implementation, and a plugin borrows it.
10//
11// These are operations, not accessors: nothing hands the underlying
12// service back, so a plugin has no handle to keep. Two reasons, and the
13// first is the load-bearing one:
14//
15//   * The capability gate is recomputed per invocation. A plugin that
16//     stashed a handle at startup would keep the authority after an
17//     operator removed the capability and reloaded config, turning
18//     "is allowed now" into "was allowed once".
19//   * The carrier can attach per-request context a stored handle could
20//     never know: the request's trace span, its remaining deadline, its
21//     subrequest depth.
22//
23// The corollary is that background work cannot retain a reusable service
24// handle: a `'static` task outlives the carrier by construction, so it has
25// no later invocation whose gate can be checked. A carrier may authorize one
26// fully-built request while it is borrowed and return that request-specific
27// future; this is reserved for cancellation cleanup such as revoking a
28// temporary credential. It conveys no handle with which to issue another
29// request.
30//
31// Two carriers, because a plugin needs services at two points in its
32// life and only one of them has a request:
33//
34//   * [`InitExtensions`] — during `Plugin::initialize_with`. No request
35//     exists, so it carries services and nothing else. A JWKS fetch at
36//     startup runs through this.
37//   * `Extensions` — during hook dispatch. Already flows to every
38//     plugin, already filtered by capability. A token exchange or an
39//     on-demand JWKS refresh runs through this.
40//
41// Both implement [`HostServices`], so a plugin writes the work once
42// against `&dyn HostServices` and the carrier becomes incidental:
43//
44// ```ignore
45// async fn fetch_jwks(&self, svc: &dyn HostServices) -> Result<KeyStore, String> {
46//     let http = svc.http_transport()?;
47//     ...
48// }
49// ```
50//
51// `InitExtensions` is a distinct type rather than an `Extensions` with
52// every slot empty. Handing a plugin a request context when there is no
53// request invites reading `ext.security` at startup and getting a
54// confusing `None`; here there is nothing to reach for.
55
56use std::fmt;
57use std::pin::Pin;
58use std::sync::Arc;
59
60use async_trait::async_trait;
61
62use crate::http::{HttpRequest, HttpResponse, HttpTransport, HttpTransportError};
63use crate::http_retry::RetryPolicy;
64
65/// One HTTP operation authorized while a host-service carrier was borrowed.
66///
67/// The future owns exactly one request and its already-gated transport. It
68/// cannot be reused to issue another request, which makes it suitable for a
69/// cleanup operation that must survive cancellation of its parent handler.
70pub type HttpRequestFuture =
71    Pin<Box<dyn Future<Output = Result<HttpResponse, HttpRequestError>> + Send + 'static>>;
72
73/// Why a host service was not available to a plugin.
74///
75/// The two variants have different fixes and different owners, so they
76/// are reported separately. Collapsing them would send an operator
77/// looking at their config when the host forgot to wire something, or
78/// the reverse.
79///
80/// Neither names the plugin. Both places these surface already tag the
81/// plugin: the engine logs `Failed to initialize plugin '{name}'`, and
82/// the executor wraps a request-time failure in `PluginError` carrying
83/// the name. Repeating it here would render as "plugin 'x': plugin 'x'
84/// needs...".
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum ServiceError {
87    /// The host never installed this service.
88    ///
89    /// A wiring problem in the embedding program, not in policy config.
90    /// Nothing an operator can fix from YAML.
91    NotInstalled {
92        /// Service name, e.g. `"http"`.
93        service: &'static str,
94    },
95
96    /// The service exists but this plugin does not hold the capability
97    /// that gates it.
98    ///
99    /// A config problem, and the message names the capability to add.
100    NotPermitted {
101        /// Service name, e.g. `"http"`.
102        service: &'static str,
103        /// The capability that would grant it, e.g. `"perform_http"`.
104        capability: &'static str,
105    },
106}
107
108impl fmt::Display for ServiceError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            Self::NotInstalled { service } => write!(
112                f,
113                "needs the '{service}' host service, but none is installed; the embedding \
114                 host must install one before initializing the engine"
115            ),
116            Self::NotPermitted {
117                service,
118                capability,
119            } => write!(
120                f,
121                "needs the '{service}' host service but does not declare '{capability}'; \
122                 add it to the plugin's `capabilities` in config"
123            ),
124        }
125    }
126}
127
128impl std::error::Error for ServiceError {}
129
130/// One host service as seen by a plugin: present, absent, or withheld.
131///
132/// Keeping "withheld" distinct from "absent" is what lets
133/// [`ServiceError`] name the right fix. An `Option` would collapse them.
134#[derive(Debug, Clone, Default)]
135pub enum ServiceSlot<T> {
136    /// Installed and permitted.
137    Available(T),
138    /// The host installed nothing.
139    #[default]
140    NotInstalled,
141    /// Installed, but the plugin lacks the gating capability.
142    NotPermitted,
143}
144
145impl<T> ServiceSlot<T> {
146    /// Resolve to the service, or to the error that says why not.
147    pub(crate) fn get(
148        &self,
149        service: &'static str,
150        capability: &'static str,
151    ) -> Result<&T, ServiceError> {
152        match self {
153            Self::Available(v) => Ok(v),
154            Self::NotInstalled => Err(ServiceError::NotInstalled { service }),
155            Self::NotPermitted => Err(ServiceError::NotPermitted {
156                service,
157                capability,
158            }),
159        }
160    }
161
162    /// Whether the service is available.
163    pub fn is_available(&self) -> bool {
164        matches!(self, Self::Available(_))
165    }
166}
167
168/// The host services a plugin may use.
169///
170/// Implemented by the carriers ([`InitExtensions`] and `Extensions`),
171/// never by a host. A host implements the individual service traits —
172/// [`HttpTransport`] and, in time, the effect log — and installs them on
173/// the engine.
174///
175/// These are *operations*, not accessors. Nothing hands back the
176/// underlying service, so there is no handle for a plugin to keep and
177/// the capability check cannot be performed once and then skipped: the
178/// call is the check. It also leaves the carrier free to enrich a
179/// request with context only it has — a trace span, a remaining
180/// deadline, a subrequest depth — which a plugin driving the transport
181/// itself would bypass.
182///
183/// Every operation returns a `Result` so a plugin that needs a service it
184/// cannot have fails with a message naming the fix, instead of silently
185/// taking a degraded path.
186#[async_trait]
187pub trait HostServices: Send + Sync {
188    /// Perform one outbound HTTP request through the host's transport.
189    ///
190    /// `retry` is required rather than defaulted because whether a
191    /// repeat is safe depends on what the request *does*, and only the
192    /// caller knows that. A JWKS `GET` takes
193    /// [`RetryPolicy::idempotent`]; a token mint or an approval prompt
194    /// takes [`RetryPolicy::undelivered_only`], where repeating a
195    /// timed-out call could issue a second credential or ask a human
196    /// twice. Making it an argument means the question gets answered at
197    /// every call site.
198    ///
199    /// # Errors
200    ///
201    /// [`HttpRequestError::Unavailable`] when no transport is installed
202    /// or the plugin lacks `perform_http`, and
203    /// [`HttpRequestError::Transport`] when the call itself failed.
204    async fn http_request(
205        &self,
206        req: HttpRequest,
207        retry: RetryPolicy,
208    ) -> Result<HttpResponse, HttpRequestError>;
209}
210
211/// Why an outbound request did not produce a response.
212///
213/// Two distinct failures with different owners: the service was not
214/// available to this plugin at all, or it was and the call failed. An
215/// operator fixes the first in config or host wiring and the second at
216/// the peer, so collapsing them would send them to the wrong place.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum HttpRequestError {
219    /// No transport is installed, or this plugin may not use it.
220    Unavailable(ServiceError),
221    /// The transport ran and the call failed.
222    Transport(HttpTransportError),
223}
224
225impl fmt::Display for HttpRequestError {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            Self::Unavailable(e) => write!(f, "{e}"),
229            Self::Transport(e) => write!(f, "{e}"),
230        }
231    }
232}
233
234impl std::error::Error for HttpRequestError {}
235
236impl From<ServiceError> for HttpRequestError {
237    fn from(e: ServiceError) -> Self {
238        Self::Unavailable(e)
239    }
240}
241
242impl From<HttpTransportError> for HttpRequestError {
243    fn from(e: HttpTransportError) -> Self {
244        Self::Transport(e)
245    }
246}
247
248/// Shared body for both carriers: resolve the gated transport, then run
249/// the request through the retry policy.
250pub(crate) async fn run_request(
251    slot: &HttpTransportSlot,
252    req: HttpRequest,
253    retry: RetryPolicy,
254) -> Result<HttpResponse, HttpRequestError> {
255    start_request(slot, req, retry)?.await
256}
257
258/// Resolve the capability-gated transport now and bind it to one request.
259/// The resulting future owns no reusable transport accessor.
260pub(crate) fn start_request(
261    slot: &HttpTransportSlot,
262    req: HttpRequest,
263    retry: RetryPolicy,
264) -> Result<HttpRequestFuture, HttpRequestError> {
265    let transport = Arc::clone(slot.slot().get(HTTP_SERVICE, HTTP_CAPABILITY)?);
266    Ok(Box::pin(async move {
267        crate::http_retry::execute_with_retry(transport.as_ref(), req, retry)
268            .await
269            .map_err(HttpRequestError::Transport)
270    }))
271}
272
273/// The host's HTTP transport as one carrier sees it.
274///
275/// A newtype over the slot rather than the slot itself, so the `Arc`
276/// inside cannot be taken out. The field on `Extensions` has to stay
277/// public — Rust forbids `..Default::default()` when any field is
278/// invisible, and nearly four hundred construction sites rely on it —
279/// but a public *field* need not imply a public *payload*.
280///
281/// Without this, a plugin could `match ext.http_transport { Available(t)
282/// => Arc::clone(t) }` and keep the transport for the life of the
283/// process, which is exactly what the operation-shaped API exists to
284/// prevent: the grant is re-evaluated per request, so a retained handle
285/// outlives a capability an operator has since revoked.
286///
287/// Deliberately not `Clone`. Hiding the `Arc` behind the newtype is not enough
288/// on its own: with the field public, a cloneable slot could be copied out of
289/// the `&Extensions` a plugin is lent, stashed, and later used on a
290/// hand-built `Extensions`. The capability check would then read the verdict
291/// frozen into that copy rather than the plugin's current grant, so a
292/// retained slot would keep egress an operator had since revoked. The crate
293/// rebuilds the slot where it legitimately needs to, through a crate-internal
294/// helper that is not reachable from a plugin.
295///
296/// ```compile_fail
297/// use praxis_policy_core::host::HttpTransportSlot;
298/// use praxis_policy_core::hooks::payload::Extensions;
299/// let ext = Extensions::default();
300/// let _stashed: HttpTransportSlot = ext.http_transport.clone();
301/// ```
302#[derive(Debug, Default)]
303pub struct HttpTransportSlot(ServiceSlot<Arc<dyn HttpTransport>>);
304
305impl HttpTransportSlot {
306    /// A slot holding `transport`, for a host or test wiring one in.
307    #[must_use]
308    pub fn installed(transport: Arc<dyn HttpTransport>) -> Self {
309        Self(ServiceSlot::Available(transport))
310    }
311
312    /// A slot recording that a transport exists but this plugin may not
313    /// use it, so the error names the capability rather than blaming the
314    /// host for installing nothing.
315    #[must_use]
316    pub fn withheld() -> Self {
317        Self(ServiceSlot::NotPermitted)
318    }
319
320    /// Whether a transport is present and permitted.
321    #[must_use]
322    pub fn is_available(&self) -> bool {
323        self.0.is_available()
324    }
325
326    pub(crate) fn slot(&self) -> &ServiceSlot<Arc<dyn HttpTransport>> {
327        &self.0
328    }
329
330    /// A second slot carrying the same verdict, for building the per-plugin
331    /// filtered view.
332    ///
333    /// This is what `Clone` would be, minus the public impl a plugin could
334    /// reach. The one caller is `filter_extensions`, which has just decided
335    /// this plugin's grant and is recording it. `Extensions::clone` does not
336    /// use it: a copy carries no transport, so a stashed copy cannot outlive
337    /// the grant it was built under.
338    pub(crate) fn rebuilt(&self) -> Self {
339        Self(self.0.clone())
340    }
341}
342
343/// Host services during `Plugin::initialize_with`, before any request.
344///
345/// The engine builds one per plugin, applying that plugin's capability
346/// grants, and drops it when initialization returns. A plugin must not
347/// retain anything from it; see the module note on borrowing per call.
348///
349/// Not `Clone`, for the same reason [`HttpTransportSlot`] is not: a plugin
350/// handed `&InitExtensions` could otherwise copy it, keep it past
351/// initialization, and call host services against grants decided at startup.
352/// Nothing needs to clone it; every caller takes it by reference.
353#[derive(Debug, Default)]
354pub struct InitExtensions {
355    http: HttpTransportSlot,
356}
357
358impl InitExtensions {
359    /// An empty set of services, as a host that installed nothing would
360    /// produce. Also the shape a test wants when the plugin under test
361    /// needs none.
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    /// Attach the HTTP transport, having already decided the plugin may
367    /// have it.
368    ///
369    /// The capability check belongs to the caller (the engine) because
370    /// only it holds the plugin's authoritative grants; this type just
371    /// carries the verdict.
372    #[must_use]
373    pub fn with_http(mut self, http: Arc<dyn HttpTransport>) -> Self {
374        self.http = HttpTransportSlot::installed(http);
375        self
376    }
377
378    /// Record that a transport exists but this plugin may not use it.
379    #[must_use]
380    pub fn with_http_withheld(mut self) -> Self {
381        self.http = HttpTransportSlot::withheld();
382        self
383    }
384}
385
386#[async_trait]
387impl HostServices for InitExtensions {
388    async fn http_request(
389        &self,
390        req: HttpRequest,
391        retry: RetryPolicy,
392    ) -> Result<HttpResponse, HttpRequestError> {
393        run_request(&self.http, req, retry).await
394    }
395}
396
397/// Service name used in [`ServiceError`] messages.
398pub const HTTP_SERVICE: &str = "http";
399
400/// Capability that gates the HTTP service, matching
401/// `Capability::PerformHttp`'s serialized form.
402pub const HTTP_CAPABILITY: &str = "perform_http";
403
404#[cfg(test)]
405#[allow(
406    clippy::expect_used,
407    clippy::panic,
408    clippy::unwrap_used,
409    reason = "tests"
410)]
411mod tests {
412    use super::*;
413    use async_trait::async_trait;
414    use bytes::Bytes;
415
416    /// Ask for the service the only way a plugin can: by using it.
417    async fn probe(ext: &InitExtensions) -> Result<HttpResponse, HttpRequestError> {
418        ext.http_request(
419            HttpRequest::get("https://example.test/probe"),
420            RetryPolicy::none(),
421        )
422        .await
423    }
424
425    #[derive(Debug)]
426    struct StubTransport;
427
428    #[async_trait]
429    impl HttpTransport for StubTransport {
430        async fn execute(&self, _req: HttpRequest) -> Result<HttpResponse, HttpTransportError> {
431            Ok(HttpResponse::new(200, Bytes::from_static(b"{}")))
432        }
433    }
434
435    #[tokio::test]
436    async fn an_uninstalled_service_names_the_host_not_the_operator() {
437        // The distinction matters operationally: nothing in policy YAML
438        // fixes a host that forgot to wire a transport, so the message
439        // must not send an operator looking there.
440        let ext = InitExtensions::new();
441        let err = probe(&ext).await.expect_err("nothing was installed");
442        assert!(matches!(
443            err,
444            HttpRequestError::Unavailable(ServiceError::NotInstalled { .. })
445        ));
446        let msg = err.to_string();
447        assert!(msg.contains("embedding host"), "{msg}");
448    }
449
450    #[tokio::test]
451    async fn a_withheld_service_names_the_capability_to_add() {
452        // The opposite case: the host did its part, and the fix is one
453        // line of config. The message has to say which line.
454        let ext = InitExtensions::new().with_http_withheld();
455        let err = probe(&ext)
456            .await
457            .expect_err("the capability was not granted");
458        assert!(matches!(
459            err,
460            HttpRequestError::Unavailable(ServiceError::NotPermitted { .. })
461        ));
462        let msg = err.to_string();
463        assert!(msg.contains("perform_http"), "{msg}");
464        assert!(msg.contains("capabilities"), "{msg}");
465    }
466
467    #[tokio::test]
468    async fn an_available_service_is_usable_through_the_trait() {
469        let ext = InitExtensions::new().with_http(Arc::new(StubTransport));
470        let svc: &dyn HostServices = &ext;
471        let resp = svc
472            .http_request(
473                HttpRequest::get("https://example.com/jwks"),
474                RetryPolicy::none(),
475            )
476            .await
477            .expect("the stub answers");
478        assert_eq!(resp.status, 200);
479    }
480
481    #[tokio::test]
482    async fn withheld_and_uninstalled_are_distinguishable() {
483        // The whole reason ServiceSlot is not an Option.
484        let uninstalled = InitExtensions::new();
485        let withheld = InitExtensions::new().with_http_withheld();
486        assert_ne!(
487            probe(&uninstalled).await.unwrap_err(),
488            probe(&withheld).await.unwrap_err()
489        );
490        assert!(!uninstalled.http.is_available());
491        assert!(!withheld.http.is_available());
492    }
493}