Skip to main content

pointlock_provider_kit/
spi.rs

1//! The Provider SPI (spine §4.2 runtime interface; 04 §1–§7 contract
2//! clauses).
3//!
4//! The authoritative SPI form is this Rust trait pair (in-process, R12); a
5//! stdio JSON-RPC sidecar adapter form is reserved for v0.2. Method names
6//! are the snake_case renderings of the spine A.5 exhaustive list
7//! (`openSession` → `open_session`, etc.); wire-facing DTO fields stay
8//! camelCase.
9//!
10//! Cancellation: the TS signatures pass an optional `AbortSignal` to
11//! `execute` / `observe`. The Rust counterpart is an optional
12//! [`CancellationToken`] with the same contract (04 §7.1): on cancellation
13//! the provider must forward the cancel intent to the substrate rather than
14//! merely abandoning the wait; cancellation is a request, not a guarantee —
15//! the provider returns whatever terminal actually materializes; a token
16//! that is already cancelled at call time makes the method fail immediately
17//! with an `action_cancelled`-class [`ProviderError`] without dispatching.
18
19use std::pin::Pin;
20
21use async_trait::async_trait;
22use futures_core::Stream;
23use pointlock_ir::{
24    ActionName, ActionOutcome, AssetRef, ErrorClass, EventCursor, FeatureId, Hash, Observation,
25    ReconcileResult, UiSnapshotOmissionReason, VerdictStatus,
26};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30pub use tokio_util::sync::CancellationToken;
31
32use crate::error::{ProviderError, RetryableSource};
33use crate::lockfile::CapabilityAttestation;
34use crate::manifest::ProviderManifest;
35
36/// Wire hard cap on `recordVerdict` summaries, in characters (spine §4.2).
37/// Providers fail closed on oversize input; compaction is the runner's
38/// report-assembly job (04 §5).
39pub const VERDICT_SUMMARY_MAX_CHARS: usize = 16384;
40
41/// Wire hard cap on `recordVerdict` evidence entries (spine §4.2).
42pub const VERDICT_EVIDENCE_MAX_ENTRIES: usize = 64;
43
44/// Options for [`Provider::open_session`] (spine §4.2
45/// `OpenSessionOptions`).
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "camelCase", deny_unknown_fields)]
48pub struct OpenSessionOptions {
49    /// Provider-defined endpoint shape (`ProviderEndpoint`; devicerail:
50    /// `{ spawn: SpawnSpec } | { attach: AttachSpec }`). Kept opaque JSON at
51    /// the SPI layer — each provider deserializes its own shape.
52    pub endpoint: Value,
53    /// Device to bind. `devices.list` / `device.select` are
54    /// connection-local: one `ProviderSession` owns one client instance
55    /// exclusively, never shared (04 §1 rule 4).
56    pub device_id: String,
57    /// The IR's `requiredFeatures`, forwarded in full into
58    /// `FeatureOffer.required` — protocol semantics fail the handshake when
59    /// unmet (free enforcement, spine §4.1).
60    pub required_features: Vec<FeatureId>,
61    /// Attestation baseline: the digest the live world must match.
62    pub lockfile_digest: Hash,
63}
64
65/// A bound, ready-to-dispatch action call (spine §4.2 `BoundActionCall`).
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct BoundActionCall {
69    /// Runner-generated UUID; equals the substrate action id (devicerail:
70    /// `device.execute` `params.id`) — the key to effectively-once and to
71    /// crash-time [`ProviderSession::reconcile`]. Providers must use it
72    /// verbatim, never regenerate or rewrite it (04 §3).
73    pub call_id: String,
74    /// Provider-native action name.
75    pub action_name: ActionName,
76    /// Runtime-evaluated arguments, already re-validated by the runner
77    /// against the action's `inputSchema`.
78    pub arguments: Value,
79    /// Action-scoped budget (driver action body only; expiry yields a
80    /// *definite* `timedOut` terminal, 04 §7.2).
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub action_timeout_ms: Option<u64>,
83    /// Request-envelope budget, distinct from the action budget (expiry
84    /// yields an envelope error — outcome *uncertain* → reconcile, 04 §7.2).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub request_timeout_ms: Option<u64>,
87}
88
89/// What an explicit observation should include (spine §4.2
90/// `observe(req)` — `wants: ("screenshot" | "uiSnapshot")[]`).
91#[derive(
92    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
93)]
94#[serde(rename_all = "camelCase")]
95pub enum ObserveWant {
96    /// A screenshot asset.
97    Screenshot,
98    /// A normalized UI-tree snapshot.
99    UiSnapshot,
100}
101
102/// Request for [`ProviderSession::observe`]. `wants` is an intent
103/// declaration, not a wire parameter (DeviceRail `device.observe` takes no
104/// params): it tells the provider whether to chase a `ui.snapshot.get`
105/// follow-up and to fill in truthful omission reasons when a wanted part is
106/// missing (04 §4.1).
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(rename_all = "camelCase", deny_unknown_fields)]
109pub struct ObserveRequest {
110    /// The parts the caller needs.
111    pub wants: Vec<ObserveWant>,
112}
113
114/// Result of dereferencing an observation's normalized UI tree (spine §4.2
115/// `uiSnapshot`; TS: `{ ok: true, snapshot } | { ok: false, reason }`).
116#[derive(Debug, Clone, PartialEq)]
117pub enum UiSnapshotOutcome {
118    /// `{ ok: true, snapshot }`. M0 narrow: the snapshot is the normalized
119    /// UI tree as raw JSON — the typed `UiSnapshot` DTO follows the
120    /// `devicerail-client` crate (M1, pending incorporation).
121    Available {
122        /// The normalized UI tree.
123        snapshot: Value,
124    },
125    /// `{ ok: false, reason }` — a typed omission (data, not an error); it
126    /// propagates to assertion `unknown` (04 §4.2).
127    Unavailable {
128        /// Why the snapshot cannot be read.
129        reason: UiSnapshotOmissionReason,
130    },
131}
132
133/// Verdict write payload for [`ProviderSession::record_verdict`]
134/// (spine §4.2 — `{ status, summary, evidence }`; the daemon only validates
135/// and persists, it runs no assertions).
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct VerdictWrite {
139    /// Three-valued status.
140    pub status: VerdictStatus,
141    /// Folding summary (wire cap: [`VERDICT_SUMMARY_MAX_CHARS`]).
142    pub summary: String,
143    /// Evidence citations (wire cap: [`VERDICT_EVIDENCE_MAX_ENTRIES`]).
144    pub evidence: Vec<AssetRef>,
145}
146
147/// Session health probe result (spine §4.2 `health()`).
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct SessionHealth {
151    /// Whether the session is usable.
152    pub ok: bool,
153    /// Most recently observed degradation reason, when any (e.g. the wire
154    /// `session_degraded` message).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub degraded: Option<String>,
157}
158
159/// Terminal outcome of a session (DeviceRail `SessionOutcome`, spine A.8).
160#[derive(
161    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
162)]
163#[serde(rename_all = "camelCase")]
164pub enum SessionOutcome {
165    /// The run finished normally.
166    Completed,
167    /// The run failed.
168    Failed,
169    /// The run was cancelled.
170    Cancelled,
171    /// Orderly shutdown (e.g. suspend).
172    Shutdown,
173}
174
175/// Byte stream returned by [`ProviderSession::fetch_evidence`] (the Rust
176/// counterpart of the TS `AsyncIterable<Uint8Array>`).
177pub type EvidenceStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, ProviderError>> + Send>>;
178
179/// A provider: a static capability declaration plus a session factory
180/// (spine §4.2 `Provider`). Providers declare and faithfully execute; they
181/// never fold, translate, improvise, retry, or degrade (04 §1).
182#[async_trait]
183pub trait Provider: Send + Sync {
184    /// The package-shipped static capability declaration.
185    fn manifest(&self) -> &ProviderManifest;
186
187    /// Opens a session. Atomic (04 §2.1): returning means the connection is
188    /// up, the protocol is negotiated with all `requiredFeatures` satisfied,
189    /// the device is selected and connected, the substrate session is
190    /// started, and attestation matched `lockfileDigest`. On any failure the
191    /// provider cleans up partial resources and fails — never a half-open
192    /// session. Failure classes: negotiation/capability mismatch →
193    /// `capability_drift`; device unavailable → `action_failed_retryable`;
194    /// connection failure → `transport_lost`.
195    async fn open_session(
196        &self,
197        opts: OpenSessionOptions,
198    ) -> Result<Box<dyn ProviderSession>, ProviderError>;
199}
200
201/// An open provider session (spine §4.2 `ProviderSession`). One session
202/// owns one underlying client connection exclusively. In the broken state
203/// every method except [`health`](Self::health) (which reports
204/// `{ ok: false }`) fails with a `transport_lost`-class error (04 §2.1).
205#[async_trait]
206pub trait ProviderSession: Send + Sync {
207    /// The attestation result (already verified inside `open_session`),
208    /// exposed for Evidence and reports.
209    fn attestation(&self) -> &CapabilityAttestation;
210
211    /// Executes an action (devicerail: `device.execute`). The four-way
212    /// terminal outcome is returned unfolded and untranslated — `failed`,
213    /// `cancelled` and `timedOut` are `Ok` values, not errors. `Err` means
214    /// "no terminal could be obtained" (transport rupture, envelope
215    /// timeout, abort undeliverable); the runner records the attempt as
216    /// hanging and goes through [`reconcile`](Self::reconcile) (04 §3).
217    ///
218    /// Preconditions the conformance suite enforces: `call.call_id` is used
219    /// verbatim as the substrate action id; a repeated `call_id` is
220    /// rejected (a retry is a new `call_id` + new WAL intent); an
221    /// unattested `action_name` is rejected with `capability_drift` before
222    /// any wire request.
223    async fn execute(
224        &self,
225        call: BoundActionCall,
226        cancel: Option<CancellationToken>,
227    ) -> Result<ActionOutcome, ProviderError>;
228
229    /// Takes an explicit observation (devicerail: `device.observe`).
230    /// Omissions are data, not errors: a wanted-but-missing part comes back
231    /// as a typed omission reason on the [`Observation`] (04 §4.1).
232    async fn observe(
233        &self,
234        req: ObserveRequest,
235        cancel: Option<CancellationToken>,
236    ) -> Result<Observation, ProviderError>;
237
238    /// Dereferences an observation's normalized UI tree (devicerail:
239    /// `ui.snapshot.get { observationId }`; feature
240    /// `observation.uiSnapshot.v1`). Protocol hard limit: readable only
241    /// while the issuing session is active → the runner localizes
242    /// immediately during `observing` (04 §4.2).
243    async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError>;
244
245    /// Effect reconciliation of a pending intent (spine §6.7-B, 04 §5).
246    /// `issuing` is the credential of the session generation that
247    /// dispatched the intent (2026-07-18 incorporation: explicit
248    /// parameter — `pendingIntent` issuing state, falling back to the
249    /// checkpoint binding cursor). The implementation must consult the
250    /// ISSUING session's log: when `issuing.sessionId` is not this
251    /// session and the old log is unreachable, the honest answer is
252    /// `logUnavailable` — never a scan of the current session's log
253    /// (a reachable-but-wrong log can fabricate `neverDispatched`).
254    async fn reconcile(
255        &self,
256        call_id: &str,
257        issuing: &EventCursor,
258    ) -> Result<ReconcileResult, ProviderError>;
259
260    /// Fetches evidence bytes by `AssetRef.uri` for the local
261    /// content-addressed store. When `asset.sha256` is present the provider
262    /// must verify while reading and fail on mismatch — evidence integrity
263    /// is non-negotiable (04 §4.3).
264    async fn fetch_evidence(&self, asset: &AssetRef) -> Result<EvidenceStream, ProviderError>;
265
266    /// Writes back a Pointlock-computed verdict (devicerail:
267    /// `verdict.record`; feature `verdict.record.v1`). Wire hard caps
268    /// ([`VERDICT_SUMMARY_MAX_CHARS`], [`VERDICT_EVIDENCE_MAX_ENTRIES`]):
269    /// the provider fails closed on oversize input with a
270    /// `bind_arguments_invalid`-class error (04 §5).
271    async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError>;
272
273    /// The checkpoint event-cursor watermark: the highest sequence the
274    /// provider has delivered to the runner (ack-after-persist), not the
275    /// highest the daemon has produced (04 §5).
276    async fn current_cursor(&self) -> Result<EventCursor, ProviderError>;
277
278    /// Lightweight, side-effect-free health probe. Must not fail on a
279    /// broken session — it reports `{ ok: false }` instead (04 §2.1).
280    async fn health(&self) -> Result<SessionHealth, ProviderError>;
281
282    /// Ends the session (devicerail: `session.end`). Idempotent: calling it
283    /// on an already ended/broken session is a no-op; best-effort — it must
284    /// not fail and block the runner's teardown when the transport is
285    /// already gone (04 §2.1).
286    async fn end(
287        &self,
288        outcome: SessionOutcome,
289        reason: Option<String>,
290    ) -> Result<(), ProviderError>;
291}
292
293// ─── provider-synthetic observation actions (04 §9.4.3) ─────────────────────
294/// Wall clock in milliseconds since the epoch.
295pub fn now_ms() -> u64 {
296    std::time::SystemTime::now()
297        .duration_since(std::time::UNIX_EPOCH)
298        .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
299        .unwrap_or(0)
300}
301
302/// The parts a provider-synthetic observation action asks for, or `None`
303/// when the call is not one of them (04 §9.4.3).
304///
305/// `observe` declares its parts in `wants`; `screenshot` is the shortcut
306/// whose parts are fixed here, which is why its action takes no arguments.
307/// A malformed `wants` is a bind-argument error rather than a guess — the
308/// runner already re-validated it against the advertised schema, so
309/// reaching this is drift.
310pub fn synthetic_observation_wants(
311    call: &BoundActionCall,
312) -> Result<Option<Vec<ObserveWant>>, ProviderError> {
313    let invalid = |detail: String| {
314        ProviderError::new(
315            ErrorClass::BindArgumentsInvalid,
316            detail,
317            RetryableSource::Classifier,
318        )
319    };
320    match call.action_name.as_str() {
321        "screenshot" => Ok(Some(vec![ObserveWant::Screenshot])),
322        "observe" => {
323            let raw = call
324                .arguments
325                .get("wants")
326                .and_then(serde_json::Value::as_array)
327                .ok_or_else(|| invalid("`observe` requires a `wants` array".to_owned()))?;
328            let mut wants = Vec::with_capacity(raw.len());
329            for part in raw {
330                match part.as_str() {
331                    Some("screenshot") => wants.push(ObserveWant::Screenshot),
332                    Some("uiSnapshot") => wants.push(ObserveWant::UiSnapshot),
333                    other => {
334                        return Err(invalid(format!(
335                            "`observe` wants an entry outside the closed vocabulary: {other:?}"
336                        )));
337                    }
338                }
339            }
340            if wants.is_empty() {
341                return Err(invalid("`observe` needs at least one part".to_owned()));
342            }
343            Ok(Some(wants))
344        }
345        _ => Ok(None),
346    }
347}
348
349/// The output projection of an observation action (04 §9.4.3): the
350/// observation's identity plus the parts that were captured, each with its
351/// truthful omission reason when a wanted part is legitimately absent.
352/// Omission is data, never an error (04 §4.1).
353pub fn observation_projection(
354    observation: &Observation,
355    wants: &[ObserveWant],
356) -> serde_json::Value {
357    let mut out = serde_json::Map::new();
358    out.insert(
359        "observationId".to_owned(),
360        serde_json::Value::String(observation.id.clone()),
361    );
362    if wants.contains(&ObserveWant::Screenshot) {
363        if let Some(asset) = &observation.screenshot {
364            out.insert(
365                "screenshot".to_owned(),
366                serde_json::to_value(asset).unwrap_or(serde_json::Value::Null),
367            );
368        }
369        if let Some(reason) = observation.screenshot_omission {
370            out.insert(
371                "screenshotOmission".to_owned(),
372                serde_json::to_value(reason).unwrap_or(serde_json::Value::Null),
373            );
374        }
375    }
376    if wants.contains(&ObserveWant::UiSnapshot) {
377        if let Some(snapshot) = &observation.ui_snapshot {
378            out.insert(
379                "uiSnapshot".to_owned(),
380                serde_json::to_value(snapshot).unwrap_or(serde_json::Value::Null),
381            );
382        }
383        if let Some(reason) = observation.ui_snapshot_omission {
384            out.insert(
385                "uiSnapshotOmission".to_owned(),
386                serde_json::to_value(reason).unwrap_or(serde_json::Value::Null),
387            );
388        }
389    }
390    serde_json::Value::Object(out)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    #[test]
399    fn bound_action_call_wire_shape() {
400        let call = BoundActionCall {
401            call_id: "4a1f2c9e-0000-4000-8000-000000000001".to_owned(),
402            action_name: ActionName::new("tapElement").unwrap(),
403            arguments: json!({ "element": { "byText": "OK" } }),
404            action_timeout_ms: Some(5000),
405            request_timeout_ms: None,
406        };
407        let wire = serde_json::to_value(&call).expect("serialize");
408        assert_eq!(wire["callId"], "4a1f2c9e-0000-4000-8000-000000000001");
409        assert_eq!(wire["actionName"], "tapElement");
410        assert_eq!(wire["actionTimeoutMs"], 5000);
411        assert!(wire.get("requestTimeoutMs").is_none());
412        let back: BoundActionCall = serde_json::from_value(wire).expect("deserialize");
413        assert_eq!(back, call);
414    }
415
416    #[test]
417    fn observe_want_wire_literals() {
418        assert_eq!(
419            serde_json::to_value(ObserveWant::UiSnapshot).unwrap(),
420            json!("uiSnapshot")
421        );
422        assert_eq!(
423            serde_json::to_value(ObserveWant::Screenshot).unwrap(),
424            json!("screenshot")
425        );
426    }
427
428    #[test]
429    fn session_outcome_wire_literals() {
430        for (outcome, literal) in [
431            (SessionOutcome::Completed, "completed"),
432            (SessionOutcome::Failed, "failed"),
433            (SessionOutcome::Cancelled, "cancelled"),
434            (SessionOutcome::Shutdown, "shutdown"),
435        ] {
436            assert_eq!(serde_json::to_value(outcome).unwrap(), json!(literal));
437        }
438    }
439}