Skip to main content

pointlock_provider_devicerail/
error_map.rs

1//! Error normalization: DeviceRail wire facts → Pointlock `ErrorClass`
2//! (design doc 04 §6 / §9.6).
3//!
4//! Three error surfaces feed this module (04 §6.1):
5//!
6//! 1. `ErrorInfo` inside an action terminal — data, not an exception; the
7//!    runner classifies it in `settling` via [`classify_wire_code`].
8//! 2. JSON-RPC envelope errors (`ClientError::RemoteRpc`) — classified by
9//!    [`classify_remote_rpc`], with the capability rows taking priority
10//!    (04 §6.2 rules 1–2 sit above the open-set fallback).
11//! 3. Client/transport errors (`devicerail-client` `ClientError`) — mapped
12//!    by [`provider_error_from_client`]; the class-name column of the 04
13//!    §9.6 table is replaced by the Rust client's variants per the R12 note
14//!    (semantics, not spellings, drive the mapping).
15//!
16//! `retryableSource` discipline (04 §6.3): rows whose class is pinned by the
17//! code identity itself record `classifier` (the retryability judgement is
18//! Pointlock's table, whatever the daemon's flag says); rows decided by the
19//! daemon's `ErrorInfo.retryable` declaration record `daemon`.
20
21use devicerail_client::ClientError;
22use devicerail_client::protocol::RpcError;
23use pointlock_ir::ErrorClass;
24use pointlock_provider_kit::{ProviderError, RetryableSource};
25
26use crate::convert::error_info_from_wire;
27
28/// The daemon's numeric JSON-RPC code for driver-layer failures of
29/// `device.execute` (DeviceRail daemon `DRIVER_ERROR`). A driver failure is
30/// appended to the session log as an `actionCompleted` `failed` terminal
31/// before the RPC error is returned (durable-terminal shield), so this code
32/// is the wire discriminator between "the action reached the driver and
33/// failed" (a definite `failed` terminal) and "the request never entered
34/// action execution" (a ProviderError).
35pub const DRIVER_ERROR_RPC_CODE: i32 = -32000;
36
37/// Maps one action-layer wire error code to the closed `ErrorClass`
38/// (04 §6.2 / §9.6; the wire code set is open — unknown codes fall through
39/// on the daemon's declared `retryable` bit).
40pub fn classify_wire_code(code: &str, retryable: bool) -> (ErrorClass, RetryableSource) {
41    match code {
42        // Locate misses are final for the current attempt: the element is
43        // genuinely not there (the act-chain advances or the step fails).
44        "element_not_found" | "element_ambiguous" => {
45            (ErrorClass::ActionFailedFinal, RetryableSource::Classifier)
46        }
47        // Staleness (documentEpoch expiry family): re-observe before retry.
48        "element_stale" | "ui_context_changed" => {
49            (ErrorClass::TargetStale, RetryableSource::Classifier)
50        }
51        // The daemon declares this retryable; the table mirrors the flag.
52        "device_unavailable" => (ErrorClass::ActionFailedRetryable, RetryableSource::Daemon),
53        "session_degraded" => (ErrorClass::SessionDegraded, RetryableSource::Classifier),
54        // Local inputSchema validation should have caught this offline — a
55        // compiler/expression bug signal, never retried.
56        "invalid_arguments" => (
57            ErrorClass::BindArgumentsInvalid,
58            RetryableSource::Classifier,
59        ),
60        // `action_timeout` is the archived-terminal spelling; the daemon's
61        // RPC envelope spells the same fact `action_timed_out` (action
62        // scope) or `request_timed_out` (request scope) — one class.
63        "action_timeout" | "action_timed_out" | "request_timed_out" => {
64            (ErrorClass::ActionTimedOut, RetryableSource::Classifier)
65        }
66        // `action_cancelled` is the archived-terminal spelling;
67        // `request_cancelled` is the RPC envelope spelling of the same fact.
68        "action_cancelled" | "request_cancelled" => {
69            (ErrorClass::ActionCancelled, RetryableSource::Classifier)
70        }
71        // Open set: trust the daemon's declaration for classification;
72        // whether to actually retry stays the runner's RetryPolicy call.
73        _ if retryable => (ErrorClass::ActionFailedRetryable, RetryableSource::Daemon),
74        _ => (ErrorClass::ActionFailedFinal, RetryableSource::Daemon),
75    }
76}
77
78/// Classifies a JSON-RPC envelope error (04 §6.2 priority rows 1–2 first,
79/// then the action-layer table).
80pub fn classify_remote_rpc(error: &RpcError) -> (ErrorClass, RetryableSource) {
81    match error.data.code.as_str() {
82        // Capability rows outrank everything (04 §6.2 rule 1): a feature
83        // the handshake did not grant, or the daemon's own
84        // semanticActions ⇒ uiSnapshot coupling check (04 §9.2).
85        "feature_not_negotiated"
86        | "required_feature_unsupported"
87        | "semantic_snapshot_dependency_unsatisfied" => {
88            (ErrorClass::CapabilityDrift, RetryableSource::Classifier)
89        }
90        // Request decode failures (daemon `invalid_params`) are the envelope
91        // spelling of 04 §6.2 rule 2.
92        "invalid_params" => (
93            ErrorClass::BindArgumentsInvalid,
94            RetryableSource::Classifier,
95        ),
96        code => classify_wire_code(code, error.data.retryable),
97    }
98}
99
100/// Builds the unified [`ProviderError`] carrier from a `devicerail-client`
101/// failure (04 §9.6 "client" rows; class names follow the Rust client crate
102/// per the R12 note).
103pub fn provider_error_from_client(error: ClientError, context: &str) -> ProviderError {
104    let message = format!("{context}: {error}");
105    match error {
106        ClientError::RemoteRpc { error, .. } => {
107            let (class, source) = classify_remote_rpc(&error);
108            ProviderError::new(class, message, source)
109                .with_wire(error_info_from_wire(&error.data))
110                .with_client_code("remote_rpc_error")
111        }
112        ClientError::FeatureNotNegotiated { .. } => ProviderError::new(
113            ErrorClass::CapabilityDrift,
114            message,
115            RetryableSource::Classifier,
116        )
117        .with_client_code("feature_not_negotiated"),
118        // Connection-layer failures: the transport is gone or the protocol
119        // stream is poisoned — either way the connection is untrustworthy.
120        ClientError::Transport(_) | ClientError::Closed => ProviderError::new(
121            ErrorClass::TransportLost,
122            message,
123            RetryableSource::Classifier,
124        )
125        .with_client_code("transport_closed"),
126        ClientError::Framing(_) => ProviderError::new(
127            ErrorClass::TransportLost,
128            message,
129            RetryableSource::Classifier,
130        )
131        .with_client_code("ndjson_frame_error"),
132        ClientError::ProtocolViolation(_) => ProviderError::new(
133            ErrorClass::TransportLost,
134            message,
135            RetryableSource::Classifier,
136        )
137        .with_client_code("protocol_violation"),
138        // A response that fails strict deserialization means the protocol
139        // stream can no longer be trusted (R12 serde discipline).
140        ClientError::Serialization(_) => ProviderError::new(
141            ErrorClass::TransportLost,
142            message,
143            RetryableSource::Classifier,
144        )
145        .with_client_code("serialization"),
146        // Client-side backpressure: 04 §9.6 keeps this internal and only
147        // promotes persistent overflow to transport_lost. M1 has no
148        // provider-internal queueing layer, so overflow surfaces directly
149        // as transport_lost (divergence documented in the crate docs).
150        ClientError::PendingRequestLimit(_)
151        | ClientError::AbandonedRequestLimit(_)
152        | ClientError::WriteQueueFull { .. } => ProviderError::new(
153            ErrorClass::TransportLost,
154            message,
155            RetryableSource::Classifier,
156        )
157        .with_client_code("write_queue_overflow"),
158        ClientError::WriteFrameTooLarge { .. } => ProviderError::new(
159            ErrorClass::TransportLost,
160            message,
161            RetryableSource::Classifier,
162        )
163        .with_client_code("write_frame_too_large"),
164        // The client's terminal phases mean the connection is gone; any
165        // other phase reported here is a provider sequencing bug (04 §9.6
166        // `handshake_state` row): unrecoverable, never retried — the
167        // conformance suite exists to extinguish these.
168        ClientError::HandshakeState(state @ ("failed" | "closed" | "closing")) => {
169            ProviderError::new(
170                ErrorClass::TransportLost,
171                message,
172                RetryableSource::Classifier,
173            )
174            .with_client_code(format!("handshake_state:{state}"))
175        }
176        ClientError::HandshakeState(_) => ProviderError::new(
177            ErrorClass::ActionFailedFinal,
178            message,
179            RetryableSource::Classifier,
180        )
181        .with_client_code("handshake_state"),
182        ClientError::RuntimeUnavailable | ClientError::Internal(_) => ProviderError::new(
183            ErrorClass::ActionFailedFinal,
184            message,
185            RetryableSource::Classifier,
186        )
187        .with_client_code("internal"),
188    }
189}
190
191/// Extracts a definite four-way terminal from a `device.execute` RPC
192/// failure, when the wire fact is one (04 §9.6).
193///
194/// The daemon returns non-`succeeded` terminals as RPC errors — the
195/// success response only ever carries an `ActionResult` — while its
196/// durable-terminal shield archives the matching `actionCompleted` event:
197///
198/// - `request_cancelled` / `action_cancelled` → a recorded `cancelled`
199///   terminal;
200/// - `request_timed_out` / `action_timed_out` / `action_timeout` → a
201///   recorded `timedOut` terminal (both scopes finalize durably);
202/// - any other error with the daemon's `DRIVER_ERROR` numeric code
203///   ([`DRIVER_ERROR_RPC_CODE`]) → a recorded `failed` terminal, with the
204///   driver's `ErrorInfo` verbatim.
205///
206/// `None` means the request never entered action execution (pre-dispatch
207/// rejection: `session_required`, `invalid_params`,
208/// `semantic_channel_unavailable`, …) — the caller falls back to
209/// ProviderError classification.
210pub fn execute_terminal_from_rpc(error: &RpcError) -> Option<pointlock_ir::ActionOutcome> {
211    use pointlock_ir::ActionOutcome;
212    let info = error_info_from_wire(&error.data);
213    match error.data.code.as_str() {
214        "request_cancelled" | "action_cancelled" => Some(ActionOutcome::Cancelled { error: info }),
215        "request_timed_out" | "action_timed_out" | "action_timeout" => {
216            Some(ActionOutcome::TimedOut { error: info })
217        }
218        // Raised before `actionStarted` is appended — not a terminal.
219        "semantic_channel_unavailable" => None,
220        _ if error.code == DRIVER_ERROR_RPC_CODE => Some(ActionOutcome::Failed { error: info }),
221        _ => None,
222    }
223}
224
225/// The immediate error for a call made with an already-cancelled token:
226/// nothing was sent on the wire (04 §7.1).
227pub(crate) fn cancelled_before_dispatch() -> ProviderError {
228    ProviderError::new(
229        ErrorClass::ActionCancelled,
230        "cancellation token was already cancelled; no wire request was sent",
231        RetryableSource::Classifier,
232    )
233}
234
235/// The error every non-`health` method returns once the session has ended
236/// or broken (04 §2.1).
237pub(crate) fn session_gone(method: &str) -> ProviderError {
238    ProviderError::new(
239        ErrorClass::TransportLost,
240        format!("provider session has ended; {method} is unavailable"),
241        RetryableSource::Classifier,
242    )
243    .with_client_code("transport_closed")
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use devicerail_client::protocol::ErrorInfo as WireErrorInfo;
250
251    fn wire_error(code: &str, numeric: i32, retryable: bool) -> RpcError {
252        RpcError {
253            code: numeric,
254            message: format!("test {code}"),
255            data: WireErrorInfo {
256                code: code.to_owned(),
257                message: format!("test {code}"),
258                retryable,
259                details: None,
260            },
261        }
262    }
263
264    #[test]
265    fn action_layer_table_rows_map_exactly() {
266        // Each row of the 04 §6 table, verbatim.
267        let rows = [
268            ("element_not_found", false, ErrorClass::ActionFailedFinal),
269            ("element_ambiguous", false, ErrorClass::ActionFailedFinal),
270            ("element_stale", true, ErrorClass::TargetStale),
271            ("ui_context_changed", true, ErrorClass::TargetStale),
272            (
273                "device_unavailable",
274                true,
275                ErrorClass::ActionFailedRetryable,
276            ),
277            ("session_degraded", true, ErrorClass::SessionDegraded),
278            ("invalid_arguments", false, ErrorClass::BindArgumentsInvalid),
279            ("action_timeout", true, ErrorClass::ActionTimedOut),
280            ("action_timed_out", true, ErrorClass::ActionTimedOut),
281            ("request_timed_out", true, ErrorClass::ActionTimedOut),
282            ("action_cancelled", false, ErrorClass::ActionCancelled),
283            ("request_cancelled", true, ErrorClass::ActionCancelled),
284        ];
285        for (code, retryable, expected) in rows {
286            let (class, _) = classify_wire_code(code, retryable);
287            assert_eq!(class, expected, "row {code}");
288        }
289    }
290
291    #[test]
292    fn table_rows_pin_the_class_regardless_of_the_wire_flag() {
293        // A daemon claiming element_not_found is retryable must not promote
294        // the class: the code identity decides, and the judgement source is
295        // recorded as classifier.
296        let (class, source) = classify_wire_code("element_not_found", true);
297        assert_eq!(class, ErrorClass::ActionFailedFinal);
298        assert_eq!(source, RetryableSource::Classifier);
299    }
300
301    #[test]
302    fn open_set_codes_follow_the_daemon_retryable_bit() {
303        let (class, source) = classify_wire_code("device_not_connected", true);
304        assert_eq!(class, ErrorClass::ActionFailedRetryable);
305        assert_eq!(source, RetryableSource::Daemon);
306
307        let (class, source) = classify_wire_code("unknown_action", false);
308        assert_eq!(class, ErrorClass::ActionFailedFinal);
309        assert_eq!(source, RetryableSource::Daemon);
310    }
311
312    #[test]
313    fn capability_rows_outrank_the_open_set_in_envelope_errors() {
314        for code in [
315            "feature_not_negotiated",
316            "required_feature_unsupported",
317            "semantic_snapshot_dependency_unsatisfied",
318        ] {
319            let (class, _) = classify_remote_rpc(&wire_error(code, -32004, true));
320            assert_eq!(class, ErrorClass::CapabilityDrift, "row {code}");
321        }
322        let (class, _) = classify_remote_rpc(&wire_error("invalid_params", -32602, false));
323        assert_eq!(class, ErrorClass::BindArgumentsInvalid);
324        let (class, _) = classify_remote_rpc(&wire_error("session_degraded", -32006, true));
325        assert_eq!(class, ErrorClass::SessionDegraded);
326    }
327
328    #[test]
329    fn remote_rpc_carrier_keeps_the_wire_error_and_client_code() {
330        let error = ClientError::RemoteRpc {
331            request_id: devicerail_client::protocol::RpcId::Number(1),
332            error: Box::new(wire_error("device_unavailable", -32000, true)),
333        };
334        let carrier = provider_error_from_client(error, "device.execute");
335        assert_eq!(carrier.error_class, ErrorClass::ActionFailedRetryable);
336        assert_eq!(carrier.retryable_source, RetryableSource::Daemon);
337        assert_eq!(carrier.client_code.as_deref(), Some("remote_rpc_error"));
338        let wire = carrier.wire.expect("wire error attached");
339        assert_eq!(wire.code, "device_unavailable");
340        assert!(wire.retryable);
341    }
342
343    #[test]
344    fn transport_family_maps_to_transport_lost() {
345        for (error, code) in [
346            (
347                ClientError::Transport("pipe closed".to_owned()),
348                "transport_closed",
349            ),
350            (ClientError::Closed, "transport_closed"),
351            (
352                ClientError::ProtocolViolation("bad frame".to_owned()),
353                "protocol_violation",
354            ),
355            (
356                ClientError::Framing(devicerail_client::FramingError::InvalidUtf8),
357                "ndjson_frame_error",
358            ),
359            (
360                ClientError::Serialization("strict decode failed".to_owned()),
361                "serialization",
362            ),
363        ] {
364            let carrier = provider_error_from_client(error, "ctx");
365            assert_eq!(carrier.error_class, ErrorClass::TransportLost);
366            assert_eq!(carrier.client_code.as_deref(), Some(code));
367        }
368    }
369
370    #[test]
371    fn feature_not_negotiated_is_capability_drift() {
372        let error = ClientError::FeatureNotNegotiated {
373            method: "ui.snapshot.get",
374            feature: "observation.uiSnapshot.v1",
375        };
376        let carrier = provider_error_from_client(error, "ui.snapshot.get");
377        assert_eq!(carrier.error_class, ErrorClass::CapabilityDrift);
378        assert_eq!(
379            carrier.client_code.as_deref(),
380            Some("feature_not_negotiated")
381        );
382    }
383
384    #[test]
385    fn client_bug_family_is_final_but_terminal_phases_are_transport() {
386        // closed/failed/closing phases mean the connection is gone.
387        for phase in ["closed", "failed", "closing"] {
388            let carrier = provider_error_from_client(ClientError::HandshakeState(phase), "ctx");
389            assert_eq!(carrier.error_class, ErrorClass::TransportLost, "{phase}");
390        }
391        // Any other phase is a provider sequencing bug.
392        let carrier = provider_error_from_client(ClientError::HandshakeState("ready"), "ctx");
393        assert_eq!(carrier.error_class, ErrorClass::ActionFailedFinal);
394        let carrier = provider_error_from_client(ClientError::Internal("bug".to_owned()), "ctx");
395        assert_eq!(carrier.error_class, ErrorClass::ActionFailedFinal);
396    }
397
398    #[test]
399    fn execute_rpc_failures_extract_definite_terminals() {
400        use pointlock_ir::ActionOutcome;
401
402        // Cancellation: both the envelope and the archived spelling.
403        for code in ["request_cancelled", "action_cancelled"] {
404            let outcome = execute_terminal_from_rpc(&wire_error(code, -32007, true))
405                .expect("cancelled terminal");
406            assert_eq!(outcome.kind(), "cancelled");
407        }
408        // Timeouts: request scope and action scope both finalize durably.
409        for code in ["request_timed_out", "action_timed_out", "action_timeout"] {
410            let outcome = execute_terminal_from_rpc(&wire_error(code, -32008, true))
411                .expect("timedOut terminal");
412            assert_eq!(outcome.kind(), "timedOut");
413        }
414        // Driver-layer failure (numeric DRIVER_ERROR): a recorded failed
415        // terminal carrying the driver ErrorInfo verbatim.
416        let outcome = execute_terminal_from_rpc(&wire_error(
417            "element_not_found",
418            DRIVER_ERROR_RPC_CODE,
419            false,
420        ))
421        .expect("failed terminal");
422        let ActionOutcome::Failed { error } = outcome else {
423            panic!("expected failed terminal");
424        };
425        assert_eq!(error.code, "element_not_found");
426
427        // Pre-dispatch rejections yield no terminal.
428        assert!(execute_terminal_from_rpc(&wire_error("session_required", -32005, true)).is_none());
429        assert!(
430            execute_terminal_from_rpc(&wire_error(
431                "semantic_channel_unavailable",
432                DRIVER_ERROR_RPC_CODE,
433                false
434            ))
435            .is_none()
436        );
437        assert!(execute_terminal_from_rpc(&wire_error("invalid_params", -32602, false)).is_none());
438    }
439}