Skip to main content

pointlock_provider_devicerail/
session.rs

1//! `DeviceRailSession`: the `ProviderSession` implementation over one
2//! exclusively-owned `DeviceRailClient` connection (04 §1 rule 4 — never
3//! shared, never pooled).
4
5use std::collections::HashSet;
6use std::sync::Mutex;
7
8use async_trait::async_trait;
9use devicerail_client::protocol::{
10    DeviceExecuteParams, EventSequence, EventsListParams, SessionEndParams,
11    SessionId as WireSessionId, SessionOutcome as WireSessionOutcome, TestEvent,
12    UiSnapshotGetParams, Verdict as WireVerdict, VerdictRecordParams,
13    VerdictStatus as WireVerdictStatus, feature,
14};
15use devicerail_client::{CallOptions, ClientError, DeviceRailClient, RequestHandle, methods};
16use pointlock_ir::{
17    ActionOutcome, ActionResult, ErrorClass, EventCursor, Observation, ReconcileResult,
18    ScreenshotOmissionReason, UiSnapshotOmissionReason, VerdictStatus,
19};
20use pointlock_provider_kit::lockfile::CapabilityAttestation;
21use pointlock_provider_kit::{
22    BoundActionCall, CancellationToken, EvidenceStream, ObserveRequest, ObserveWant, ProviderError,
23    ProviderSession, RetryableSource, SessionHealth, SessionOutcome, UiSnapshotOutcome,
24    VERDICT_EVIDENCE_MAX_ENTRIES, VERDICT_SUMMARY_MAX_CHARS, VerdictWrite, now_ms,
25    observation_projection, synthetic_observation_wants,
26};
27use uuid::Uuid;
28
29use crate::budget::{
30    BoundedError, DEFAULT_CALL_BUDGET_MS, ENVELOPE_MARGIN_MS, bounded, clamp_timeout,
31    envelope_options,
32};
33use crate::convert::{
34    action_outcome_from_wire, action_result_from_wire, asset_ref_to_wire, observation_from_wire,
35    ui_snapshot_omission_from_wire,
36};
37use crate::error_map::{
38    cancelled_before_dispatch, execute_terminal_from_rpc, provider_error_from_client, session_gone,
39};
40use crate::scan::{CallFate, SCAN_PAGE_LIMIT, latest_sequence, scan_for_call};
41
42#[derive(Debug, Default)]
43struct SessionState {
44    /// Set by [`DeviceRailSession::end`]; every method except `health` and
45    /// `reconcile` fails afterwards (04 §2.1).
46    ended: bool,
47    /// Most recently observed degradation reason (wire `session_degraded`
48    /// message), surfaced by `health` (04 §2.1).
49    degraded: Option<String>,
50    /// callIds already dispatched through this session — a repeated callId
51    /// is rejected; a retry is a new callId with a new WAL intent (04 §3).
52    dispatched: HashSet<Uuid>,
53    /// Highest event sequence this provider has delivered to the runner
54    /// through `currentCursor` (ack-after-persist watermark, 04 §9.8.3).
55    watermark: Option<EventSequence>,
56}
57
58/// One open DeviceRail session (see the module docs).
59pub struct DeviceRailSession {
60    client: DeviceRailClient,
61    attestation: CapabilityAttestation,
62    session_id: WireSessionId,
63    state: Mutex<SessionState>,
64}
65
66impl std::fmt::Debug for DeviceRailSession {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("DeviceRailSession")
69            .field("session_id", &self.session_id)
70            .finish_non_exhaustive()
71    }
72}
73
74impl DeviceRailSession {
75    pub(crate) fn new(
76        client: DeviceRailClient,
77        attestation: CapabilityAttestation,
78        session_id: WireSessionId,
79    ) -> Self {
80        DeviceRailSession {
81            client,
82            attestation,
83            session_id,
84            state: Mutex::new(SessionState::default()),
85        }
86    }
87
88    fn state(&self) -> std::sync::MutexGuard<'_, SessionState> {
89        self.state.lock().expect("session state lock poisoned")
90    }
91
92    fn ensure_active(&self, method: &str) -> Result<(), ProviderError> {
93        if self.state().ended {
94            return Err(session_gone(method));
95        }
96        Ok(())
97    }
98
99    fn feature_enabled(&self, feature: &str) -> bool {
100        self.attestation
101            .features_enabled
102            .iter()
103            .any(|enabled| enabled.as_str() == feature)
104    }
105
106    fn pre_cancelled(cancel: &Option<CancellationToken>) -> bool {
107        cancel.as_ref().is_some_and(CancellationToken::is_cancelled)
108    }
109
110    /// Records a wire-reported degradation for later `health` probes.
111    fn note_degradation(&self, error: &ClientError) {
112        if let ClientError::RemoteRpc { error, .. } = error
113            && error.data.code == "session_degraded"
114        {
115            self.state().degraded = Some(error.data.message.clone());
116        }
117    }
118
119    /// Awaits an in-flight request, forwarding a cancellation intent to the
120    /// daemon when the token fires (04 §7.1): cancellation is a request,
121    /// not a guarantee — after `request.cancel` the call keeps waiting for
122    /// whatever terminal actually materializes.
123    async fn await_with_cancel<T: Send + 'static>(
124        &self,
125        handle: RequestHandle<T>,
126        cancel: Option<CancellationToken>,
127    ) -> Result<T, ClientError> {
128        let request_id = handle.id().clone();
129        let result = handle.result();
130        let Some(token) = cancel else {
131            return result.await;
132        };
133        let mut result = std::pin::pin!(result);
134        tokio::select! {
135            outcome = &mut result => outcome,
136            () = token.cancelled() => {
137                // Best-effort: the daemon may already be finalizing.
138                let _ = self.client.cancel(request_id).await;
139                result.await
140            }
141        }
142    }
143
144    /// One `events.list` page of the issuing session's log (04 §5 query
145    /// discipline: only ever this session's log in the M1 single-session
146    /// scenario), under the provider-local budget.
147    async fn events_page(
148        &self,
149        after: Option<EventSequence>,
150    ) -> Result<Vec<TestEvent>, BoundedError> {
151        let params = EventsListParams {
152            session_id: Some(self.session_id.clone()),
153            after_sequence: after,
154            limit: Some(SCAN_PAGE_LIMIT),
155        };
156        bounded(
157            self.client
158                .call::<methods::EventsList>(Some(params), CallOptions::default()),
159        )
160        .await
161    }
162
163    /// The 04 §9.2 pin: compile time guarantees `verdict.record.v1`
164    /// rides every IR into `requiredFeatures`, so an un-negotiated
165    /// session can never legitimately reach `recordVerdict` — reaching
166    /// it IS capability drift (fail-closed, never a silent no-op). The
167    /// runner turns the failure into the 04 §5 "remote archival failed"
168    /// annotation; the local verdict is untouched either way.
169    fn verdict_record_gate(negotiated: bool) -> Result<(), ProviderError> {
170        if negotiated {
171            return Ok(());
172        }
173        Err(ProviderError::new(
174            ErrorClass::CapabilityDrift,
175            "verdict.record.v1 was not negotiated in this session; the compiler \
176             guarantees every IR requires it (04 §9.2) — reaching recordVerdict \
177             without it is capability drift",
178            RetryableSource::Classifier,
179        ))
180    }
181
182    /// Derives the request-envelope budget for one `device.execute`
183    /// (04 §9.7): an explicit `requestTimeoutMs` wins (after checking
184    /// invariant 1); otherwise the envelope is the action budget plus the
185    /// margin, so timeouts converge on the definite `timedOut` side.
186    fn execute_budgets(
187        call: &BoundActionCall,
188    ) -> Result<(Option<u64>, Option<u64>), ProviderError> {
189        match (call.action_timeout_ms, call.request_timeout_ms) {
190            (Some(action), Some(envelope)) if action >= envelope => Err(ProviderError::new(
191                ErrorClass::BindArgumentsInvalid,
192                format!(
193                    "budget invariant violated: actionTimeoutMs ({action}) must be strictly \
194                     below the request envelope timeoutMs ({envelope}) (04 §9.7 invariant 1)"
195                ),
196                RetryableSource::Classifier,
197            )),
198            (action, Some(envelope)) => Ok((action, Some(envelope))),
199            (Some(action), None) => Ok((
200                Some(action),
201                Some(action.saturating_add(ENVELOPE_MARGIN_MS)),
202            )),
203            (None, None) => Ok((None, None)),
204        }
205    }
206
207    /// Serves a provider-synthetic observation action (04 §9.4.3).
208    ///
209    /// The runner's contract is unchanged — it called `execute` and gets an
210    /// `ActionOutcome` back — but the wire path is `device.observe`, not
211    /// `device.execute`. The action is readonly, so there is no WAL
212    /// concern: a dangling readonly intent is always safe to replay, which
213    /// is why these actions are exempt from reconcile by construction
214    /// (spine §6.7-B).
215    async fn execute_observation(
216        &self,
217        call: BoundActionCall,
218        wants: Vec<ObserveWant>,
219        cancel: Option<CancellationToken>,
220    ) -> Result<ActionOutcome, ProviderError> {
221        let started_at_ms = now_ms();
222        let observation = self
223            .observe(
224                ObserveRequest {
225                    wants: wants.clone(),
226                },
227                cancel,
228            )
229            .await?;
230        let output = observation_projection(&observation, &wants);
231        Ok(ActionOutcome::Succeeded {
232            result: Box::new(ActionResult {
233                call_id: call.call_id,
234                started_at_ms,
235                finished_at_ms: now_ms(),
236                output,
237                before: None,
238                // The observation IS the act: recording it as the after
239                // snapshot lets a following assertion verify against it
240                // through the ordinary verify chain.
241                after: Some(observation),
242                evidence: Vec::new(),
243                execution: None,
244            }),
245        })
246    }
247}
248
249#[async_trait]
250impl ProviderSession for DeviceRailSession {
251    fn attestation(&self) -> &CapabilityAttestation {
252        &self.attestation
253    }
254
255    async fn execute(
256        &self,
257        call: BoundActionCall,
258        cancel: Option<CancellationToken>,
259    ) -> Result<ActionOutcome, ProviderError> {
260        self.ensure_active("execute")?;
261        if Self::pre_cancelled(&cancel) {
262            // The WAL intent is already written; reconcile will find
263            // neverDispatched — safe (04 §7.1).
264            return Err(cancelled_before_dispatch());
265        }
266        // Provider-synthetic observation actions (04 §9.4.3) route to
267        // `device.observe` instead of `device.execute`. They are the
268        // provider's own, so no driver declares them and they are absent
269        // from the attestation — which is also the shadowing rule at run
270        // time: a driver action of the same name IS attested and therefore
271        // takes the normal path below, exactly as bind resolved it.
272        if !self.attestation.actions.contains_key(&call.action_name)
273            && let Some(wants) = synthetic_observation_wants(&call)?
274        {
275            return self.execute_observation(call, wants, cancel).await;
276        }
277        // Fail-closed on unattested actions, before any wire request (04 §3).
278        if !self.attestation.actions.contains_key(&call.action_name) {
279            return Err(ProviderError::new(
280                ErrorClass::CapabilityDrift,
281                format!(
282                    "actionName `{}` is not in the attested action set; refusing to dispatch",
283                    call.action_name
284                ),
285                RetryableSource::Classifier,
286            ));
287        }
288        // The runner-generated callId is used verbatim as the substrate
289        // action id (`device.execute` params.id) — never regenerated (04 §3).
290        let call_id = Uuid::parse_str(&call.call_id).map_err(|error| {
291            ProviderError::new(
292                ErrorClass::BindArgumentsInvalid,
293                format!("callId must be the runner-generated UUID: {error}"),
294                RetryableSource::Classifier,
295            )
296        })?;
297        let (action_ms, envelope_ms) = Self::execute_budgets(&call)?;
298        if !self.state().dispatched.insert(call_id) {
299            return Err(ProviderError::new(
300                ErrorClass::BindArgumentsInvalid,
301                format!(
302                    "duplicate callId {call_id}: a retry is a new callId with a new WAL intent \
303                     (04 §3)"
304                ),
305                RetryableSource::Classifier,
306            ));
307        }
308
309        let params = DeviceExecuteParams {
310            id: call_id,
311            name: call.action_name.as_str().to_owned(),
312            arguments: call.arguments,
313            action_timeout_ms: action_ms.map(clamp_timeout),
314        };
315        let options = CallOptions {
316            timeout_ms: envelope_ms.map(clamp_timeout),
317        };
318        let handle = self
319            .client
320            .begin_call::<methods::DeviceExecute>(params, options)
321            .map_err(|error| provider_error_from_client(error, "device.execute"))?;
322
323        match self.await_with_cancel(handle, cancel).await {
324            // RPC success only ever carries the succeeded terminal.
325            Ok(result) => Ok(ActionOutcome::Succeeded {
326                result: Box::new(action_result_from_wire(&result)),
327            }),
328            Err(error) => {
329                // Non-succeeded terminals arrive as RPC errors with the
330                // durable-terminal shield behind them; return them
331                // unfolded and untranslated (04 §3).
332                if let ClientError::RemoteRpc { error: rpc, .. } = &error
333                    && let Some(outcome) = execute_terminal_from_rpc(rpc)
334                {
335                    return Ok(outcome);
336                }
337                // No terminal could be obtained: the runner records the
338                // attempt as hanging and goes through reconcile (04 §3).
339                self.note_degradation(&error);
340                Err(provider_error_from_client(error, "device.execute"))
341            }
342        }
343    }
344
345    async fn observe(
346        &self,
347        req: ObserveRequest,
348        cancel: Option<CancellationToken>,
349    ) -> Result<Observation, ProviderError> {
350        self.ensure_active("observe")?;
351        if Self::pre_cancelled(&cancel) {
352            return Err(cancelled_before_dispatch());
353        }
354        // `device.observe` takes no params; `wants` is an intent
355        // declaration, not a wire parameter (04 §4.1).
356        let handle = self
357            .client
358            .begin_call::<methods::DeviceObserve>(
359                methods::NoParams,
360                envelope_options(DEFAULT_CALL_BUDGET_MS),
361            )
362            .map_err(|error| provider_error_from_client(error, "device.observe"))?;
363        let wire_observation = self
364            .await_with_cancel(handle, cancel)
365            .await
366            .map_err(|error| {
367                self.note_degradation(&error);
368                provider_error_from_client(error, "device.observe")
369            })?;
370        let mut observation = observation_from_wire(&wire_observation);
371
372        // Truthful omission back-fill (04 §4.1): when a wanted part is
373        // missing and the daemon made no claim, record why it can
374        // legitimately be absent — omission is data, not an error.
375        if req.wants.contains(&ObserveWant::Screenshot)
376            && observation.screenshot.is_none()
377            && observation.screenshot_omission.is_none()
378        {
379            // The daemon's capture policy decided not to produce one.
380            observation.screenshot_omission = Some(ScreenshotOmissionReason::Policy);
381        }
382        if req.wants.contains(&ObserveWant::UiSnapshot)
383            && observation.ui_snapshot.is_none()
384            && observation.ui_snapshot_omission.is_none()
385        {
386            // No snapshot and no claim: the driver has no semantic UI
387            // channel for this observation.
388            observation.ui_snapshot_omission = Some(UiSnapshotOmissionReason::DriverUnsupported);
389        }
390        Ok(observation)
391    }
392
393    async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
394        self.ensure_active("uiSnapshot")?;
395        // Fail-closed feature gate (04 §9.2 adjudication): compile-time
396        // promises make an un-negotiated `observation.uiSnapshot.v1`
397        // unreachable — reaching it is drift, not a soft omission.
398        if !self.feature_enabled(feature::OBSERVATION_UI_SNAPSHOT_V1) {
399            return Err(ProviderError::new(
400                ErrorClass::CapabilityDrift,
401                "ui.snapshot.get requires observation.uiSnapshot.v1, which this session did \
402                 not negotiate (04 §9.2: reaching this is capability drift)",
403                RetryableSource::Classifier,
404            ));
405        }
406        let observation_id = Uuid::parse_str(observation_id).map_err(|error| {
407            ProviderError::new(
408                ErrorClass::BindArgumentsInvalid,
409                format!("observationId must be the provider-issued UUID: {error}"),
410                RetryableSource::Classifier,
411            )
412        })?;
413        let call = self.client.call::<methods::UiSnapshotGet>(
414            UiSnapshotGetParams { observation_id },
415            CallOptions::default(),
416        );
417        match bounded(call).await {
418            Ok(snapshot) => Ok(UiSnapshotOutcome::Available {
419                // M0-narrow SPI shape: the normalized tree as raw JSON.
420                snapshot: serde_json::to_value(snapshot).map_err(|error| {
421                    ProviderError::new(
422                        ErrorClass::TransportLost,
423                        format!("ui.snapshot.get result failed to re-serialize: {error}"),
424                        RetryableSource::Classifier,
425                    )
426                })?,
427            }),
428            // The daemon's typed "no snapshot on this observation" reply
429            // carries the observation's omission reason — a typed omission,
430            // not an error (04 §4.2).
431            Err(BoundedError::Client(ClientError::RemoteRpc { error, .. }))
432                if error.data.code == "ui_snapshot_unavailable" =>
433            {
434                let reason = error
435                    .data
436                    .details
437                    .as_ref()
438                    .and_then(|details| details.get("omissionReason"))
439                    .and_then(|value| serde_json::from_value(value.clone()).ok())
440                    .map(ui_snapshot_omission_from_wire)
441                    .unwrap_or(UiSnapshotOmissionReason::DriverUnsupported);
442                Ok(UiSnapshotOutcome::Unavailable { reason })
443            }
444            Err(error) => Err(error.into_provider_error("ui.snapshot.get")),
445        }
446    }
447
448    async fn reconcile(
449        &self,
450        call_id: &str,
451        issuing: &EventCursor,
452    ) -> Result<ReconcileResult, ProviderError> {
453        // Cross-generation guard (04 §5, 2026-07-18 incorporation): the
454        // scan below reads THIS session's log. A foreign issuing
455        // credential must answer logUnavailable — scanning the current
456        // session's (reachable but wrong) log would fabricate
457        // `neverDispatched` and license an auto-replay. True old-log
458        // retrieval (sessions.list + events over a dead session) is
459        // provider-milestone work (v0.2).
460        if issuing.session_id != self.session_id.to_string() {
461            return Ok(ReconcileResult::LogUnavailable {
462                reason: format!(
463                    "the issuing session {} is not this session ({}); cross-generation \
464                     log retrieval is not implemented in v0.1",
465                    issuing.session_id, self.session_id
466                ),
467            });
468        }
469        // An ended/broken session cannot reach the issuing session's log
470        // through this connection any more: always the uncertain branch,
471        // never a guess (04 §5).
472        if self.state().ended {
473            return Ok(ReconcileResult::LogUnavailable {
474                reason: "the provider session has ended; the issuing session's event log is \
475                         no longer reachable through this connection"
476                    .to_owned(),
477            });
478        }
479        // The issuing session must still be indexed — `neverDispatched`
480        // requires having read its *complete* event range; a deleted or
481        // cleared log (events.clear, daemon restart) is always
482        // logUnavailable (04 §5).
483        let sessions = match bounded(
484            self.client
485                .call::<methods::SessionsList>(methods::NoParams, CallOptions::default()),
486        )
487        .await
488        {
489            Ok(sessions) => sessions,
490            Err(error) => {
491                return Ok(ReconcileResult::LogUnavailable {
492                    reason: format!("the daemon's session index is unreadable: {error}"),
493                });
494            }
495        };
496        if !sessions.iter().any(|session| session.id == self.session_id) {
497            return Ok(ReconcileResult::LogUnavailable {
498                reason: format!(
499                    "issuing session {} is absent from the daemon's session index (event log \
500                     cleared or deleted)",
501                    self.session_id
502                ),
503            });
504        }
505        // A non-UUID callId can never have been dispatched (`device.execute`
506        // ids are UUIDs); the log is readable, so no-trace is definite.
507        let Ok(call_uuid) = Uuid::parse_str(call_id) else {
508            return Ok(ReconcileResult::NeverDispatched);
509        };
510        let floor = EventSequence::new(issuing.last_sequence);
511        match scan_for_call(call_uuid, floor, |after| self.events_page(after)).await {
512            // A recorded terminal is a certain fate whatever its four-way
513            // discriminant: adopted verbatim, never demoted (04 §5).
514            Ok(CallFate::Completed(outcome)) => Ok(ReconcileResult::Completed {
515                outcome: Box::new(action_outcome_from_wire(&outcome)),
516            }),
517            Ok(CallFate::StartedNoTerminal) => Ok(ReconcileResult::StartedNoTerminal),
518            Ok(CallFate::NoTrace) => Ok(ReconcileResult::NeverDispatched),
519            Err(error) => Ok(ReconcileResult::LogUnavailable {
520                reason: format!("event log scan failed mid-range: {error}"),
521            }),
522        }
523    }
524
525    async fn fetch_evidence(
526        &self,
527        asset: &pointlock_ir::AssetRef,
528    ) -> Result<EvidenceStream, ProviderError> {
529        self.ensure_active("fetchEvidence")?;
530        // Honest unsupported (04 §4.3 intent vs wire reality): DeviceRail
531        // asset URIs use the `devicerail://assets/sha256/<digest>` scheme,
532        // the NDJSON control plane carries no asset bytes, and
533        // `devicerail-client` exposes no fetch API (`media.stream.capture`
534        // creates new frames; it cannot dereference an existing AssetRef).
535        // Reported as a wire/client gap — evidence localization needs an
536        // asset byte channel.
537        let _ = asset_ref_to_wire(asset);
538        Err(ProviderError::new(
539            ErrorClass::ActionFailedFinal,
540            format!(
541                "fetchEvidence is unsupported by the DeviceRail wire surface: no byte channel \
542                 exists for asset URI `{}` (devicerail:// assets are daemon-internal; \
543                 devicerail-client has no fetch API)",
544                asset.uri
545            ),
546            RetryableSource::Classifier,
547        ))
548    }
549
550    async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
551        self.ensure_active("recordVerdict")?;
552        // Wire hard caps, fail-closed before any request — compaction is
553        // the runner's report-assembly job (04 §5).
554        let summary_chars = verdict.summary.chars().count();
555        if summary_chars > VERDICT_SUMMARY_MAX_CHARS {
556            return Err(ProviderError::new(
557                ErrorClass::BindArgumentsInvalid,
558                format!(
559                    "verdict summary is {summary_chars} chars; the wire cap is \
560                     {VERDICT_SUMMARY_MAX_CHARS} (fail-closed, 04 §5)"
561                ),
562                RetryableSource::Classifier,
563            ));
564        }
565        if verdict.evidence.len() > VERDICT_EVIDENCE_MAX_ENTRIES {
566            return Err(ProviderError::new(
567                ErrorClass::BindArgumentsInvalid,
568                format!(
569                    "verdict cites {} evidence entries; the wire cap is \
570                     {VERDICT_EVIDENCE_MAX_ENTRIES} (fail-closed, 04 §5)",
571                    verdict.evidence.len()
572                ),
573                RetryableSource::Classifier,
574            ));
575        }
576        Self::verdict_record_gate(self.feature_enabled(feature::VERDICT_RECORD_V1))?;
577        let params = VerdictRecordParams {
578            verdict: WireVerdict {
579                status: match verdict.status {
580                    VerdictStatus::Pass => WireVerdictStatus::Pass,
581                    VerdictStatus::Fail => WireVerdictStatus::Fail,
582                    VerdictStatus::Unknown => WireVerdictStatus::Unknown,
583                },
584                summary: verdict.summary,
585                evidence: verdict.evidence.iter().map(asset_ref_to_wire).collect(),
586            },
587        };
588        bounded(
589            self.client
590                .call::<methods::VerdictRecord>(params, CallOptions::default()),
591        )
592        .await
593        .map(|_receipt| ())
594        .map_err(|error| error.into_provider_error("verdict.record"))
595    }
596
597    async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
598        self.ensure_active("currentCursor")?;
599        // v0.1 watermark inference: follow `events.list` to the log's end
600        // (04 §5; no event stream in v0.1, §9.8 scope note). What is
601        // returned here is by construction what has been delivered to the
602        // runner — ack-after-persist (04 §9.8.3).
603        let from = self.state().watermark;
604        let latest = latest_sequence(from, |after| self.events_page(after))
605            .await
606            .map_err(|error| error.into_provider_error("events.list"))?;
607        if let Some(sequence) = latest {
608            self.state().watermark = Some(sequence);
609        }
610        Ok(EventCursor {
611            session_id: self.session_id.to_string(),
612            last_sequence: latest.map(EventSequence::get).unwrap_or(0),
613        })
614    }
615
616    async fn health(&self) -> Result<SessionHealth, ProviderError> {
617        // Must not fail on a broken session (04 §2.1).
618        let (ended, degraded) = {
619            let state = self.state();
620            (state.ended, state.degraded.clone())
621        };
622        if ended {
623            return Ok(SessionHealth {
624                ok: false,
625                degraded,
626            });
627        }
628        let probe = bounded(
629            self.client
630                .call::<methods::SessionCurrent>(methods::NoParams, CallOptions::default()),
631        )
632        .await;
633        Ok(match probe {
634            Ok(current) if current.session_id == self.session_id => {
635                SessionHealth { ok: true, degraded }
636            }
637            Ok(current) => SessionHealth {
638                ok: false,
639                degraded: Some(format!(
640                    "daemon's active session {} is not the bound session {}",
641                    current.session_id, self.session_id
642                )),
643            },
644            Err(error) => SessionHealth {
645                ok: false,
646                degraded: Some(error.to_string()),
647            },
648        })
649    }
650
651    async fn end(
652        &self,
653        outcome: SessionOutcome,
654        reason: Option<String>,
655    ) -> Result<(), ProviderError> {
656        // Idempotent: ending an ended/broken session is a no-op (04 §2.1).
657        {
658            let mut state = self.state();
659            if state.ended {
660                return Ok(());
661            }
662            state.ended = true;
663        }
664        // Best-effort exit protocol (04 §9.1, spawn form): session.end →
665        // device.disconnect → close (stdin EOF → grace → kill). Transport
666        // failures must not block the runner's teardown.
667        let params = SessionEndParams {
668            outcome: Some(match outcome {
669                SessionOutcome::Completed => WireSessionOutcome::Completed,
670                SessionOutcome::Failed => WireSessionOutcome::Failed,
671                SessionOutcome::Cancelled => WireSessionOutcome::Cancelled,
672                SessionOutcome::Shutdown => WireSessionOutcome::Shutdown,
673            }),
674            reason,
675        };
676        let _ = bounded(
677            self.client
678                .call::<methods::SessionEnd>(Some(params), CallOptions::default()),
679        )
680        .await;
681        let _ = bounded(self.client.call::<methods::DeviceDisconnect>(
682            methods::NoParams,
683            envelope_options(DEFAULT_CALL_BUDGET_MS),
684        ))
685        .await;
686        let _ = self.client.close().await;
687        Ok(())
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use pointlock_ir::ActionName;
695    use serde_json::json;
696
697    fn call(action_ms: Option<u64>, request_ms: Option<u64>) -> BoundActionCall {
698        BoundActionCall {
699            call_id: Uuid::new_v4().to_string(),
700            action_name: ActionName::new("tap").unwrap(),
701            arguments: json!({}),
702            action_timeout_ms: action_ms,
703            request_timeout_ms: request_ms,
704        }
705    }
706
707    #[test]
708    fn record_verdict_without_the_feature_is_capability_drift() {
709        // 04 §9.2: never a silent no-op — the compile-time guarantee
710        // makes this arm unreachable, so reaching it is drift.
711        let error = DeviceRailSession::verdict_record_gate(false).expect_err("drift");
712        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
713        assert!(error.message.contains("verdict.record.v1"));
714        // Negative control: a negotiated session passes the gate.
715        DeviceRailSession::verdict_record_gate(true).expect("negotiated");
716    }
717
718    #[test]
719    fn envelope_budget_is_derived_from_the_action_budget() {
720        // 04 §9.7: envelope = action + margin, keeping expiry on the
721        // definite-terminal side.
722        let (action, envelope) =
723            DeviceRailSession::execute_budgets(&call(Some(15_000), None)).unwrap();
724        assert_eq!(action, Some(15_000));
725        assert_eq!(envelope, Some(20_000));
726
727        // Explicit envelope wins.
728        let (action, envelope) =
729            DeviceRailSession::execute_budgets(&call(Some(1_000), Some(30_000))).unwrap();
730        assert_eq!(action, Some(1_000));
731        assert_eq!(envelope, Some(30_000));
732
733        // No budgets: nothing is sent (the step watchdog still covers).
734        let (action, envelope) = DeviceRailSession::execute_budgets(&call(None, None)).unwrap();
735        assert_eq!(action, None);
736        assert_eq!(envelope, None);
737    }
738
739    #[test]
740    fn budget_invariant_one_fails_closed() {
741        // actionTimeoutMs must be strictly below the envelope.
742        for (action, envelope) in [(5_000, 5_000), (6_000, 5_000)] {
743            let error = DeviceRailSession::execute_budgets(&call(Some(action), Some(envelope)))
744                .expect_err("invariant 1");
745            assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
746        }
747    }
748}