Skip to main content

liminal_sdk/
embedded.rs

1use alloc::format;
2use alloc::string::{String, ToString};
3use alloc::sync::Arc;
4use core::fmt;
5use core::marker::PhantomData;
6use core::pin::Pin;
7use core::task::{Context, Poll};
8
9use futures_core::Stream;
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12
13use crate::{
14    ChannelHandle, ConversationEvent, ConversationHandle, ConversationId, PressureResponse,
15    SchemaMetadata, SchemaValidate, SdkError,
16};
17
18#[cfg(test)]
19mod tests;
20
21/// Type-erased view of a typed channel message passed to an embedded backend.
22///
23/// The embedded SDK path hands this reference directly to the in-process backend;
24/// it never asks serde to produce bytes, protocol envelopes, or wire frames.
25pub trait EmbeddedChannelMessage {
26    /// Returns the compile-time schema metadata declared by the message type.
27    #[must_use]
28    fn schema_metadata(&self) -> SchemaMetadata;
29
30    /// Returns the Rust type name used for diagnostics and backend routing.
31    #[must_use]
32    fn type_name(&self) -> &'static str;
33}
34
35impl<M> EmbeddedChannelMessage for M
36where
37    M: Serialize + SchemaValidate,
38{
39    fn schema_metadata(&self) -> SchemaMetadata {
40        M::schema_metadata()
41    }
42
43    fn type_name(&self) -> &'static str {
44        core::any::type_name::<M>()
45    }
46}
47
48/// Type-erased view of a typed conversation message passed to an embedded backend.
49pub trait EmbeddedConversationMessage {
50    /// Returns the Rust type name used for diagnostics and backend routing.
51    #[must_use]
52    fn type_name(&self) -> &'static str;
53}
54
55impl<M> EmbeddedConversationMessage for M
56where
57    M: Serialize,
58{
59    fn type_name(&self) -> &'static str {
60        core::any::type_name::<M>()
61    }
62}
63
64/// Stream used by SDK handles when no typed messages are buffered locally.
65pub struct SdkSubscription<M> {
66    pending_error: Option<SdkError>,
67    message: PhantomData<M>,
68}
69
70impl<M> SdkSubscription<M> {
71    /// Creates an empty typed subscription stream.
72    #[must_use]
73    pub const fn empty() -> Self {
74        Self {
75            pending_error: None,
76            message: PhantomData,
77        }
78    }
79
80    /// Creates a subscription stream that surfaces a setup error to the caller.
81    #[must_use]
82    pub const fn error(error: SdkError) -> Self {
83        Self {
84            pending_error: Some(error),
85            message: PhantomData,
86        }
87    }
88
89    /// Returns true when this local subscription has no pending setup error.
90    #[must_use]
91    pub const fn is_empty(&self) -> bool {
92        self.pending_error.is_none()
93    }
94}
95
96impl<M> Default for SdkSubscription<M> {
97    fn default() -> Self {
98        Self::empty()
99    }
100}
101
102impl<M> fmt::Debug for SdkSubscription<M> {
103    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104        formatter
105            .debug_struct("SdkSubscription")
106            .field("has_pending_error", &self.pending_error.is_some())
107            .finish()
108    }
109}
110
111impl<M> Unpin for SdkSubscription<M> {}
112
113impl<M> Stream for SdkSubscription<M> {
114    type Item = Result<M, SdkError>;
115
116    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
117        core::hint::black_box(context.waker());
118        Poll::Ready(self.pending_error.take().map(Err))
119    }
120}
121
122/// Stream used when a conversation has no lifecycle events buffered locally.
123#[derive(Clone, Debug, Default)]
124pub struct EmptyLifecycleStream;
125
126impl Unpin for EmptyLifecycleStream {}
127
128impl Stream for EmptyLifecycleStream {
129    type Item = ConversationEvent;
130
131    fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
132        core::hint::black_box((self, context.waker()));
133        Poll::Ready(None)
134    }
135}
136
137/// Ready future used by SDK handles that complete an operation synchronously.
138pub struct ReadyResult<T> {
139    result: Option<Result<T, SdkError>>,
140}
141
142impl<T> fmt::Debug for ReadyResult<T> {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        formatter
145            .debug_struct("ReadyResult")
146            .field("is_ready", &self.result.is_some())
147            .finish()
148    }
149}
150
151impl<T> ReadyResult<T> {
152    /// Creates a ready future from a result.
153    #[must_use]
154    pub const fn new(result: Result<T, SdkError>) -> Self {
155        Self {
156            result: Some(result),
157        }
158    }
159}
160
161impl<T> Unpin for ReadyResult<T> {}
162
163impl<T> core::future::Future for ReadyResult<T> {
164    type Output = Result<T, SdkError>;
165
166    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
167        core::hint::black_box(context.waker());
168        let Some(result) = self.result.take() else {
169            return Poll::Ready(Err(SdkError::Protocol {
170                description: "ready future polled after completion".to_string(),
171            }));
172        };
173
174        Poll::Ready(result)
175    }
176}
177
178/// Direct in-process channel backend used by embedded handles.
179///
180/// Publish only. There is no subscribe or deliver method here and never has
181/// been, which is why [`ChannelHandle::subscribe`] on an
182/// [`EmbeddedChannelHandle`] has no delivery path to reach and refuses instead
183/// of returning a stream nothing can feed.
184pub trait EmbeddedChannelBackend: fmt::Debug + Send + Sync {
185    /// Publishes a typed message reference without protocol framing or wire encoding.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`SdkError`] if the in-process backend rejects the publish attempt.
190    fn publish(&self, message: &dyn EmbeddedChannelMessage) -> Result<PressureResponse, SdkError>;
191}
192
193/// Direct in-process conversation backend used by embedded handles.
194pub trait EmbeddedConversationBackend: fmt::Debug + Send + Sync {
195    /// Sends a typed message reference without protocol framing or wire encoding.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`SdkError`] if the in-process backend rejects the send attempt.
200    fn send(&self, message: &dyn EmbeddedConversationMessage) -> Result<(), SdkError>;
201}
202
203/// Ledger row that records why embedded mode has no in-process bus behind it.
204const B1_SEAM: &str = "seam B1 — Embedded/frame-conv integration \
205                       (docs/stack-review/liminal-ledger.md)";
206
207/// The refusal embedded typed `subscribe` returns instead of an instantly-dry
208/// stream.
209///
210/// Embedded typed subscribe delivery is unimplemented, and unimplemented in a
211/// stronger sense than the publish seam beside it: publish at least has a
212/// backend trait a caller can implement, while [`EmbeddedChannelBackend`]
213/// declares `publish` and nothing else. There is no subscribe or deliver method
214/// on it, so no backend installed through
215/// [`EmbeddedConfig::with_channel_backend`] can feed a subscription either.
216/// Nothing in this crate delivers a message to an embedded subscriber.
217///
218/// No ledger row records this gap. B1 — the row named by [`B1_SEAM`], and the
219/// row this module already cites for the default backends — is about the SDK's
220/// no-op embedded *backend*, i.e. the publish/send seam that a shared-scheduler
221/// `liminal`-core integration would fill; read at
222/// `docs/stack-review/liminal-ledger.md` it says nothing about subscription
223/// delivery, and the backend it describes has no subscription surface to fill.
224/// This refusal says so rather than borrowing a row that does not cover it.
225fn unwired_embedded_subscribe() -> SdkError {
226    SdkError::Unwired {
227        surface: "typed subscribe delivery (ChannelHandle::subscribe on an embedded handle)"
228            .to_string(),
229        seam: "no ledger row records this gap; B1 covers the no-op embedded backend, which is \
230               publish-only and has no subscription surface at all"
231            .to_string(),
232        alternative: "embedded mode has no in-process delivery path to point at. For real \
233                      delivery use a remote handle against a running server: \
234                      SubscriptionStream::open plus SubscriptionStream::recv_timeout, or \
235                      WebSocketSubscriptionStream::open on the WebSocket transport"
236            .to_string(),
237    }
238}
239
240/// The channel backend [`EmbeddedConfig::new`] installs by default — and it
241/// REFUSES every publish.
242///
243/// Embedded mode has no in-process bus behind it. That absence is deliberate
244/// and recorded: it is seam **B1**, "Embedded/frame-conv integration", in
245/// `docs/stack-review/liminal-ledger.md`, which makes shared-scheduler
246/// construction on the `liminal` core crate the blessed path and leaves this
247/// backend as a labelled seam until then.
248///
249/// What it must NOT be is a seam that lies. It used to discard the message and
250/// return [`PressureResponse::Accept`], so every embedded publish reported
251/// success while nothing was delivered to anyone. It now returns
252/// [`SdkError::Unwired`] naming the B1 seam and
253/// [`EmbeddedConfig::with_channel_backend`], which is how a caller installs a
254/// backend that genuinely delivers.
255#[derive(Clone, Debug, Default)]
256pub struct DirectEmbeddedChannelBackend;
257
258impl EmbeddedChannelBackend for DirectEmbeddedChannelBackend {
259    fn publish(&self, message: &dyn EmbeddedChannelMessage) -> Result<PressureResponse, SdkError> {
260        Err(SdkError::Unwired {
261            surface: format!(
262                "the default embedded channel backend (publish of {})",
263                message.type_name()
264            ),
265            seam: B1_SEAM.to_string(),
266            alternative: "install an in-process backend that genuinely delivers with \
267                          EmbeddedConfig::with_channel_backend"
268                .to_string(),
269        })
270    }
271}
272
273/// The conversation backend [`EmbeddedConfig::new`] installs by default — and it
274/// REFUSES every send.
275///
276/// The sibling of [`DirectEmbeddedChannelBackend`] and the same seam: it used to
277/// discard the message and return `Ok(())`, reporting a delivery that never
278/// happened. It now returns [`SdkError::Unwired`] naming the B1 seam and
279/// [`EmbeddedConfig::with_conversation_backend`].
280#[derive(Clone, Debug, Default)]
281pub struct DirectEmbeddedConversationBackend;
282
283impl EmbeddedConversationBackend for DirectEmbeddedConversationBackend {
284    fn send(&self, message: &dyn EmbeddedConversationMessage) -> Result<(), SdkError> {
285        Err(SdkError::Unwired {
286            surface: format!(
287                "the default embedded conversation backend (send of {})",
288                message.type_name()
289            ),
290            seam: B1_SEAM.to_string(),
291            alternative: "install an in-process backend that genuinely delivers with \
292                          EmbeddedConfig::with_conversation_backend"
293                .to_string(),
294        })
295    }
296}
297
298/// Configuration for embedded SDK handles.
299#[derive(Clone, Debug)]
300pub struct EmbeddedConfig {
301    /// Application-visible channel name.
302    pub channel_name: String,
303    /// Application-visible conversation identifier.
304    pub conversation_id: ConversationId,
305    /// Direct channel backend used for in-process publication.
306    pub channel_backend: Arc<dyn EmbeddedChannelBackend>,
307    /// Direct conversation backend used for in-process conversation sends.
308    pub conversation_backend: Arc<dyn EmbeddedConversationBackend>,
309}
310
311impl EmbeddedConfig {
312    /// Creates embedded configuration without requiring a server address.
313    ///
314    /// **The default backends REFUSE.** Both
315    /// [`DirectEmbeddedChannelBackend`] and [`DirectEmbeddedConversationBackend`]
316    /// return [`SdkError::Unwired`] naming seam B1: embedded mode has no
317    /// in-process bus behind it, and this configuration will not pretend
318    /// otherwise by reporting an `Accept` nothing delivered. Install real
319    /// backends with [`with_channel_backend`](Self::with_channel_backend) and
320    /// [`with_conversation_backend`](Self::with_conversation_backend).
321    #[must_use]
322    pub fn new(
323        channel_name: impl Into<String>,
324        conversation_id: impl Into<ConversationId>,
325    ) -> Self {
326        Self {
327            channel_name: channel_name.into(),
328            conversation_id: conversation_id.into(),
329            channel_backend: Arc::new(DirectEmbeddedChannelBackend),
330            conversation_backend: Arc::new(DirectEmbeddedConversationBackend),
331        }
332    }
333
334    /// Replaces the direct in-process channel backend.
335    ///
336    /// This installs a publish path and nothing else: [`EmbeddedChannelBackend`]
337    /// has no subscribe or deliver method, so a backend installed here does not
338    /// give [`ChannelHandle::subscribe`] a delivery path — that surface refuses
339    /// either way.
340    #[must_use]
341    pub fn with_channel_backend(mut self, backend: Arc<dyn EmbeddedChannelBackend>) -> Self {
342        self.channel_backend = backend;
343        self
344    }
345
346    /// Replaces the direct in-process conversation backend.
347    #[must_use]
348    pub fn with_conversation_backend(
349        mut self,
350        backend: Arc<dyn EmbeddedConversationBackend>,
351    ) -> Self {
352        self.conversation_backend = backend;
353        self
354    }
355}
356
357/// Channel handle that publishes through direct in-process references.
358///
359/// Publishes — and only publishes. [`ChannelHandle::subscribe`] on this handle
360/// REFUSES with [`SdkError::Unwired`]: embedded mode has no in-process delivery
361/// path, and this handle will not hand back an empty stream that looks like a
362/// live subscription with nothing on it yet.
363#[derive(Clone, Debug)]
364pub struct EmbeddedChannelHandle {
365    channel_name: String,
366    backend: Arc<dyn EmbeddedChannelBackend>,
367}
368
369impl EmbeddedChannelHandle {
370    /// Creates an embedded channel handle from direct in-process configuration.
371    #[must_use]
372    pub fn new(config: &EmbeddedConfig) -> Self {
373        Self {
374            channel_name: config.channel_name.clone(),
375            backend: Arc::clone(&config.channel_backend),
376        }
377    }
378
379    /// Returns the application-visible channel name.
380    #[must_use]
381    pub fn channel_name(&self) -> &str {
382        self.channel_name.as_str()
383    }
384}
385
386impl ChannelHandle for EmbeddedChannelHandle {
387    type Subscription<M>
388        = SdkSubscription<M>
389    where
390        M: DeserializeOwned;
391
392    type ReplyFuture<'a, Resp>
393        = ReadyResult<Resp>
394    where
395        Self: 'a,
396        Resp: DeserializeOwned + 'a;
397
398    fn publish<M>(&self, message: M) -> Result<PressureResponse, SdkError>
399    where
400        M: Serialize + SchemaValidate,
401    {
402        self.backend.publish(&message)
403    }
404
405    /// REFUSES. Embedded typed subscription delivery is unimplemented.
406    ///
407    /// The returned stream yields exactly one item — `Err(SdkError::Unwired)` —
408    /// and then ends. It touches no backend, because there is no backend method
409    /// it could touch.
410    ///
411    /// What this replaces reported a success it could not have had. The old body
412    /// returned [`SdkSubscription::empty`], a stream whose `poll_next` is
413    /// `Ready(None)` immediately and forever, so a caller's
414    /// `while let Some(m) = sub.next().await` exited on the first poll and read
415    /// as "subscribed, nothing published yet" — indistinguishable from a quiet
416    /// channel, and permanent.
417    ///
418    /// There is no in-process alternative to point at: [`EmbeddedChannelBackend`]
419    /// is publish-only, so a backend installed through
420    /// [`EmbeddedConfig::with_channel_backend`] gives this surface nothing. For
421    /// real delivery use a remote handle against a running server —
422    /// `SubscriptionStream::open` plus `SubscriptionStream::recv_timeout`, or
423    /// `WebSocketSubscriptionStream::open` on the WebSocket transport.
424    fn subscribe<M>(&self) -> Self::Subscription<M>
425    where
426        M: DeserializeOwned,
427    {
428        SdkSubscription::error(unwired_embedded_subscribe())
429    }
430
431    fn request_reply<Req, Resp>(&self, request: Req) -> ReadyResult<Resp>
432    where
433        Req: Serialize + SchemaValidate,
434        Resp: DeserializeOwned,
435    {
436        let schema = Req::schema_metadata();
437        core::hint::black_box((&request, &schema));
438        ReadyResult::new(Err(SdkError::Protocol {
439            description: "embedded request/reply requires an in-process responder backend"
440                .to_string(),
441        }))
442    }
443}
444
445/// Conversation handle that sends through direct in-process references.
446#[derive(Clone, Debug)]
447pub struct EmbeddedConversationHandle {
448    conversation_id: ConversationId,
449    backend: Arc<dyn EmbeddedConversationBackend>,
450}
451
452impl EmbeddedConversationHandle {
453    /// Creates an embedded conversation handle from direct in-process configuration.
454    #[must_use]
455    pub fn new(config: &EmbeddedConfig) -> Self {
456        Self {
457            conversation_id: config.conversation_id.clone(),
458            backend: Arc::clone(&config.conversation_backend),
459        }
460    }
461
462    /// Returns the application-visible conversation identifier.
463    #[must_use]
464    pub const fn conversation_id(&self) -> &ConversationId {
465        &self.conversation_id
466    }
467}
468
469impl ConversationHandle for EmbeddedConversationHandle {
470    type ReceiveFuture<'a, M>
471        = ReadyResult<M>
472    where
473        Self: 'a,
474        M: DeserializeOwned + 'a;
475
476    type LifecycleStream = EmptyLifecycleStream;
477
478    fn send<M>(&self, message: M) -> Result<(), SdkError>
479    where
480        M: Serialize,
481    {
482        self.backend.send(&message)
483    }
484
485    fn receive<M>(&self) -> ReadyResult<M>
486    where
487        M: DeserializeOwned,
488    {
489        ReadyResult::new(Err(SdkError::Conversation {
490            conversation_id: self.conversation_id.as_str().to_string(),
491            description: "embedded receive requires an in-process inbox backend".to_string(),
492        }))
493    }
494
495    fn lifecycle(&self) -> Self::LifecycleStream {
496        EmptyLifecycleStream
497    }
498}