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}