Skip to main content

kanade_shared/wire/
result.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4/// Prefix injected into the UUIDv5 name string for deriving legacy
5/// `result_id`s. Fixed marker so two backends (or one backend across
6/// restarts) projecting the same legacy payload arrive at the same
7/// id. Tied to the standard `Uuid::NAMESPACE_OID` namespace below.
8/// Bumping this prefix would break dedupe of legacy redeliveries
9/// crossing the upgrade — don't.
10const LEGACY_RESULT_ID_PREFIX: &str = "kanade-issue-19/legacy-result-id:";
11
12#[derive(Serialize, Deserialize, Debug, Clone)]
13pub struct ExecResult {
14    /// v0.29 / Issue #19: agent-minted UUID, unique per (Command, PC)
15    /// run. Replaces `request_id` as the projector's primary key so
16    /// broadcast Commands (commands.all / commands.group.X) — where N
17    /// PCs share one `request_id` — finally persist all N results
18    /// instead of silently dropping all but the first. Pre-v0.29
19    /// agents omit this field; it deserialises as the empty string,
20    /// and [`Self::stable_result_id`] derives a deterministic UUIDv5
21    /// from `(request_id, pc_id)` so legacy payloads (a) get distinct
22    /// ids across broadcast PCs (PC #2's row stops being dropped) and
23    /// (b) get the SAME id on JetStream redelivery (the new `ON
24    /// CONFLICT(result_id) DO NOTHING` path correctly dedupes, so
25    /// `executions.success_count` doesn't double-count across retries).
26    #[serde(default)]
27    pub result_id: String,
28    /// The NATS reply token. Still surfaced for joining back to the
29    /// `kanade run` request/reply path. No longer unique across rows
30    /// (broadcast Commands share it).
31    pub request_id: String,
32    /// v0.29 / Issue #19: back-link to `executions.exec_id`. Copied
33    /// from `Command.exec_id` by the agent. `None` for ad-hoc
34    /// `kanade run` (no deployment) and for results emitted by
35    /// pre-v0.29 agents (decoded via `serde(default)`).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub exec_id: Option<String>,
38    /// #955: back-link to the parent run's `result_id` for a
39    /// `finalize:` hook's own result row. Set by the agent to the
40    /// triggering run's `result_id` so the SPA can link the
41    /// `<job>__finalize` row to (and from) the run whose collect it
42    /// cleaned up. `None` for every ordinary run and every pre-#955
43    /// payload (`serde(default)` keeps older results decodable).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub parent_result_id: Option<String>,
46    pub pc_id: String,
47    pub exit_code: i32,
48    /// Whether the agent ran the script, as the agent itself reports it.
49    ///
50    /// - `Some(true)`: it published this result *instead of* running the
51    ///   script because policy (or this OS) said "not now": deadline /
52    ///   revoke / version-pin / staleness / unsupported-OS. Such a result
53    ///   says nothing about the script's outcome, so aggregations count it
54    ///   as `skipped`, never as a failure.
55    /// - `Some(false)`: authoritative "this is not a skip" — the script ran
56    ///   (whatever it exited, including 126 / 127, which a real script under
57    ///   sh returns for "not executable" / "command not found"), or the
58    ///   agent refused the command ([`EXIT_REJECTED_UNSIGNED`]). A refusal
59    ///   is deliberately not a skip: "this command was not authorised" is
60    ///   something the fleet's failure counts must surface.
61    /// - `None`: the key was absent, i.e. an agent that predates the flag.
62    ///   See [`Self::is_reported_skip`] and
63    ///   [`Self::skips_check_projection`] for how each consumer reads it.
64    ///
65    /// Agents that know the flag always send it (never `null`), so absence
66    /// means exactly "legacy agent". The reserved exit codes only say *why*.
67    #[serde(default)]
68    pub skipped: Option<bool>,
69    /// stdout. Empty string when [`Self::stdout_object`] is set — the
70    /// agent overflowed the bytes into [`crate::kv::OBJECT_RESULT_OUTPUT`]
71    /// because the inline payload would have exceeded NATS's default
72    /// `max_payload` (#227). The backend projector derefs the pointer
73    /// before inserting; SQLite still stores the full text inline so
74    /// the SPA Activity page reads unchanged.
75    pub stdout: String,
76    pub stderr: String,
77    pub started_at: chrono::DateTime<chrono::Utc>,
78    pub finished_at: chrono::DateTime<chrono::Utc>,
79    /// Object Store key under [`crate::kv::OBJECT_RESULT_OUTPUT`] when
80    /// `stdout` overflowed the agent's inline threshold (#227). Set to
81    /// `Some("<request_id>/<pc_id>/stdout")` by the agent's outbox drain; the
82    /// backend projector fetches the bytes from that key and uses them
83    /// in place of the (empty) `stdout` field. `None` for the common
84    /// small-stdout case + every pre-#227 payload (`serde(default)`
85    /// keeps older results decodable).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub stdout_object: Option<String>,
88    /// Sibling of `stdout_object` for the stderr stream. Same key
89    /// shape (`<request_id>/stderr`).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub stderr_object: Option<String>,
92    /// v0.13: the manifest id that produced this result. Sourced
93    /// from `Command.id` (which is the YAML `manifest.id`, e.g.
94    /// `"inventory-hw"`). Distinct from the per-deploy UUID stored
95    /// in `Command.exec_id`. The results projector uses this to
96    /// look up the manifest's `inventory:` hint and upsert
97    /// `inventory_facts` rows for inventory-tagged jobs.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub manifest_id: Option<String>,
100    /// #219: Object Store key under [`crate::kv::OBJECT_COLLECTIONS`] for
101    /// the bundle this run collected, when the job carried a `collect:`
102    /// hint and the run succeeded. Set by the agent to
103    /// `Some("<pc_id>/<job_id>/<rfc3339>.zip")` after it zips the
104    /// script's listed files and uploads the archive. `None` for every
105    /// non-collect job + every pre-#219 payload (`serde(default)` keeps
106    /// older results decodable). The SPA Collect page lists / downloads
107    /// these straight from the bucket.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub collect_object: Option<String>,
110}
111
112/// Synthetic exit code for a run the agent skipped because the
113/// Command's `version` didn't match the `script_current` pin. One of
114/// the reserved synthetic codes (122–127) saying *why* the agent
115/// published a result instead of running the script; the result
116/// carries [`ExecResult::skipped`], which is what consumers key on —
117/// the code alone proves nothing, as a real script can exit with the
118/// same number. Consumers that derive state from a run's output (e.g.
119/// the backend's `check_status` projection) treat a skip as "no new
120/// evidence", not as a run (#909).
121pub const EXIT_SKIP_VERSION_PIN: i32 = 124;
122/// Synthetic exit code: `deadline_at` passed before the agent could
123/// fire. See [`EXIT_SKIP_VERSION_PIN`] for the shared contract.
124pub const EXIT_SKIP_DEADLINE: i32 = 125;
125/// Synthetic exit code: the script is revoked in
126/// `BUCKET_SCRIPT_STATUS`. See [`EXIT_SKIP_VERSION_PIN`].
127pub const EXIT_SKIP_REVOKED: i32 = 126;
128/// Synthetic exit code: the `staleness.mode: strict` policy suppressed
129/// the fire. See [`EXIT_SKIP_VERSION_PIN`].
130pub const EXIT_SKIP_STALENESS: i32 = 127;
131/// Synthetic exit code: the agent **refused** the command because its
132/// provenance signature did not check out (#1165 stage 3).
133///
134/// Extends the reserved block downwards, because 124–127 was full. The script
135/// never ran, so this is not evidence about its outcome — but it is not a
136/// *skip*: the others mean "policy (or this OS) said not now", while this one
137/// means "this command was not authorised". So the result is published with
138/// [`ExecResult::skipped`] `Some(false)` and counts as a failure everywhere
139/// results are tallied: a fleet refusing unsigned commands must show up in
140/// the failure counts, not vanish into "skipped". The one consumer that only
141/// asks whether the script ran — the `check_status` projection — recognises a
142/// refusal by [`ExecResult::is_signature_refusal`] instead.
143///
144/// Emitting a result at all is the point. `kanade run` waits on
145/// `results.<request_id>`, so a refusal that published nothing would be
146/// indistinguishable from an agent that is simply gone — and the most likely
147/// refusal in practice is an operator's own break-glass command going stale on
148/// a host whose clock is wrong, during an incident, with the backend down and
149/// the obs event stuck in the outbox.
150pub const EXIT_REJECTED_UNSIGNED: i32 = 123;
151/// Synthetic exit code: the schedule needs a feature this agent's OS
152/// **cannot evaluate or source** — a `constraints.require` gate with no
153/// sensing on this platform, or a `when.on` event this OS never emits.
154/// Emitted by the agent's local scheduler, which fails closed (the job is
155/// not run and no per-pc completion is recorded, so it still runs once the
156/// gate becomes supported). Grew the reserved block downwards again (123
157/// was the edge); shares the [`EXIT_SKIP_VERSION_PIN`] contract — the
158/// script never ran.
159pub const EXIT_SKIP_UNSUPPORTED: i32 = 122;
160
161/// Synthetic exit code: the agent restarted while this command was
162/// launching or running, so what became of the script is **unknown**.
163///
164/// Reported once, at the next start, for an admitted command whose durable
165/// record says it had begun but whose outcome never reached disk. The process
166/// may still be running, may have finished, or may never have started; the
167/// agent does not relaunch it, because a second launch could repeat a side
168/// effect. Sits just below the skip block (122..=127) on purpose: the result is
169/// published with [`ExecResult::skipped`] `Some(false)`, so it is a failure in
170/// every tally, never a skip and never a success.
171pub const EXIT_RESTARTED_OUTCOME_UNKNOWN: i32 = 121;
172
173/// The deterministic `result_id` of the signature refusal for
174/// `(request_id, pc_id)` (#1165). Deterministic because a refusal is the one
175/// result that repeats — the replay re-delivers the same unverifiable command
176/// on every reconnect — so every re-publish must land on the same row. It also
177/// makes a refusal recognisable without trusting the exit code alone: see
178/// [`ExecResult::is_signature_refusal`].
179pub fn signature_refusal_result_id(request_id: &str, pc_id: &str) -> String {
180    Uuid::new_v5(
181        &Uuid::NAMESPACE_OID,
182        format!("{request_id}|{pc_id}|signature-refused").as_bytes(),
183    )
184    .to_string()
185}
186
187impl ExecResult {
188    /// Return the `result_id` if the agent supplied one (v0.29+
189    /// payloads always do), otherwise derive a stable UUIDv5 from
190    /// `(request_id, pc_id)`. The projector calls this before INSERT
191    /// so legacy payloads still get a non-empty PK, AND so that
192    /// JetStream redeliveries of the same legacy payload hash to the
193    /// same id and dedupe via `ON CONFLICT`. Per-PC fan-out stays
194    /// distinct (different `pc_id` → different hash).
195    pub fn stable_result_id(&self) -> String {
196        if !self.result_id.is_empty() {
197            return self.result_id.clone();
198        }
199        let name = format!(
200            "{LEGACY_RESULT_ID_PREFIX}{}:{}",
201            self.request_id, self.pc_id
202        );
203        Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()).to_string()
204    }
205
206    /// True when the agent reported this result as a skip. What every
207    /// result tally (and `execution_results.skipped`) counts. A legacy
208    /// result (`None`) is not one: tallies read those by exit code, exactly
209    /// as they did before the flag existed.
210    pub fn is_reported_skip(&self) -> bool {
211        self.skipped == Some(true)
212    }
213
214    /// True when this result must be kept out of the `check_status`
215    /// projection because the script never ran (#909). Follows the flag
216    /// when the agent sent one. For a legacy result (`None`) it keeps the
217    /// rule the projector applied before the flag existed: every reserved
218    /// exit code 122..=127 is dropped. A legacy agent cannot tell its own
219    /// skip from a script that really exited 126 / 127, so those real exits
220    /// stay dropped for legacy agents too — unchanged behaviour, not a new
221    /// guess. (The signature refusal is handled separately, see
222    /// [`Self::is_signature_refusal`].)
223    pub fn skips_check_projection(&self) -> bool {
224        match self.skipped {
225            Some(skipped) => skipped,
226            None => (EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS).contains(&self.exit_code),
227        }
228    }
229
230    /// True for the agent's signature refusal (#1165): exit
231    /// [`EXIT_REJECTED_UNSIGNED`] under the refusal's derived `result_id`.
232    /// A script that merely exits 123 (xargs does, for one) carries an
233    /// ordinary random id and is not a refusal.
234    pub fn is_signature_refusal(&self) -> bool {
235        self.exit_code == EXIT_REJECTED_UNSIGNED
236            && self.result_id == signature_refusal_result_id(&self.request_id, &self.pc_id)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use chrono::TimeZone;
244
245    fn sample(exit_code: i32, skipped: Option<bool>) -> ExecResult {
246        let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
247        ExecResult {
248            result_id: "r1".into(),
249            request_id: "req".into(),
250            exec_id: None,
251            parent_result_id: None,
252            pc_id: "PC1".into(),
253            exit_code,
254            skipped,
255            stdout: String::new(),
256            stderr: String::new(),
257            started_at: t0,
258            finished_at: t0,
259            stdout_object: None,
260            stderr_object: None,
261            manifest_id: None,
262            collect_object: None,
263        }
264    }
265
266    #[test]
267    fn restart_outcome_unknown_is_a_failure_outside_the_skip_block() {
268        assert!(
269            !(EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS)
270                .contains(&EXIT_RESTARTED_OUTCOME_UNKNOWN)
271        );
272        let r = sample(EXIT_RESTARTED_OUTCOME_UNKNOWN, Some(false));
273        assert!(!r.is_reported_skip());
274        assert!(!r.skips_check_projection());
275    }
276
277    #[test]
278    fn skipped_is_always_on_the_wire_and_absent_means_legacy() {
279        for flag in [Some(true), Some(false)] {
280            let json = serde_json::to_string(&sample(127, flag)).unwrap();
281            let expected = format!("\"skipped\":{}", flag.unwrap());
282            assert!(json.contains(&expected), "{expected} missing: {json}");
283            let back: ExecResult = serde_json::from_str(&json).unwrap();
284            assert_eq!(back.skipped, flag);
285        }
286        // An agent that predates the flag sends no key at all.
287        let json = r#"{
288            "request_id":"r","pc_id":"x","exit_code":125,
289            "stdout":"","stderr":"",
290            "started_at":"2026-05-16T00:00:00Z",
291            "finished_at":"2026-05-16T00:00:00Z"
292        }"#;
293        let legacy: ExecResult = serde_json::from_str(json).unwrap();
294        assert_eq!(legacy.skipped, None);
295    }
296
297    #[test]
298    fn reporting_follows_the_flag_and_check_projection_falls_back_for_legacy() {
299        // Reporting: only an explicit skip; legacy is read by exit code.
300        assert!(sample(125, Some(true)).is_reported_skip());
301        assert!(!sample(127, Some(false)).is_reported_skip());
302        assert!(!sample(125, None).is_reported_skip());
303
304        // Check projection: the flag decides when present...
305        assert!(sample(0, Some(true)).skips_check_projection());
306        assert!(!sample(127, Some(false)).skips_check_projection());
307        // ...and legacy keeps the pre-flag 122..=127 rule, edges included.
308        for code in [122, 123, 125, 127] {
309            assert!(sample(code, None).skips_check_projection(), "{code}");
310        }
311        for code in [0, 1, 121, 128] {
312            assert!(!sample(code, None).skips_check_projection(), "{code}");
313        }
314    }
315
316    #[test]
317    fn signature_refusal_is_recognised_by_its_derived_id_not_by_exit_123() {
318        let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
319        let mut r = ExecResult {
320            result_id: signature_refusal_result_id("req-1", "PC1"),
321            request_id: "req-1".into(),
322            exec_id: None,
323            parent_result_id: None,
324            pc_id: "PC1".into(),
325            exit_code: EXIT_REJECTED_UNSIGNED,
326            skipped: Some(false),
327            stdout: String::new(),
328            stderr: "refused: unsigned".into(),
329            started_at: t0,
330            finished_at: t0,
331            stdout_object: None,
332            stderr_object: None,
333            manifest_id: None,
334            collect_object: None,
335        };
336        assert!(r.is_signature_refusal());
337        // A script that just exits 123 (xargs does) has an ordinary id.
338        r.result_id = "3f0e6a52-1b7c-4c1e-9d55-0d8f1f2b7a10".into();
339        assert!(!r.is_signature_refusal());
340        // The derived id with any other exit code is not a refusal either.
341        r.result_id = signature_refusal_result_id("req-1", "PC1");
342        r.exit_code = 1;
343        assert!(!r.is_signature_refusal());
344    }
345
346    #[test]
347    fn exec_result_round_trips_through_json() {
348        let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
349        let t1 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 5).unwrap();
350        let r = ExecResult {
351            result_id: "result-uuid-1".into(),
352            request_id: "req-1".into(),
353            exec_id: Some("exec-uuid-1".into()),
354            parent_result_id: None,
355            pc_id: "pc-01".into(),
356            exit_code: 0,
357            skipped: Some(false),
358            stdout: "hello\n".into(),
359            stderr: String::new(),
360            started_at: t0,
361            finished_at: t1,
362            stdout_object: None,
363            stderr_object: None,
364            manifest_id: Some("inventory-hw".into()),
365            collect_object: None,
366        };
367        let json = serde_json::to_string(&r).unwrap();
368        let back: ExecResult = serde_json::from_str(&json).unwrap();
369        assert_eq!(back.result_id, r.result_id);
370        assert_eq!(back.request_id, r.request_id);
371        assert_eq!(back.exec_id.as_deref(), Some("exec-uuid-1"));
372        assert_eq!(back.exit_code, r.exit_code);
373        assert_eq!(back.stdout, r.stdout);
374        assert_eq!(back.started_at, t0);
375        assert_eq!(back.finished_at, t1);
376        assert_eq!(back.manifest_id.as_deref(), Some("inventory-hw"));
377    }
378
379    #[test]
380    fn exec_result_without_manifest_id_decodes() {
381        // Older agents (pre-0.13) sent ExecResult with no manifest_id field.
382        let json = r#"{
383            "request_id":"r","pc_id":"x","exit_code":0,
384            "stdout":"","stderr":"",
385            "started_at":"2026-05-16T00:00:00Z",
386            "finished_at":"2026-05-16T00:00:00Z"
387        }"#;
388        let r: ExecResult = serde_json::from_str(json).unwrap();
389        assert_eq!(r.manifest_id, None);
390    }
391
392    #[test]
393    fn exec_result_without_result_id_decodes_empty() {
394        // v0.29 / Issue #19: pre-v0.29 agents don't send `result_id`.
395        // `#[serde(default)]` decodes it as the empty string so the
396        // projector can detect "legacy payload" and call
397        // `stable_result_id()` to derive a deterministic PK.
398        let json = r#"{
399            "request_id":"r","pc_id":"x","exit_code":0,
400            "stdout":"","stderr":"",
401            "started_at":"2026-05-16T00:00:00Z",
402            "finished_at":"2026-05-16T00:00:00Z"
403        }"#;
404        let r: ExecResult = serde_json::from_str(json).unwrap();
405        assert_eq!(r.result_id, "");
406        assert!(r.exec_id.is_none());
407    }
408
409    #[test]
410    fn stable_result_id_is_deterministic_for_legacy_payload() {
411        // Gemini #65 medium fix: legacy redeliveries (same request_id +
412        // pc_id) must hash to the SAME result_id so the projector's
413        // ON CONFLICT(result_id) DO NOTHING dedupes — otherwise
414        // `executions.success_count` double-counts on JetStream ack
415        // timeouts.
416        let json = r#"{
417            "request_id":"r","pc_id":"x","exit_code":0,
418            "stdout":"","stderr":"",
419            "started_at":"2026-05-16T00:00:00Z",
420            "finished_at":"2026-05-16T00:00:00Z"
421        }"#;
422        let a: ExecResult = serde_json::from_str(json).unwrap();
423        let b: ExecResult = serde_json::from_str(json).unwrap();
424        assert_eq!(
425            a.stable_result_id(),
426            b.stable_result_id(),
427            "same legacy payload must hash to the same result_id",
428        );
429    }
430
431    #[test]
432    fn stable_result_id_differs_across_pcs_for_broadcast() {
433        // The other half: a broadcast Command published to two PCs
434        // produces two legacy ExecResults sharing one request_id but
435        // with different pc_ids. Each must get its OWN result_id so
436        // both rows persist (the whole point of Issue #19).
437        let json_a = r#"{
438            "request_id":"shared","pc_id":"pc-1","exit_code":0,
439            "stdout":"","stderr":"",
440            "started_at":"2026-05-16T00:00:00Z",
441            "finished_at":"2026-05-16T00:00:00Z"
442        }"#;
443        let json_b = r#"{
444            "request_id":"shared","pc_id":"pc-2","exit_code":0,
445            "stdout":"","stderr":"",
446            "started_at":"2026-05-16T00:00:00Z",
447            "finished_at":"2026-05-16T00:00:00Z"
448        }"#;
449        let a: ExecResult = serde_json::from_str(json_a).unwrap();
450        let b: ExecResult = serde_json::from_str(json_b).unwrap();
451        assert_ne!(
452            a.stable_result_id(),
453            b.stable_result_id(),
454            "different pc_id must produce a different result_id",
455        );
456    }
457
458    #[test]
459    fn stable_result_id_passes_through_explicit_value() {
460        // v0.29 agents always supply result_id; the helper must
461        // return that as-is (no surprise re-hashing).
462        let r = ExecResult {
463            result_id: "agent-minted-uuid".into(),
464            request_id: "r".into(),
465            exec_id: None,
466            parent_result_id: None,
467            pc_id: "x".into(),
468            exit_code: 0,
469            skipped: Some(false),
470            stdout: String::new(),
471            stderr: String::new(),
472            started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
473            finished_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
474            stdout_object: None,
475            stderr_object: None,
476            manifest_id: None,
477            collect_object: None,
478        };
479        assert_eq!(r.stable_result_id(), "agent-minted-uuid");
480    }
481
482    #[test]
483    fn exec_result_collect_object_round_trips_and_omits_when_absent() {
484        // #219: collect_object is off the wire when None
485        // (skip_serializing_if) so pre-#219 readers stay compatible...
486        let t0 = chrono::Utc.with_ymd_and_hms(2026, 6, 15, 0, 0, 0).unwrap();
487        let mut r = ExecResult {
488            result_id: "r1".into(),
489            request_id: "req".into(),
490            exec_id: None,
491            parent_result_id: None,
492            pc_id: "PC1".into(),
493            exit_code: 0,
494            skipped: Some(false),
495            stdout: String::new(),
496            stderr: String::new(),
497            started_at: t0,
498            finished_at: t0,
499            stdout_object: None,
500            stderr_object: None,
501            manifest_id: Some("collect-diagnostics".into()),
502            collect_object: None,
503        };
504        let json = serde_json::to_string(&r).unwrap();
505        assert!(
506            !json.contains("collect_object"),
507            "collect_object must be absent when None: {json}"
508        );
509        // ...and a set key survives the round-trip.
510        r.collect_object = Some("PC1/collect-diagnostics/20260615T000000Z.zip".into());
511        let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
512        assert_eq!(
513            back.collect_object.as_deref(),
514            Some("PC1/collect-diagnostics/20260615T000000Z.zip"),
515        );
516    }
517
518    #[test]
519    fn exec_result_parent_result_id_round_trips_and_omits_when_absent() {
520        // #955: a finalize row carries `parent_result_id`; ordinary runs
521        // leave it None, and it stays off the wire so pre-#955 readers
522        // are unaffected (skip_serializing_if).
523        let t0 = chrono::Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
524        let mut r = ExecResult {
525            result_id: "fin-1".into(),
526            request_id: "req__finalize".into(),
527            exec_id: None,
528            parent_result_id: None,
529            pc_id: "PC1".into(),
530            exit_code: 0,
531            skipped: Some(false),
532            stdout: String::new(),
533            stderr: String::new(),
534            started_at: t0,
535            finished_at: t0,
536            stdout_object: None,
537            stderr_object: None,
538            manifest_id: Some("screenshot-collect__finalize".into()),
539            collect_object: None,
540        };
541        let json = serde_json::to_string(&r).unwrap();
542        assert!(
543            !json.contains("parent_result_id"),
544            "parent_result_id must be absent when None: {json}"
545        );
546        r.parent_result_id = Some("parent-run-uuid".into());
547        let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
548        assert_eq!(back.parent_result_id.as_deref(), Some("parent-run-uuid"));
549    }
550}