Skip to main content

sim_lib_stream_host/
device.rs

1//! Session-oriented stream-device provider surface.
2
3use std::{
4    collections::{BTreeMap, VecDeque},
5    fmt,
6    time::Duration,
7};
8
9use sim_kernel::{CapabilityName, Expr, Symbol};
10
11/// Result type returned by device providers and sessions.
12pub type DeviceResult<T> = std::result::Result<T, DeviceError>;
13
14/// Error returned by stream-device providers and sessions.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum DeviceError {
17    /// The selected provider does not support opening or using a device.
18    Unsupported,
19    /// A sample expression was malformed for the requested sample kind.
20    Sample(String),
21    /// A provider or session failed for a host-specific reason.
22    Host(String),
23    /// Provider metadata or an effect request violates the authority contract.
24    Contract(String),
25}
26
27impl fmt::Display for DeviceError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Unsupported => f.write_str("device provider is unsupported"),
31            Self::Sample(message) => write!(f, "device sample error: {message}"),
32            Self::Host(message) => f.write_str(message),
33            Self::Contract(message) => write!(f, "device contract error: {message}"),
34        }
35    }
36}
37
38impl std::error::Error for DeviceError {}
39
40impl From<DeviceError> for sim_kernel::Error {
41    fn from(error: DeviceError) -> Self {
42        match error {
43            DeviceError::Unsupported => {
44                Self::HostError("device provider is unsupported".to_owned())
45            }
46            DeviceError::Sample(message) => Self::Eval(format!("device sample error: {message}")),
47            DeviceError::Host(message) => Self::HostError(message),
48            DeviceError::Contract(message) => {
49                Self::Eval(format!("device contract error: {message}"))
50            }
51        }
52    }
53}
54
55/// Stream-facing profile advertised by a concrete device.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct DeviceProfile {
58    /// Stable device identity.
59    pub device: Symbol,
60    /// Sample streams this device can emit.
61    pub streams: Vec<Symbol>,
62    /// Input controls accepted by the device.
63    pub inputs: Vec<Symbol>,
64    /// Output actuators exposed by the device.
65    pub outputs: Vec<Symbol>,
66    /// Sample kinds the provider may return from [`ObservationSession::poll`].
67    pub sample_kinds: Vec<Symbol>,
68}
69
70impl DeviceProfile {
71    /// Builds a device profile from stable stream-facing metadata.
72    pub fn new(
73        device: Symbol,
74        streams: Vec<Symbol>,
75        inputs: Vec<Symbol>,
76        outputs: Vec<Symbol>,
77        sample_kinds: Vec<Symbol>,
78    ) -> Self {
79        Self {
80            device,
81            streams,
82            inputs,
83            outputs,
84            sample_kinds,
85        }
86    }
87
88    /// Builds the deterministic modeled edge profile used by tests and docs.
89    pub fn modeled_edge() -> Self {
90        Self::new(
91            Symbol::qualified("device", "modeled-edge"),
92            vec![
93                Symbol::qualified("device/stream", "battery"),
94                Symbol::qualified("device/stream", "motion"),
95            ],
96            vec![Symbol::qualified("device/input", "button")],
97            vec![
98                Symbol::qualified("device/output", "screen"),
99                Symbol::qualified("device/output", "haptic"),
100            ],
101            vec![device_sample_kind_symbol("device-caps")],
102        )
103    }
104
105    /// Returns whether this profile advertises `sample_kind`.
106    pub fn supports_sample_kind(&self, sample_kind: &Symbol) -> bool {
107        self.sample_kinds.contains(sample_kind)
108    }
109}
110
111/// Device sample expression contract used by session polling helpers.
112pub trait DeviceSample: Sized {
113    /// Stable bare sample kind, such as `device-caps`.
114    fn sample_kind() -> &'static str;
115
116    /// Encodes the sample as a self-describing expression.
117    fn to_expr(&self) -> Expr;
118
119    /// Decodes the sample from its expression form.
120    fn from_expr(expr: &Expr) -> DeviceResult<Self>;
121}
122
123/// Returns the qualified sample-kind symbol for `kind`.
124pub fn device_sample_kind_symbol(kind: &str) -> Symbol {
125    Symbol::qualified("stream/device-sample", kind)
126}
127
128/// Provider that opens one stream-device session.
129pub trait DeviceProvider: Send {
130    /// Opens a provider-owned device session.
131    fn open(&self) -> DeviceResult<OpenedSession>;
132}
133
134/// Open read-only stream-device session.
135///
136/// This trait deliberately has no downcast hook and no generic command method.
137/// A caller holding this object can only observe and release the device.
138///
139/// ```compile_fail
140/// # use sim_lib_stream_host::{EffectRequest, ObservationSession};
141/// fn hostile_caller(session: &mut dyn ObservationSession, request: EffectRequest) {
142///     session.invoke(request); // observation authority has no invocation escape
143/// }
144/// ```
145pub trait ObservationSession: Send {
146    /// Returns the profile for this session.
147    fn profile(&self) -> &DeviceProfile;
148
149    /// Starts sample processing.
150    fn start(&mut self) -> DeviceResult<()>;
151
152    /// Polls one sample expression for `kind`.
153    fn poll(&mut self, kind: &str) -> DeviceResult<Option<Expr>>;
154
155    /// Stops sample processing and releases session resources.
156    fn stop(&mut self) -> DeviceResult<()>;
157}
158
159/// Open device session that may invoke registered, named effects.
160pub trait EffectSession: ObservationSession {
161    /// Invokes one request after matching its registered descriptor.
162    fn invoke(&mut self, request: EffectRequest) -> DeviceResult<EffectReceipt>;
163}
164
165/// Authority-preserving result of opening a provider.
166pub enum OpenedSession {
167    /// A session whose reachable type exposes observation only.
168    Observe(Box<dyn ObservationSession>),
169    /// A session with explicitly described effect authority.
170    Effect(Box<dyn EffectSession>),
171}
172
173impl OpenedSession {
174    /// Borrows the observation surface shared by both variants.
175    pub fn observation(&mut self) -> &mut dyn ObservationSession {
176        match self {
177            Self::Observe(session) => session.as_mut(),
178            Self::Effect(session) => session.as_mut(),
179        }
180    }
181
182    /// Returns the effect surface only when the provider explicitly opened one.
183    pub fn effect(&mut self) -> Option<&mut (dyn EffectSession + '_)> {
184        match self {
185            Self::Observe(_) => None,
186            Self::Effect(session) => Some(session.as_mut()),
187        }
188    }
189}
190
191/// Closed vocabulary of supported provider transports.
192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193pub enum ProviderTransport {
194    /// Deterministic in-memory cassette.
195    Cassette,
196    /// Bluetooth Low Energy device link.
197    Ble,
198    /// Local USB device link.
199    Usb,
200    /// Local native host API.
201    Native,
202    /// Explicit user-supplied import.
203    Import,
204}
205
206/// Policy for retrying an effect request.
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub enum IdempotencePolicy {
209    /// Repeating the request is safe.
210    Idempotent,
211    /// A stable caller key is required for deduplication.
212    Keyed,
213    /// The effect must not be retried automatically.
214    AtMostOnce,
215}
216
217/// Policy describing whether and how an effect can be reversed.
218#[derive(Clone, Debug, PartialEq, Eq)]
219pub enum ReversalPolicy {
220    /// No reversal exists.
221    Irreversible,
222    /// Invoke the named registered effect to reverse this effect.
223    Effect(Symbol),
224}
225
226/// Bounds attached to every callable effect.
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct EffectBounds {
229    /// Maximum encoded request size.
230    pub max_request_bytes: usize,
231    /// Maximum number of invocations for one opened session.
232    pub max_invocations: u64,
233}
234
235/// Complete registered authority description for one named effect.
236#[derive(Clone, Debug, PartialEq, Eq)]
237pub struct EffectDescriptor {
238    /// Stable descriptor identity.
239    pub id: Symbol,
240    /// Shape reference used to validate the request payload.
241    pub shape: Symbol,
242    /// Capability required from the caller.
243    pub capability: CapabilityName,
244    /// Request and session bounds.
245    pub bounds: EffectBounds,
246    /// Whether an explicit arm token is mandatory.
247    pub requires_arm: bool,
248    /// Maximum age of an armed request.
249    pub expires_after: Duration,
250    /// Receipt kind emitted after execution.
251    pub receipt: Symbol,
252    /// Retry semantics.
253    pub idempotence: IdempotencePolicy,
254    /// Reversal semantics.
255    pub reversal: ReversalPolicy,
256    /// Locally executable stop effect.
257    pub local_stop: Symbol,
258}
259
260impl EffectDescriptor {
261    /// Validates that every mandatory authority field is meaningful.
262    pub fn validate(&self) -> DeviceResult<()> {
263        if self.shape.name.is_empty()
264            || self.capability.as_str().is_empty()
265            || self.bounds.max_request_bytes == 0
266            || self.bounds.max_invocations == 0
267            || self.expires_after.is_zero()
268            || self.receipt.name.is_empty()
269            || self.local_stop.name.is_empty()
270        {
271            return Err(DeviceError::Contract(format!(
272                "effect {} has an incomplete authority descriptor",
273                self.id
274            )));
275        }
276        if matches!(&self.reversal, ReversalPolicy::Effect(effect) if effect == &self.id) {
277            return Err(DeviceError::Contract(format!(
278                "effect {} reverses itself",
279                self.id
280            )));
281        }
282        Ok(())
283    }
284
285    /// Checks caller authority, arming, expiry, idempotence, and payload bounds.
286    pub fn authorize(&self, request: &EffectRequest) -> DeviceResult<()> {
287        self.validate()?;
288        if request.descriptor != self.id {
289            return Err(DeviceError::Contract("forged effect descriptor".to_owned()));
290        }
291        if !request.grants.contains(&self.capability) {
292            return Err(DeviceError::Contract(format!(
293                "missing capability {}",
294                self.capability
295            )));
296        }
297        if self.requires_arm && request.arm.as_deref().is_none_or(str::is_empty) {
298            return Err(DeviceError::Contract(
299                "effect request is not armed".to_owned(),
300            ));
301        }
302        if request.invoked_at_ms < request.armed_at_ms
303            || Duration::from_millis(request.invoked_at_ms - request.armed_at_ms)
304                > self.expires_after
305        {
306            return Err(DeviceError::Contract(
307                "effect request arm has expired".to_owned(),
308            ));
309        }
310        if matches!(self.idempotence, IdempotencePolicy::Keyed)
311            && request.idempotence_key.as_deref().is_none_or(str::is_empty)
312        {
313            return Err(DeviceError::Contract(
314                "effect request has no idempotence key".to_owned(),
315            ));
316        }
317        if format!("{:?}", request.payload).len() > self.bounds.max_request_bytes {
318            return Err(DeviceError::Contract(
319                "effect request exceeds its size bound".to_owned(),
320            ));
321        }
322        Ok(())
323    }
324}
325
326/// Builds the complete standard descriptor used by an in-repository device effect.
327pub fn standard_effect_descriptor(id: &str) -> EffectDescriptor {
328    EffectDescriptor {
329        id: Symbol::qualified("device/effect", id),
330        shape: Symbol::qualified("shape/device-effect", id),
331        capability: CapabilityName::new(format!("device.effect.{id}")),
332        bounds: EffectBounds {
333            max_request_bytes: 64 * 1024,
334            max_invocations: u64::MAX,
335        },
336        requires_arm: true,
337        expires_after: Duration::from_secs(30),
338        receipt: Symbol::qualified("device/receipt", id),
339        idempotence: IdempotencePolicy::Keyed,
340        reversal: ReversalPolicy::Irreversible,
341        local_stop: Symbol::qualified("device/effect", "stop"),
342    }
343}
344
345/// Registry whose entries, rather than manifest strings, create effect callables.
346#[derive(Clone, Debug, Default)]
347pub struct EffectRegistry(BTreeMap<Symbol, EffectDescriptor>);
348
349impl EffectRegistry {
350    /// Builds a checked registry, rejecting duplicate or incomplete descriptors.
351    pub fn new(descriptors: impl IntoIterator<Item = EffectDescriptor>) -> DeviceResult<Self> {
352        let mut entries = BTreeMap::new();
353        for descriptor in descriptors {
354            descriptor.validate()?;
355            let id = descriptor.id.clone();
356            if entries.insert(id.clone(), descriptor).is_some() {
357                return Err(DeviceError::Contract(format!(
358                    "duplicate effect descriptor {id}"
359                )));
360            }
361        }
362        Ok(Self(entries))
363    }
364
365    /// Resolves a descriptor by stable identity.
366    pub fn get(&self, id: &Symbol) -> Option<&EffectDescriptor> {
367        self.0.get(id)
368    }
369}
370
371/// Versioned, non-secret provider declaration.
372#[derive(Clone, Debug, PartialEq, Eq)]
373pub struct ProviderManifest {
374    /// Manifest schema version. Version one is currently supported.
375    pub version: u16,
376    /// Stable provider identity.
377    pub id: Symbol,
378    /// Transport selected from the closed vocabulary.
379    pub transport: ProviderTransport,
380    /// Content-addressed profile reference.
381    pub profile: String,
382    /// Declared observation kinds.
383    pub observations: Vec<Symbol>,
384    /// References to registered effect descriptors.
385    pub effects: Vec<Symbol>,
386    /// Explicit consent capabilities.
387    pub consent: Vec<CapabilityName>,
388    /// Bound after which discovery data is stale.
389    pub stale_after: Duration,
390    /// Content-addressed fake cassette.
391    pub cassette: String,
392    /// Named observation-only fallback.
393    pub fallback: Symbol,
394}
395
396impl ProviderManifest {
397    /// Validates schema, staleness, secret hygiene, and descriptor references.
398    pub fn validate(&self, registry: &EffectRegistry) -> DeviceResult<()> {
399        if self.version != 1 {
400            return Err(DeviceError::Contract(format!(
401                "unsupported provider manifest version {}",
402                self.version
403            )));
404        }
405        if self.stale_after.is_zero() {
406            return Err(DeviceError::Contract(
407                "provider manifest has no stale bound".to_owned(),
408            ));
409        }
410        for value in [&self.profile, &self.cassette] {
411            let lower = value.to_ascii_lowercase();
412            if lower.contains("secret") || lower.contains("token") || lower.contains("password") {
413                return Err(DeviceError::Contract(
414                    "provider manifest contains secret material".to_owned(),
415                ));
416            }
417            if value.is_empty() {
418                return Err(DeviceError::Contract(
419                    "provider manifest has an empty content reference".to_owned(),
420                ));
421            }
422        }
423        for effect in &self.effects {
424            if registry.get(effect).is_none() {
425                return Err(DeviceError::Contract(format!(
426                    "undeclared effect descriptor {effect}"
427                )));
428            }
429        }
430        Ok(())
431    }
432}
433
434/// One invocation of a registered effect.
435#[derive(Clone, Debug, PartialEq)]
436pub struct EffectRequest {
437    /// Registered descriptor identity.
438    pub descriptor: Symbol,
439    /// Shape-checked payload.
440    pub payload: Expr,
441    /// Explicit arm token when required by the descriptor.
442    pub arm: Option<String>,
443    /// Stable idempotence key when required by policy.
444    pub idempotence_key: Option<String>,
445    /// Capabilities explicitly presented for this invocation.
446    pub grants: Vec<CapabilityName>,
447    /// Monotonic timestamp at which the request was armed.
448    pub armed_at_ms: u64,
449    /// Monotonic timestamp at which invocation was attempted.
450    pub invoked_at_ms: u64,
451}
452
453/// Durable acknowledgement returned for an invoked effect.
454#[derive(Clone, Debug, PartialEq, Eq)]
455pub struct EffectReceipt {
456    /// Receipt kind declared by the descriptor.
457    pub kind: Symbol,
458    /// Descriptor that authorized the effect.
459    pub descriptor: Symbol,
460    /// Provider-local monotonically increasing sequence.
461    pub sequence: u64,
462}
463
464/// Deterministic effect session used to prove registered adapters without hardware.
465pub struct FakeEffectSession {
466    profile: DeviceProfile,
467    registry: EffectRegistry,
468    receipts: BTreeMap<String, EffectReceipt>,
469    invocations: BTreeMap<Symbol, u64>,
470    stopped: bool,
471    sequence: u64,
472}
473
474impl FakeEffectSession {
475    /// Builds a fake session from individually reviewed descriptors.
476    pub fn new(profile: DeviceProfile, registry: EffectRegistry) -> Self {
477        Self {
478            profile,
479            registry,
480            receipts: BTreeMap::new(),
481            invocations: BTreeMap::new(),
482            stopped: false,
483            sequence: 0,
484        }
485    }
486}
487
488impl ObservationSession for FakeEffectSession {
489    fn profile(&self) -> &DeviceProfile {
490        &self.profile
491    }
492    fn start(&mut self) -> DeviceResult<()> {
493        self.stopped = false;
494        Ok(())
495    }
496    fn poll(&mut self, _kind: &str) -> DeviceResult<Option<Expr>> {
497        Ok(None)
498    }
499    fn stop(&mut self) -> DeviceResult<()> {
500        self.stopped = true;
501        Ok(())
502    }
503}
504
505impl EffectSession for FakeEffectSession {
506    fn invoke(&mut self, request: EffectRequest) -> DeviceResult<EffectReceipt> {
507        if self.stopped {
508            return Err(DeviceError::Host(
509                "effect session is locally stopped".into(),
510            ));
511        }
512        let descriptor = self
513            .registry
514            .get(&request.descriptor)
515            .ok_or_else(|| DeviceError::Contract("effect is not registered".into()))?;
516        descriptor.authorize(&request)?;
517        let count = self
518            .invocations
519            .entry(request.descriptor.clone())
520            .or_default();
521        if *count >= descriptor.bounds.max_invocations {
522            return Err(DeviceError::Contract(
523                "effect invocation bound exhausted".into(),
524            ));
525        }
526        if let Some(key) = request.idempotence_key.as_ref()
527            && let Some(receipt) = self.receipts.get(key)
528        {
529            return Ok(receipt.clone());
530        }
531        *count += 1;
532        self.sequence += 1;
533        let receipt = EffectReceipt {
534            kind: descriptor.receipt.clone(),
535            descriptor: descriptor.id.clone(),
536            sequence: self.sequence,
537        };
538        if let Some(key) = request.idempotence_key {
539            self.receipts.insert(key, receipt.clone());
540        }
541        Ok(receipt)
542    }
543}
544
545/// Polls and decodes a typed device sample from a session.
546pub fn poll_device_sample<S>(session: &mut dyn ObservationSession) -> DeviceResult<Option<S>>
547where
548    S: DeviceSample,
549{
550    session
551        .poll(S::sample_kind())?
552        .map(|expr| S::from_expr(&expr))
553        .transpose()
554}
555
556/// Hardware-free provider used when no concrete device provider is installed.
557#[derive(Clone, Debug, PartialEq, Eq)]
558pub struct StubProvider {
559    profile: DeviceProfile,
560}
561
562impl StubProvider {
563    /// Builds a stub provider for the supplied profile.
564    pub fn new(profile: DeviceProfile) -> Self {
565        Self { profile }
566    }
567
568    /// Returns the profile this stub advertises for browse and placement.
569    pub fn profile(&self) -> &DeviceProfile {
570        &self.profile
571    }
572
573    /// Builds an unopened stub session for provider-surface validation.
574    pub fn session(&self) -> StubSession {
575        StubSession::new(self.profile.clone())
576    }
577}
578
579impl DeviceProvider for StubProvider {
580    fn open(&self) -> DeviceResult<OpenedSession> {
581        Err(DeviceError::Unsupported)
582    }
583}
584
585/// Hardware-free session that refuses all live device operations.
586#[derive(Clone, Debug, PartialEq, Eq)]
587pub struct StubSession {
588    profile: DeviceProfile,
589}
590
591/// Deterministic observation-only cassette provider for tests and recipes.
592#[derive(Clone, Debug)]
593pub struct ObservationCassette {
594    profile: DeviceProfile,
595    samples: Vec<Expr>,
596}
597
598impl ObservationCassette {
599    /// Builds a cassette whose samples are replayed in insertion order.
600    pub fn new(profile: DeviceProfile, samples: Vec<Expr>) -> Self {
601        Self { profile, samples }
602    }
603}
604
605impl DeviceProvider for ObservationCassette {
606    fn open(&self) -> DeviceResult<OpenedSession> {
607        Ok(OpenedSession::Observe(Box::new(
608            ObservationCassetteSession {
609                profile: self.profile.clone(),
610                samples: self.samples.clone().into(),
611            },
612        )))
613    }
614}
615
616struct ObservationCassetteSession {
617    profile: DeviceProfile,
618    samples: VecDeque<Expr>,
619}
620
621impl ObservationSession for ObservationCassetteSession {
622    fn profile(&self) -> &DeviceProfile {
623        &self.profile
624    }
625    fn start(&mut self) -> DeviceResult<()> {
626        Ok(())
627    }
628    fn poll(&mut self, _kind: &str) -> DeviceResult<Option<Expr>> {
629        Ok(self.samples.pop_front())
630    }
631    fn stop(&mut self) -> DeviceResult<()> {
632        Ok(())
633    }
634}
635
636impl StubSession {
637    /// Builds a stub session for the supplied profile.
638    pub fn new(profile: DeviceProfile) -> Self {
639        Self { profile }
640    }
641}
642
643impl ObservationSession for StubSession {
644    fn profile(&self) -> &DeviceProfile {
645        &self.profile
646    }
647
648    fn start(&mut self) -> DeviceResult<()> {
649        Err(DeviceError::Unsupported)
650    }
651
652    fn poll(&mut self, _kind: &str) -> DeviceResult<Option<Expr>> {
653        Err(DeviceError::Unsupported)
654    }
655
656    fn stop(&mut self) -> DeviceResult<()> {
657        Ok(())
658    }
659}