Skip to main content

pointlock_ir/
record.rs

1//! Durable execution records and the checkpoint materialization view.
2//!
3//! Definition home adjudicated to `pointlock-ir` (type truth source, R12) so
4//! `pointlock-store` and `pointlock-runner` share one shape without violating
5//! the dependency direction; pending spine batch incorporation.
6//!
7//! Shapes follow spine §6.6 (`CheckpointView`/`StepRecord`), §6.7-A
8//! (`AlignmentReport`), and 07 §3.2 (`CallFrame` refinement,
9//! `humanPending.purpose`).
10
11use std::collections::BTreeMap;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::primitives::{ActionName, AssertId, FlowId, Hash, StepId};
18use crate::run_path::RunPath;
19use crate::runtime::{EvidenceRef, StepVerdict, Viewport};
20use crate::vocab::{
21    ActChannel, AlignmentClass, Channel, CoordinateFallbackReason, ErrorClass, ExecutionMode,
22    HumanMode, HumanPurpose, ScreenshotOmissionReason, StepState, UiSnapshotOmissionReason,
23    VerdictStatus,
24};
25
26/// Discriminant-only projection of an `ActionOutcome` for durable attempt
27/// records (the full outcome lives in the RunLog `actionSettled` payload).
28/// Pending incorporation.
29#[derive(
30    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
31)]
32#[serde(rename_all = "camelCase")]
33pub enum ActionOutcomeKind {
34    /// Completed with a result.
35    Succeeded,
36    /// Failed with a structured error.
37    Failed,
38    /// Cooperatively cancelled.
39    Cancelled,
40    /// Action budget elapsed.
41    TimedOut,
42}
43
44/// One attempt of an action step (spine §6.6): every dispatch, successful
45/// or not, leaves one record keyed by its WAL callId.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "camelCase", deny_unknown_fields)]
48pub struct AttemptRecord {
49    /// The caller-generated action id (matches the `actionIntent` WAL entry).
50    pub call_id: String,
51    /// Four-way terminal discriminant.
52    pub outcome: ActionOutcomeKind,
53    /// Runner-side error classification, for non-succeeded outcomes.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub error_class: Option<ErrorClass>,
56    /// The execution mode the provider reported.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub execution_mode: Option<ExecutionMode>,
59    /// Fallback reason when `executionMode` is `coordinateFallback`.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub fallback_reason: Option<CoordinateFallbackReason>,
62    /// 1-based `binding.attempts` position of the dispatch (2026-07-18
63    /// incorporation, item ② — carried from the `actionIntent` event;
64    /// absent on pre-incorporation ledgers).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub chain_index: Option<u32>,
67    /// The dispatched attempt's locating channel.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub channel: Option<ActChannel>,
70    /// The dispatched attempt's provider-native action name.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub action_name: Option<ActionName>,
73}
74
75/// The provider-side session profile captured at a failure or suspension
76/// instant (07 §2.2, incorporated 2026-07-18): pure forensic material for
77/// the dossier/overview — resume reconcile, the fold's binding provenance
78/// and alignment never consume it. Rides `stepExited` (fail/unknown
79/// verdict in force at exit) and `runSuspended` as an additive optional
80/// payload field; absent on ledgers recorded before incorporation.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
82#[serde(rename_all = "camelCase", deny_unknown_fields)]
83pub struct ProviderStateSummary {
84    /// Known session generations, best-effort: the checkpoint-known
85    /// lineage plus the live session observed at capture.
86    pub session_lineage: Vec<String>,
87    /// The capture-instant cursor (`currentCursor()` RPC). Absent when
88    /// the call failed — never a stale bind-time value (principle 4).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub event_cursor: Option<EventCursor>,
91    /// The attested capability baseline at capture.
92    pub attestation: AttestationSnapshot,
93    /// `ProviderSession::health()` at capture; a failed call records
94    /// `{ ok: false, degraded: "<errorClass>" }`.
95    pub health: SessionHealthSnapshot,
96    /// The bound device.
97    pub device_id: String,
98    /// `lockfile.device.platform`; absent when the assembly layer did
99    /// not supply it (the SPI attestation does not carry it).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub platform: Option<String>,
102}
103
104/// The two attestation facts 07 §2.2 pins into the summary (deliberately
105/// not the full `CapabilityAttestation`).
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
107#[serde(rename_all = "camelCase", deny_unknown_fields)]
108pub struct AttestationSnapshot {
109    /// Digest of the attested lockfile.
110    pub lockfile_digest: Hash,
111    /// Attestation timestamp, verbatim.
112    pub attested_at: String,
113}
114
115/// Wire twin of the provider-kit `SessionHealth` (defined here because
116/// provider-kit depends on ir, not vice versa).
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct SessionHealthSnapshot {
120    /// Whether the session answered healthy.
121    pub ok: bool,
122    /// Degradation note (an error class when the health call failed).
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub degraded: Option<String>,
125}
126
127/// A localized observation record (spine §6.6; omissions are typed data).
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
129#[serde(rename_all = "camelCase", deny_unknown_fields)]
130pub struct ObservationRecord {
131    /// Provider-scoped observation id.
132    pub observation_id: String,
133    /// Capture timestamp (ms since epoch).
134    pub captured_at_ms: u64,
135    /// The capture-time viewport, verbatim from the provider observation
136    /// (M3a additive close of the registered W1 gap; absent on ledgers
137    /// recorded before the field existed, or when the provider reported
138    /// a non-finite scale factor — the runner refuses to persist a value
139    /// serde_json would write as `null` and never read back).
140    ///
141    /// Compat note: the additive claim is one-directional — readers
142    /// built before this field existed reject ledgers containing it
143    /// (`deny_unknown_fields`, fail-closed per R12); binary downgrade
144    /// against a store written by a newer runner is unsupported.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub viewport: Option<Viewport>,
147    /// Localized screenshot evidence, when captured.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub screenshot: Option<EvidenceRef>,
150    /// Why the screenshot was legitimately omitted.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub screenshot_omission: Option<ScreenshotOmissionReason>,
153    /// Localized UI-tree evidence, when captured.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub ui_snapshot: Option<EvidenceRef>,
156    /// Why the UI snapshot was legitimately omitted.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
159}
160
161/// Identity and self-reported basis of the vision judge that completed a
162/// vision-channel evaluation. Evidence honesty for a pluggable judge:
163/// once the model behind the `vision` channel is swappable, a verdict
164/// that does not say *which* model answered — and on *what* facts it says
165/// it based the answer — cannot be audited, compared across deployments,
166/// or meaningfully re-judged offline.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct VisionJudgeRecord {
170    /// The verifier implementation family, e.g. `anthropic` /
171    /// `openai-compat`.
172    pub provider: String,
173    /// The model id the verifier requested, when it has one.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub model: Option<String>,
176    /// The judge's self-reported observations: the on-screen facts it
177    /// listed before answering (the look-then-judge answer protocol).
178    /// Self-reported — checkable against the archived screenshot, not
179    /// independently attested. Absent when the model listed none.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub observations: Option<Vec<String>>,
182}
183
184/// Outcome of one assertion evaluation along its verify chain (spine §6.6).
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct AssertionOutcomeRecord {
188    /// The assertion's id.
189    pub assert_id: AssertId,
190    /// Three-valued result (a chain that ran dry is `unknown`, never `fail`).
191    pub result: VerdictStatus,
192    /// The channel that completed the evaluation, when one did.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub channel: Option<Channel>,
195    /// Human-readable evaluation reason.
196    pub reason: String,
197    /// The vision judge that completed this evaluation, when the
198    /// completing channel was `vision`. Absent on ledgers recorded before
199    /// the field existed and on evaluations no vision model completed.
200    ///
201    /// Compat note: the additive claim is one-directional, as for
202    /// [`ObservationRecord::viewport`] — readers built before this field
203    /// existed reject ledgers containing it (`deny_unknown_fields`,
204    /// fail-closed per R12).
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub vision_judge: Option<VisionJudgeRecord>,
207}
208
209/// The durable record of one completed step (spine §6.6).
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct StepRecord {
213    /// Full run path of this step instance.
214    pub run_path: RunPath,
215    /// The step's stable id.
216    pub step_id: StepId,
217    /// Effect-domain hash at execution time (alignment input).
218    pub effect_hash: Hash,
219    /// Judge-domain hash at execution time (alignment input).
220    pub judge_hash: Hash,
221    /// Every dispatch attempt, in order.
222    pub attempts: Vec<AttemptRecord>,
223    /// Input expressions evaluated once at `ready` and snapshotted;
224    /// never re-evaluated on resume.
225    pub resolved_inputs: Value,
226    /// Projected step output, when the step declares outputs. A JSON
227    /// `null` output is present (`Some(Null)`), not absent, across the
228    /// checkpoint view.
229    #[serde(
230        default,
231        deserialize_with = "crate::run_log::some_even_if_null",
232        skip_serializing_if = "Option::is_none"
233    )]
234    pub output: Option<Value>,
235    /// Localized observations.
236    pub observations: Vec<ObservationRecord>,
237    /// Localized evidence (content-addressed).
238    pub evidence: Vec<EvidenceRef>,
239    /// Assertion outcomes, in declaration order.
240    pub assertion_outcomes: Vec<AssertionOutcomeRecord>,
241    /// The folded verdict projection, absent for unasserted mutations (R4).
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub verdict: Option<StepVerdict>,
244}
245
246/// One live `foreach` iteration inside a call frame (07 §3.2).
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
248#[serde(rename_all = "camelCase", deny_unknown_fields)]
249pub struct IterState {
250    /// The iteration variable name (`foreach ... as`).
251    #[serde(rename = "as")]
252    pub var: String,
253    /// Zero-based index of the current item.
254    pub index: u64,
255    /// Optional stable item key (keyed foreach is reserved, v0.2).
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub key: Option<String>,
258}
259
260/// One frame of the live call stack (07 §3.2 refinement; `frames[0]` is the
261/// root flow frame). Pending spine incorporation of the refined fields.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
263#[serde(rename_all = "camelCase", deny_unknown_fields)]
264pub struct CallFrame {
265    /// The flow executing in this frame.
266    pub flow_id: FlowId,
267    /// Content hash of that flow's IR.
268    pub ir_hash: Hash,
269    /// The host call step's id; absent for the root frame and for
270    /// handler-repair launched frames (spine §9).
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub call_step_id: Option<StepId>,
273    /// Call-by-value input snapshot taken when the frame was pushed;
274    /// never updated from a newer IR (07 §5.2).
275    pub inputs_snapshot: Value,
276    /// `let` bindings materialized in this frame (SSA, single assignment).
277    pub vars: BTreeMap<String, Value>,
278    /// Live `foreach` iterations, innermost last.
279    pub iter_stack: Vec<IterState>,
280    /// Index of the next body step to schedule in this frame.
281    pub next_index: u64,
282}
283
284/// The WAL'd intent of a dispatched-but-unsettled action (spine §6.6).
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
286#[serde(rename_all = "camelCase", deny_unknown_fields)]
287pub struct PendingIntent {
288    /// The caller-generated action id to reconcile against.
289    pub call_id: String,
290    /// The evaluated arguments as dispatched.
291    pub args_snapshot: Value,
292}
293
294/// The execution frontier: the single in-flight step (spine §6.6).
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
296#[serde(rename_all = "camelCase", deny_unknown_fields)]
297pub struct Frontier {
298    /// Run path of the in-flight step.
299    pub run_path: RunPath,
300    /// Its lifecycle state at materialization time.
301    pub state: StepState,
302    /// The hanging action intent, when crash-vulnerable work was in flight.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub pending_intent: Option<PendingIntent>,
305}
306
307/// A pending human interaction (spine §6.6 + R13 purpose discriminator;
308/// 06 §8-Q4 adopted — `mode`/`deadlineAtMs` are carried so lazy timeout
309/// settlement needs no event-stream re-read).
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312pub struct HumanPending {
313    /// Run path of the awaiting step (or gated step for supervision).
314    pub run_path: RunPath,
315    /// The request id the response must pair with.
316    pub request_id: String,
317    /// Why the interaction exists (step vs supervision gate).
318    pub purpose: HumanPurpose,
319    /// Interaction mode of a `purpose="step"` request; absent for
320    /// supervision gates (06 §2.1).
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub mode: Option<HumanMode>,
323    /// The prompt shown to the human (auto-generated gate description for
324    /// supervision requests).
325    pub prompt: String,
326    /// Absolute response deadline (ms since epoch); absent for supervision
327    /// requests, which have no deadline (spine §6.9).
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub deadline_at_ms: Option<u64>,
330}
331
332/// Event-log cursor of the bound provider session (spine §6.6; advanced
333/// ack-after-persist, never across session generations).
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
335#[serde(rename_all = "camelCase", deny_unknown_fields)]
336pub struct EventCursor {
337    /// The provider session that issued the recorded events.
338    pub session_id: String,
339    /// Last sequence durably consumed from that session.
340    pub last_sequence: u64,
341}
342
343/// The run's provider binding state (spine §6.6).
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
345#[serde(rename_all = "camelCase", deny_unknown_fields)]
346pub struct BindingState {
347    /// The bound device.
348    pub device_id: String,
349    /// Every provider session generation of this run, oldest first.
350    pub session_lineage: Vec<String>,
351    /// Cursor into the issuing session's event log.
352    pub event_cursor: EventCursor,
353}
354
355/// The deterministic materialized view of a RunLog at a safe point
356/// (spine §6.6); always reconstructible by folding the log.
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
358#[serde(rename_all = "camelCase", deny_unknown_fields)]
359pub struct CheckpointView {
360    /// The run this view materializes.
361    pub run_id: String,
362    /// Content hash of the executing IR.
363    pub ir_hash: Hash,
364    /// Digest of the capability lockfile the IR was bound against.
365    pub lockfile_digest: Hash,
366    /// The run's input parameters, snapshotted at start.
367    pub params_snapshot: Value,
368    /// Provider binding state.
369    pub binding: BindingState,
370    /// Records of all completed steps, in completion order.
371    pub completed: Vec<StepRecord>,
372    /// The live call stack; `frames[0]` is the root flow frame.
373    pub frames: Vec<CallFrame>,
374    /// The single in-flight step.
375    pub frontier: Frontier,
376    /// The pending human interaction, when suspended on one.
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub human_pending: Option<HumanPending>,
379}
380
381/// One aligned step in a resume alignment report (spine §6.7-A).
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
383#[serde(rename_all = "camelCase", deny_unknown_fields)]
384pub struct AlignmentEntry {
385    /// Which instance was classified (07 §5.2: the report entry is
386    /// path-addressed).
387    ///
388    /// A step id alone is ambiguous the moment a run nests: the same id
389    /// recurs once per `foreach` round and once per call of a callee, so
390    /// two entries could name the same step and mean different instances.
391    /// The path says which one — and it is the same address
392    /// `requiresConfirmation` already uses, so the two halves of the
393    /// report finally speak one vocabulary.
394    pub run_path: RunPath,
395    /// The step being classified. Kept alongside the path: it is what a
396    /// reader recognizes, and what `--allow-mutating-reexec` names.
397    pub step_id: StepId,
398    /// Its alignment class.
399    pub class: AlignmentClass,
400    /// Sub-domain annotation, e.g. `preflightChanged` (02 §12.3 ruling 6).
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub reason: Option<String>,
403}
404
405/// A step whose re-execution needs explicit human authorization
406/// (07 §5.2/§5.4 unified gate).
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
408#[serde(rename_all = "camelCase", deny_unknown_fields)]
409pub struct RequiresConfirmation {
410    /// Run path of the gated step.
411    pub run_path: RunPath,
412    /// The step id `--allow-mutating-reexec` takes for this gate (07 §5.4
413    /// step 2: authorization names steps one by one, never a wildcard).
414    ///
415    /// Without it a reader has to re-derive the id from the path — which is
416    /// exactly what the CLI did, and what every other consumer would have
417    /// had to reimplement. Additive optional field (spine §6.1 R12): absent
418    /// on pre-incorporation ledgers, where consumers fall back to the path.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub step_id: Option<StepId>,
421    /// Gate cause. Known values: `mutatingReexec`, `positionalReplay`,
422    /// `orderInvalidated`, `frontierUnknown` (07 §5.4); closed-set
423    /// incorporation pending.
424    pub cause: String,
425    /// Human-readable explanation.
426    pub reason: String,
427}
428
429/// The alignment report a resume records in `runResumed` (spine §6.7-A).
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
431#[serde(rename_all = "camelCase", deny_unknown_fields)]
432pub struct AlignmentReport {
433    /// Per-step classifications, in new-IR order.
434    pub entries: Vec<AlignmentEntry>,
435    /// The computed resume point, absent when the run cannot resume.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub resume_point: Option<RunPath>,
438    /// Steps requiring explicit re-execution authorization.
439    pub requires_confirmation: Vec<RequiresConfirmation>,
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use serde_json::json;
446
447    #[test]
448    fn iter_state_uses_the_as_keyword_on_the_wire() {
449        let iter = IterState {
450            var: "item".to_owned(),
451            index: 2,
452            key: None,
453        };
454        let wire = serde_json::to_value(&iter).expect("serialize");
455        assert_eq!(wire, json!({"as": "item", "index": 2}));
456    }
457
458    #[test]
459    fn checkpoint_view_round_trips_with_absent_optionals() {
460        let view = CheckpointView {
461            run_id: "run-1".to_owned(),
462            ir_hash: serde_json::from_value(json!(format!("sha256:{}", "a".repeat(64))))
463                .expect("hash"),
464            lockfile_digest: serde_json::from_value(json!(format!("sha256:{}", "b".repeat(64))))
465                .expect("hash"),
466            params_snapshot: json!({"user": "alice"}),
467            binding: BindingState {
468                device_id: "dev-1".to_owned(),
469                session_lineage: vec!["s-1".to_owned()],
470                event_cursor: EventCursor {
471                    session_id: "s-1".to_owned(),
472                    last_sequence: 42,
473                },
474            },
475            completed: vec![],
476            frames: vec![CallFrame {
477                flow_id: serde_json::from_value(json!("checkout")).expect("flow id"),
478                ir_hash: serde_json::from_value(json!(format!("sha256:{}", "a".repeat(64))))
479                    .expect("hash"),
480                call_step_id: None,
481                inputs_snapshot: json!({}),
482                vars: BTreeMap::new(),
483                iter_stack: vec![],
484                next_index: 3,
485            }],
486            frontier: Frontier {
487                run_path: vec![],
488                state: StepState::Acting,
489                pending_intent: Some(PendingIntent {
490                    call_id: "c-1".to_owned(),
491                    args_snapshot: json!({"x": 1}),
492                }),
493            },
494            human_pending: None,
495        };
496        let wire = serde_json::to_value(&view).expect("serialize");
497        assert_eq!(wire["frontier"]["state"], "acting");
498        assert_eq!(wire["binding"]["eventCursor"]["lastSequence"], 42);
499        assert!(wire.get("humanPending").is_none());
500        assert!(wire["frames"][0].get("callStepId").is_none());
501        let back: CheckpointView = serde_json::from_value(wire).expect("deserialize");
502        assert_eq!(back, view);
503    }
504
505    #[test]
506    fn human_pending_purpose_discriminates_supervision() {
507        // Supervision requests carry no mode and no deadline (spine §6.9):
508        // both optionals are absent on the wire, never null.
509        let pending = HumanPending {
510            run_path: vec![],
511            request_id: "req-1".to_owned(),
512            purpose: HumanPurpose::Supervision,
513            mode: None,
514            prompt: "Approve mutating dispatch of tapPay".to_owned(),
515            deadline_at_ms: None,
516        };
517        let wire = serde_json::to_value(&pending).expect("serialize");
518        assert_eq!(wire["purpose"], "supervision");
519        let object = wire.as_object().expect("object");
520        assert!(!object.contains_key("mode"));
521        assert!(!object.contains_key("deadlineAtMs"));
522        let back: HumanPending = serde_json::from_value(wire).expect("deserialize");
523        assert_eq!(back, pending);
524    }
525
526    #[test]
527    fn human_pending_step_purpose_carries_mode_and_deadline() {
528        // 06 §8-Q4 adopted: a step-purpose pending request carries its
529        // mode and absolute deadline for lazy settlement.
530        let pending = HumanPending {
531            run_path: vec![],
532            request_id: "req-2".to_owned(),
533            purpose: HumanPurpose::Step,
534            mode: Some(crate::vocab::HumanMode::Confirm),
535            prompt: "Confirm the transfer".to_owned(),
536            deadline_at_ms: Some(1_700_000_600_000),
537        };
538        let wire = serde_json::to_value(&pending).expect("serialize");
539        assert_eq!(wire["mode"], "confirm");
540        assert_eq!(
541            wire["deadlineAtMs"],
542            serde_json::json!(1_700_000_600_000_u64)
543        );
544        let back: HumanPending = serde_json::from_value(wire).expect("deserialize");
545        assert_eq!(back, pending);
546    }
547}