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}