Skip to main content

nomoreide_remote_protocol/
platform_bound.rs

1//! Everything a daemon may send to the platform — the frozen v1 event union.
2//!
3//! The mirror of [`super::device_bound`], and refused just as exhaustively. The
4//! asymmetry worth noticing is which direction each end distrusts: the daemon
5//! refuses unknown commands because a hostile relay could otherwise reach the
6//! machine, and the platform refuses unknown events because a hostile *daemon*
7//! is a real device in someone's account, and the relay fans its frames out to
8//! a browser.
9//!
10//! Nothing here carries prompts, tool input beyond the approval card, log
11//! bodies past their bounds, terminal data, environment, or local credentials
12//! — and none of it is persisted by the platform. The database learns that a
13//! command happened, not what was in it.
14
15use super::errors::ProtocolError;
16use super::snapshot::{
17    DeviceSnapshot, LogLine, RemoteAgentProvider, RemoteAgentUsage, RemoteBundle, RemoteIncident,
18    RemotePullRequest, RemoteService, RemoteTimelineEntry, RemoteWorkflowJob, RemoteWorkflowRun,
19};
20use super::version::CapabilitySet;
21use serde::{Deserialize, Serialize};
22
23/// One frame travelling daemon → platform.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "type", content = "payload")]
26pub enum PlatformBound {
27    /// The first frame on every socket. Until this arrives the connection has a
28    /// credential but no identity, and routes nothing.
29    #[serde(rename = "session.hello")]
30    SessionHello(SessionHello),
31    /// Liveness, every [`super::limits::HEARTBEAT_INTERVAL`]. Missing them for
32    /// [`super::limits::PRESENCE_TIMEOUT`] marks the device offline, which
33    /// suspends routing rather than queueing.
34    #[serde(rename = "session.heartbeat")]
35    SessionHeartbeat(super::device_bound::Empty),
36
37    #[serde(rename = "device.snapshot.response")]
38    DeviceSnapshot(DeviceSnapshotResponse),
39    #[serde(rename = "service.list.response")]
40    ServiceList(ServiceListResponse),
41    #[serde(rename = "service.action.response")]
42    ServiceAction(ServiceActionResponse),
43    #[serde(rename = "service.logs.response")]
44    ServiceLogs(ServiceLogsResponse),
45    #[serde(rename = "bundle.list.response")]
46    BundleList(BundleListResponse),
47
48    #[serde(rename = "agent.providers.response")]
49    AgentProviders(AgentProvidersResponse),
50    /// The turn was accepted and has an id. Sent before any run event, so a
51    /// phone always knows the run it is about to watch.
52    #[serde(rename = "agent.turn.accepted")]
53    AgentTurnAccepted(AgentTurnAccepted),
54    /// One sequenced event from a run.
55    #[serde(rename = "agent.turn.event")]
56    AgentTurnEvent(super::agent_event::AgentEvent),
57
58    /// An agent terminal was started, and this is it. **v2.**
59    #[serde(rename = "terminal.spawned")]
60    TerminalSpawned(TerminalSpawned),
61    /// The agent terminals that could be mirrored. **v2.**
62    #[serde(rename = "terminal.sessions.response")]
63    TerminalSessions(TerminalSessionsResponse),
64    /// The mirror is open, and this is its id. **v2.**
65    #[serde(rename = "terminal.attach.accepted")]
66    TerminalAttachAccepted(TerminalAttachAccepted),
67    /// A coalesced chunk of what the terminal drew. **v2.**
68    #[serde(rename = "terminal.output")]
69    TerminalOutput(TerminalOutput),
70    /// The mirrored session was resized *on the machine*. **v2.**
71    #[serde(rename = "terminal.geometry")]
72    TerminalGeometry(TerminalGeometry),
73    /// A terminal command was carried out and had nothing to report. **v2.**
74    #[serde(rename = "terminal.ack")]
75    TerminalAck(TerminalAck),
76    /// The mirror ended. **v2.**
77    #[serde(rename = "terminal.closed")]
78    TerminalClosed(TerminalClosed),
79    /// The session was ended, and the agent in it stopped.
80    #[serde(rename = "terminal.killed")]
81    TerminalKilled(TerminalKilled),
82
83    /// The repositories this machine has registered.
84    #[serde(rename = "repositories.response")]
85    Repositories(RepositoriesResponse),
86
87    /// Recent GitHub Actions runs.
88    #[serde(rename = "github.runs.response")]
89    GithubRuns(GithubRunsResponse),
90    /// One run's jobs.
91    #[serde(rename = "github.run.jobs.response")]
92    GithubRunJobs(GithubRunJobsResponse),
93    /// Pull requests on the selected repository.
94    #[serde(rename = "github.prs.response")]
95    GithubPulls(GithubPullsResponse),
96    /// One pull request.
97    #[serde(rename = "github.pr.response")]
98    GithubPull(GithubPullResponse),
99    #[serde(rename = "linear.response")]
100    /// Boxed: the variant is ~1500 bytes against ~270 for the next largest, so
101    /// every `PlatformBound` on the socket would carry that width. `Box` is
102    /// transparent to serde, so the wire format is unchanged.
103    Linear(Box<super::linear::LinearResponse>),
104    /// What the agents have spent.
105    #[serde(rename = "agent.usage.response")]
106    AgentUsage(AgentUsageResponse),
107    /// The error inbox.
108    #[serde(rename = "errors.response")]
109    Errors(ErrorsResponse),
110    /// The runtime timeline.
111    #[serde(rename = "timeline.response")]
112    Timeline(TimelineResponse),
113
114    /// This machine is unpairing itself, and asks to be retired.
115    ///
116    /// **Device-initiated, and answers nothing.** Every other frame in this
117    /// union is either a reply or an event about work the platform asked for;
118    /// this one is the machine saying it has thrown its own credential away and
119    /// would like the row to go with it. Revocation stays the platform's to
120    /// perform and remains one-way — the device asks, it does not do.
121    ///
122    /// Unsolicited rather than a reply to some `device.retire.request`, because
123    /// there is no version of this the platform initiates: the credential is
124    /// deleted on the machine by somebody standing at it.
125    ///
126    /// **Nothing depends on it arriving.** An older platform does not know this
127    /// name and refuses it, by the same fail-closed rule as any unknown frame,
128    /// and the machine unpairs locally regardless. A retirement that did not
129    /// land leaves exactly what leaving this frame out always left: a device
130    /// row the owner can revoke from their phone.
131    #[serde(rename = "device.retire")]
132    DeviceRetire(DeviceRetire),
133
134    /// A refusal. Always carries `replyTo`, because an error with nothing to
135    /// answer is a log line, not a frame.
136    #[serde(rename = "command.error")]
137    CommandError(CommandErrorResponse),
138}
139
140/// Why a machine retired itself.
141///
142/// A reason rather than an empty body, so the platform's audit of a vanished
143/// device can say whether a person unpaired it or something else did. It is a
144/// closed set rather than free text: this crosses a trust boundary, and a
145/// string a device chooses would end up rendered somewhere.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase", deny_unknown_fields)]
148pub struct DeviceRetire {
149    pub reason: RetireReason,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub enum RetireReason {
155    /// Somebody pressed Unpair on the machine.
156    Unpaired,
157}
158
159/// Every list answer here carries `truncated` for the same reason
160/// [`ServiceLogsResponse`] does: a phone showing thirty runs must be able to say
161/// "the most recent thirty" rather than implying the repository has thirty.
162/// The repositories a phone may ask about.
163///
164/// **An id and a name, and nothing else.** No path, no remote URL, no branch —
165/// the rule this protocol is built on is that a phone names *what* to look at
166/// and never *where*, and a filesystem path is the purest form of "where".
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct RemoteRepository {
170    /// What a request passes back as `repository`. The daemon's own name for
171    /// it; the dispatcher refuses any id it did not report here.
172    pub id: String,
173    pub name: String,
174    /// The one the machine currently has selected — what an absent `repository`
175    /// resolves to, so a phone can show which it is defaulting to.
176    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
177    pub selected: bool,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", deny_unknown_fields)]
182pub struct RepositoriesResponse {
183    pub repositories: Vec<RemoteRepository>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase", deny_unknown_fields)]
188pub struct GithubRunsResponse {
189    pub runs: Vec<RemoteWorkflowRun>,
190    /// Echoed back, so an answer that arrives after the filter changed can be
191    /// told apart from one for the branch now on screen.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub branch: Option<String>,
194    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
195    pub truncated: bool,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct GithubRunJobsResponse {
201    pub run_id: String,
202    pub jobs: Vec<RemoteWorkflowJob>,
203    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
204    pub truncated: bool,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct GithubPullsResponse {
210    pub pulls: Vec<RemotePullRequest>,
211    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
212    pub truncated: bool,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217pub struct GithubPullResponse {
218    pub pull: RemotePullRequest,
219}
220
221/// Both agents' readings, or as many of them as this machine has.
222///
223/// An empty answer is a real answer — "no agent has ever run here" — and is not
224/// an error. The phone renders that as a state, not as a failure.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase", deny_unknown_fields)]
227pub struct AgentUsageResponse {
228    pub usage: RemoteAgentUsage,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase", deny_unknown_fields)]
233pub struct ErrorsResponse {
234    pub incidents: Vec<RemoteIncident>,
235    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
236    pub truncated: bool,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase", deny_unknown_fields)]
241pub struct TimelineResponse {
242    pub entries: Vec<RemoteTimelineEntry>,
243    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
244    pub truncated: bool,
245}
246
247/// The session a spawn produced, described exactly like any other.
248///
249/// The same sanitized shape the listing uses, so a phone can attach to it
250/// without a second code path — and so a spawn cannot answer with fields the
251/// listing would have dropped.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase", deny_unknown_fields)]
254pub struct TerminalSpawned {
255    pub session: super::snapshot::RemoteTerminalSession,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase", deny_unknown_fields)]
260pub struct TerminalSessionsResponse {
261    pub sessions: Vec<super::snapshot::RemoteTerminalSession>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase", deny_unknown_fields)]
266pub struct TerminalAttachAccepted {
267    /// Minted by the daemon. Every later frame for this mirror names it, so a
268    /// stale frame from a mirror that has already closed cannot be applied to
269    /// the one that replaced it.
270    pub stream_id: String,
271    pub session_id: String,
272    /// What the daemon actually set, which may be smaller than what was asked
273    /// for — the request is clamped, not rejected.
274    pub cols: u16,
275    pub rows: u16,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase", deny_unknown_fields)]
280pub struct TerminalOutput {
281    pub stream_id: String,
282    /// Monotonic within a stream, from zero. A reader that sees a gap has lost
283    /// bytes to backpressure and must repaint rather than render a hole it
284    /// cannot see — the same contract the run-event stream uses.
285    pub seq: u64,
286    pub data: super::terminal_bytes::TerminalBytes,
287}
288
289/// The session changed size, and every byte after this frame is drawn for it.
290///
291/// **Why this has to be said rather than inferred.** A PTY has exactly one
292/// geometry, and the mirror does not own it — the person at the desk does, by
293/// resizing the dock the session is running in. A viewer told the size once at
294/// attach keeps drawing into the old grid forever, and because a TUI positions
295/// with absolute column escapes (`ESC[nG`), the result is not a slightly wrong
296/// margin but text landing on top of other text. That is what a permission
297/// prompt looks like when it goes wrong, and it is why this exists.
298///
299/// Sent *before* the repaint it explains, which costs nothing to arrange: the
300/// notification fires when the `ioctl` returns, and the child's redraw cannot
301/// begin until it has seen the `SIGWINCH` that follows. A viewer that resizes
302/// on this frame is therefore already the right shape when the bytes arrive.
303///
304/// A platform too old to know this name skips it and stays connected — an
305/// unknown event is refused, not fatal — so the phone simply keeps the
306/// behaviour it had before this frame existed.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase", deny_unknown_fields)]
309pub struct TerminalGeometry {
310    pub stream_id: String,
311    pub cols: u16,
312    pub rows: u16,
313}
314
315/// Nothing to say beyond "done".
316///
317/// Its own frame because the alternative was answering a keystroke with
318/// [`TerminalAttachAccepted`] carrying a geometry nobody set — an ack that has
319/// to lie about a field is a worse economy than one more variant. A *resize*
320/// still answers with `attach.accepted`, because reporting the geometry that
321/// was actually applied is precisely what that frame is for.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase", deny_unknown_fields)]
324pub struct TerminalAck {
325    pub stream_id: String,
326}
327
328/// One session was ended.
329///
330/// Names the session rather than a stream, which is why this is not a
331/// [`TerminalAck`]: a kill is aimed at the session, and a phone that never
332/// mirrored it has no stream id to be told back. Ending a session that was
333/// being mirrored also produces a [`TerminalClosed`] for the mirror, from the
334/// pump noticing the child go — one frame per thing that ended.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase", deny_unknown_fields)]
337pub struct TerminalKilled {
338    pub session_id: String,
339}
340
341/// Why a mirror ended.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub enum TerminalCloseReason {
345    /// The phone asked to stop mirroring.
346    Detached,
347    /// The child exited. The tab is over, not just the mirror.
348    Exited,
349    /// The session was closed on the machine.
350    SessionClosed,
351    /// The reader could not keep up and the mirror was dropped to protect the
352    /// device socket. Reattaching is the remedy, and it replays.
353    Overrun,
354    #[serde(other)]
355    Unknown,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase", deny_unknown_fields)]
360pub struct TerminalClosed {
361    pub stream_id: String,
362    pub reason: TerminalCloseReason,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase", deny_unknown_fields)]
367pub struct SessionHello {
368    /// Every major protocol version this daemon can serve.
369    pub supported_versions: Vec<u32>,
370    pub daemon_version: String,
371    /// Coarse: `macos`, `linux`, `windows`.
372    pub platform: String,
373    pub capabilities: CapabilitySet,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "camelCase", deny_unknown_fields)]
378pub struct DeviceSnapshotResponse {
379    pub device: DeviceSnapshot,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase", deny_unknown_fields)]
384pub struct ServiceListResponse {
385    pub services: Vec<RemoteService>,
386}
387
388/// The state after the action, not a claim that it succeeded.
389///
390/// A `start` that answers `errored` is a complete, honest answer; the phone
391/// shows the state and the user decides. Failures that stopped the action from
392/// happening at all come back as [`PlatformBound::CommandError`] instead.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase", deny_unknown_fields)]
395pub struct ServiceActionResponse {
396    pub service: String,
397    pub action: super::device_bound::ServiceAction,
398    pub state: super::snapshot::ServiceState,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase", deny_unknown_fields)]
403pub struct ServiceLogsResponse {
404    pub service: String,
405    pub lines: Vec<LogLine>,
406    /// Set when older lines were dropped to fit the bounds, so the phone can
407    /// say "showing the last 200" rather than implying this is everything.
408    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
409    pub truncated: bool,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase", deny_unknown_fields)]
414pub struct BundleListResponse {
415    pub bundles: Vec<RemoteBundle>,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "camelCase", deny_unknown_fields)]
420pub struct AgentProvidersResponse {
421    pub providers: Vec<RemoteAgentProvider>,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase", deny_unknown_fields)]
426pub struct AgentTurnAccepted {
427    pub run_id: String,
428    /// The sequence the next event will carry. `0` for a new run; for a resumed
429    /// one, where the daemon is picking up.
430    pub next_seq: u64,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "camelCase", deny_unknown_fields)]
435pub struct CommandErrorResponse {
436    pub error: ProtocolError,
437}
438
439impl PlatformBound {
440    /// Every accepted `type`, in the order the union declares them.
441    pub const KINDS: &'static [&'static str] = &[
442        "session.hello",
443        "session.heartbeat",
444        "device.snapshot.response",
445        "service.list.response",
446        "service.action.response",
447        "service.logs.response",
448        "bundle.list.response",
449        "agent.providers.response",
450        "agent.turn.accepted",
451        "agent.turn.event",
452        "terminal.spawned",
453        "terminal.sessions.response",
454        "terminal.attach.accepted",
455        "terminal.output",
456        "terminal.geometry",
457        "terminal.ack",
458        "terminal.killed",
459        "terminal.closed",
460        "repositories.response",
461        "github.runs.response",
462        "github.run.jobs.response",
463        "github.prs.response",
464        "github.pr.response",
465        "linear.response",
466        "agent.usage.response",
467        "errors.response",
468        "timeline.response",
469        "device.retire",
470        "command.error",
471    ];
472
473    pub fn kind(&self) -> &'static str {
474        match self {
475            Self::SessionHello(_) => "session.hello",
476            Self::SessionHeartbeat(_) => "session.heartbeat",
477            Self::DeviceSnapshot(_) => "device.snapshot.response",
478            Self::ServiceList(_) => "service.list.response",
479            Self::ServiceAction(_) => "service.action.response",
480            Self::ServiceLogs(_) => "service.logs.response",
481            Self::BundleList(_) => "bundle.list.response",
482            Self::AgentProviders(_) => "agent.providers.response",
483            Self::AgentTurnAccepted(_) => "agent.turn.accepted",
484            Self::AgentTurnEvent(_) => "agent.turn.event",
485            Self::TerminalSpawned(_) => "terminal.spawned",
486            Self::TerminalSessions(_) => "terminal.sessions.response",
487            Self::TerminalAttachAccepted(_) => "terminal.attach.accepted",
488            Self::TerminalOutput(_) => "terminal.output",
489            Self::TerminalGeometry(_) => "terminal.geometry",
490            Self::TerminalAck(_) => "terminal.ack",
491            Self::TerminalKilled(_) => "terminal.killed",
492            Self::TerminalClosed(_) => "terminal.closed",
493            Self::Repositories(_) => "repositories.response",
494            Self::GithubRuns(_) => "github.runs.response",
495            Self::GithubRunJobs(_) => "github.run.jobs.response",
496            Self::GithubPulls(_) => "github.prs.response",
497            Self::GithubPull(_) => "github.pr.response",
498            Self::Linear(_) => "linear.response",
499            Self::AgentUsage(_) => "agent.usage.response",
500            Self::Errors(_) => "errors.response",
501            Self::Timeline(_) => "timeline.response",
502            Self::DeviceRetire(_) => "device.retire",
503            Self::CommandError(_) => "command.error",
504        }
505    }
506
507    /// Whether this frame must name the request it answers.
508    ///
509    /// Unsolicited frames — hello, heartbeat, run events — must **not** carry
510    /// `replyTo`, and answers must. Enforced rather than assumed, because a
511    /// response that arrives with no correlation is one the relay would have to
512    /// guess a destination for, and guessing means fanning a private answer to
513    /// the wrong browser.
514    pub fn requires_reply_to(&self) -> bool {
515        match self {
516            Self::SessionHello(_)
517            | Self::SessionHeartbeat(_)
518            | Self::AgentTurnEvent(_)
519            // A mirror is a stream, not an exchange: output and its closing
520            // arrive on their own schedule, long after the attach they belong
521            // to was answered.
522            | Self::TerminalOutput(_)
523            // The machine resizing is news, not an answer: nobody on the phone
524            // asked for it, and the request it would otherwise name was
525            // answered when the mirror opened.
526            | Self::TerminalGeometry(_)
527            | Self::TerminalClosed(_)
528            // Nobody asked the machine to leave. It is telling the platform
529            // that somebody standing at it already has.
530            | Self::DeviceRetire(_) => false,
531            Self::DeviceSnapshot(_)
532            | Self::ServiceList(_)
533            | Self::ServiceAction(_)
534            | Self::ServiceLogs(_)
535            | Self::BundleList(_)
536            | Self::AgentProviders(_)
537            | Self::AgentTurnAccepted(_)
538            | Self::TerminalSpawned(_)
539            | Self::TerminalSessions(_)
540            | Self::TerminalAttachAccepted(_)
541            | Self::TerminalAck(_)
542            | Self::TerminalKilled(_)
543            | Self::Linear(_)
544            | Self::Repositories(_)
545            | Self::GithubRuns(_)
546            | Self::GithubRunJobs(_)
547            | Self::GithubPulls(_)
548            | Self::GithubPull(_)
549            | Self::AgentUsage(_)
550            | Self::Errors(_)
551            | Self::Timeline(_)
552            | Self::CommandError(_) => true,
553        }
554    }
555
556    /// Parse one frame's `type` and `payload`. Same two-failure split as
557    /// [`super::device_bound::DeviceBound::parse`].
558    pub fn parse(kind: &str, payload: serde_json::Value) -> Result<Self, ProtocolError> {
559        use super::errors::ErrorCode;
560        if !Self::KINDS.contains(&kind) {
561            return Err(
562                ProtocolError::new(ErrorCode::UnknownCommand, "Unknown remote event.")
563                    .with_detail(kind),
564            );
565        }
566        let tagged = serde_json::json!({ "type": kind, "payload": payload });
567        serde_json::from_value(tagged).map_err(|error| {
568            ProtocolError::new(ErrorCode::MalformedFrame, "Event payload is not valid.")
569                .with_detail(error.to_string())
570        })
571    }
572
573    /// The `payload` half of the envelope.
574    pub fn payload(&self) -> serde_json::Value {
575        let tagged = serde_json::to_value(self).expect("an event always serialises");
576        tagged
577            .get("payload")
578            .cloned()
579            .unwrap_or(serde_json::Value::Null)
580    }
581}