Skip to main content

supercode_interchange/session/
native.rs

1//! Supercode's own native/sidecar session container and its residue envelopes.
2
3use super::*;
4
5impl Session {
6    /// Serialize to the **supercode-native** lossless format: a header line
7    /// recording the original source, followed by every original JSONL line
8    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
9    /// schema and is necessarily lossy), this preserves *everything* — including
10    /// records with no canonical representation — so [`Self::from_native_str`]
11    /// reconstructs the session with full fidelity.
12    pub fn to_native_jsonl(&self) -> String {
13        let source = match self.meta.source {
14            SessionSource::ClaudeCode => "claude_code",
15            SessionSource::Codex => "codex",
16            SessionSource::Pi => "pi",
17            SessionSource::OpenCode => "opencode",
18            SessionSource::Grok => "grok",
19            SessionSource::Gemini => "gemini",
20            SessionSource::Goose => "goose",
21            SessionSource::OpenClaw => "openclaw",
22            SessionSource::Hermes => "hermes",
23            // P5-3 safety-hardening fix: a natively-spawned session must
24            // never be written to disk labeled as an imported CC session.
25            SessionSource::Native => "native",
26        };
27        let header = serde_json::json!({
28            "supercode_native": 1,
29            "source": source,
30            // IX-1: carries whether the ORIGINAL imported source text ended
31            // with a trailing newline — `from_native_str` needs this to
32            // reconstruct the exact source bytes (not just the `raw` line
33            // list) when re-parsing the body with the per-source loader.
34            "raw_trailing_newline": self.raw_trailing_newline,
35        })
36        .to_string();
37        let mut out =
38            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
39        out.push_str(&header);
40        out.push('\n');
41        for line in &self.raw {
42            out.push_str(line);
43            out.push('\n');
44        }
45        out
46    }
47
48    /// Serialize to the **supercode-native v2** format: the same imported-body
49    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
50    /// followed by every `Session.raw` line verbatim), plus one
51    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
52    /// produced after import, which have no backing `raw` line of their own.
53    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
54    /// serde), so nothing the live agent loop records is lost to disk.
55    ///
56    /// `appended` is caller-supplied rather than inferred from
57    /// `self.messages`: A1 doesn't track which of `self.messages` came from
58    /// import vs. the live loop — that bookkeeping belongs to the live writer
59    /// built on top of this (A2/A3).
60    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
61        self.to_native_jsonl_v2_with_timestamp(appended, None)
62    }
63
64    pub(crate) fn to_native_jsonl_v2_with_timestamp(
65        &self,
66        appended: &[ChatMessage],
67        fixed_timestamp: Option<&str>,
68    ) -> String {
69        let source = match self.meta.source {
70            SessionSource::ClaudeCode => "claude_code",
71            SessionSource::Codex => "codex",
72            SessionSource::Pi => "pi",
73            SessionSource::OpenCode => "opencode",
74            SessionSource::Grok => "grok",
75            SessionSource::Gemini => "gemini",
76            SessionSource::Goose => "goose",
77            SessionSource::OpenClaw => "openclaw",
78            SessionSource::Hermes => "hermes",
79            // P5-3 safety-hardening fix: a natively-spawned session must
80            // never be written to disk labeled as an imported CC session.
81            SessionSource::Native => "native",
82        };
83        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
84        // already parses CC sidechains + CX lineage on import"): a
85        // natively-spawned subagent's own `Session` carries its lineage on
86        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
87        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
88        // all, so a native-spawned child's lineage was lost the instant it
89        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
90        // (`skip_serializing_if`-equivalent via manual omission below) so a
91        // plain top-level session's header is byte-identical to before this
92        // change.
93        let mut header_obj = serde_json::json!({
94            "supercode_native": 2,
95            "source": source,
96            "session_id": self.meta.session_id,
97            "created": fixed_timestamp
98                .map(ToOwned::to_owned)
99                .unwrap_or_else(crate::sidecar::now_rfc3339),
100            // IX-1: see `to_native_jsonl`'s header field of the same name.
101            "raw_trailing_newline": self.raw_trailing_newline,
102        });
103        if let Some(obj) = header_obj.as_object_mut() {
104            if let Some(agent_id) = &self.meta.agent_id {
105                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
106            }
107            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
108                obj.insert(
109                    "parent_tool_use_id".to_string(),
110                    Value::String(parent_tool_use_id.clone()),
111                );
112            }
113            if !self.meta.lineage.is_empty() {
114                obj.insert(
115                    "lineage".to_string(),
116                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
117                );
118            }
119        }
120        let header = header_obj.to_string();
121        let mut out =
122            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
123        out.push_str(&header);
124        out.push('\n');
125        for line in &self.raw {
126            out.push_str(line);
127            out.push('\n');
128        }
129        for (turn_index, msg) in appended.iter().enumerate() {
130            let turn = match fixed_timestamp {
131                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
132                    msg,
133                    timestamp.to_string(),
134                    turn_index as u64,
135                ),
136                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
137                    msg,
138                    crate::sidecar::now_rfc3339(),
139                    turn_index as u64,
140                ),
141            };
142            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
143            out.push('\n');
144        }
145        out
146    }
147
148    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
149    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
150    /// exactly as before. A v2 file's appended `NativeTurn` records —
151    /// discriminated by the `supercode_turn` key, which never appears in a v1
152    /// body — are split out before the imported body is handed to the
153    /// per-source loader, then reattached in file order: to `messages` (via
154    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
155    /// so a v2 file round-trips byte-for-byte through
156    /// [`Self::to_native_jsonl_v2`] again.
157    pub fn from_native_str(jsonl: &str) -> Result<Session> {
158        // IX-1: the native WRAPPER's own lines are split verbatim (not via
159        // the blank-skipping `non_empty_lines`) so that any `raw` line it
160        // carries — which can itself be blank, CRLF-terminated, or
161        // whitespace-padded, now that raw-capture is strict-verbatim —
162        // survives being embedded in (and re-extracted from) this wrapper
163        // bit-for-bit. The wrapper we ourselves emit never has a blank line
164        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
165        // record per line), so this is a behavior-preserving switch for any
166        // native text this crate produced; it also makes a hand-fed/legacy
167        // native string tolerated exactly as `non_empty_lines` used to.
168        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
169        let mut lines = all_lines.into_iter();
170        let header = lines.next().unwrap_or("");
171        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
172        let source = hv.get("source").and_then(Value::as_str);
173        // IX-1: whether the ORIGINAL imported source (before it was wrapped
174        // in this native format) ended with a trailing newline — a property
175        // of the pre-wrap source, not of this wrapper (which always
176        // LF-terminates every line it writes, regardless). Missing on a
177        // native file written before IX-1 (or a hand-built header in an
178        // older test/sidecar) — default `true`, the historical
179        // always-newline-terminated assumption.
180        let raw_trailing_newline = hv
181            .get("raw_trailing_newline")
182            .and_then(Value::as_bool)
183            .unwrap_or(true);
184
185        // Split appended NativeTurn records (v2) out of the imported body. A
186        // v1 body never carries a `supercode_turn` key, so this is a no-op
187        // there — one code path serves both versions.
188        let mut body_lines: Vec<String> = Vec::new();
189        let mut turn_lines: Vec<&str> = Vec::new();
190        for line in lines {
191            let is_turn = serde_json::from_str::<Value>(line)
192                .ok()
193                .is_some_and(|v| v.get("supercode_turn").is_some());
194            if is_turn {
195                turn_lines.push(line);
196            } else {
197                body_lines.push(line.to_string());
198            }
199        }
200        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
201        // `body_lines.join("\n")` alone would silently gain a trailing
202        // newline the original source never had (or lose one it did have).
203        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
204
205        // The remaining lines are the original log; re-parse with the right loader.
206        let mut session = match source {
207            Some("codex") => Self::from_codex_str(&body)?,
208            Some("claude_code") => Self::from_claude_code_str(&body)?,
209            Some("pi") => Self::from_pi_str(&body)?,
210            Some("opencode") => Self::from_opencode_str(&body)?,
211            Some("grok") => Self::from_grok_str(&body)?,
212            Some("gemini") => Self::from_gemini_str(&body)?,
213            Some("goose") => Self::from_goose_str(&body)?,
214            Some("openclaw") => Self::from_openclaw_str(&body)?,
215            // P5-3 safety-hardening fix: a natively-spawned session's body
216            // is always empty (it never had any foreign-tool prefix to
217            // begin with — see `SessionSource::Native`'s doc comment), so
218            // any loader would parse it identically; `from_claude_code_str`
219            // is reused purely as a blank-skeleton builder (empty
220            // `raw`/`messages`), then its `meta.source` is corrected to
221            // `Native` — never left mislabeled as `ClaudeCode`.
222            Some("native") => {
223                let mut s = Self::from_claude_code_str(&body)?;
224                s.meta.source = SessionSource::Native;
225                s
226            }
227            // No/unknown header — auto-detect the body.
228            _ => match detect_source(&body) {
229                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
230                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
231                Some(SessionSource::OpenClaw) => Self::from_openclaw_str(&body)?,
232                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
233                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
234                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
235                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
236                _ => Self::from_claude_code_str(&body)?,
237            },
238        };
239
240        for line in turn_lines {
241            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
242                Ok(turn) => {
243                    session.raw.push(line.to_string());
244                    session.messages.push(turn.into_message());
245                }
246                Err(_) => {
247                    // A valid JSON object carrying the native-turn
248                    // discriminator belongs to this wrapper, not to the
249                    // imported body. If its required fields are malformed,
250                    // count it as parse loss so every fail-loud caller can
251                    // refuse continuation instead of silently dropping a
252                    // native history record. Keep the rejected source line
253                    // in `raw` as well: diagnostics must count it in their
254                    // denominator, and even corrupt input must not disappear
255                    // merely because it reached the parser.
256                    session.raw.push(line.to_string());
257                    session.parse_error_lines += 1;
258                }
259            }
260        }
261
262        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
263        // header block): recover a natively-spawned subagent's own lineage
264        // from the v2 header, when present. Overlays (rather than merges
265        // into) whatever the per-source body loader may have already set on
266        // `session.meta` — these three keys are ONLY ever written by
267        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
268        // header that carries them is authoritative for a file this crate
269        // produced.
270        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
271            session.meta.agent_id = Some(agent_id.to_string());
272        }
273        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
274            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
275        }
276        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
277            for (k, v) in lineage {
278                if let Some(s) = v.as_str() {
279                    session.meta.lineage.insert(k.clone(), s.to_string());
280                }
281            }
282        }
283
284        Ok(session)
285    }
286
287    /// The full-fidelity [`Session`] a sidecar denotes.
288    ///
289    /// The sidecar (native-v2 format, D1) is the imported body plus every
290    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
291    /// tolerant lower-level native parser, this persisted-store entry point
292    /// validates its framing header before loading anything: a missing,
293    /// malformed, or unsupported header must never become a zero-message
294    /// session that callers could continue as if it were complete.
295    pub fn from_sidecar_str(s: &str) -> Result<Session> {
296        let header = s.lines().next().ok_or_else(|| {
297            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
298        })?;
299        let value: Value = serde_json::from_str(header).map_err(|error| {
300            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
301        })?;
302        let version = value.get("supercode_native").and_then(Value::as_u64);
303        if !matches!(version, Some(1 | 2)) {
304            return Err(Error::InvalidSession(
305                "sidecar header must declare supported `supercode_native` version 1 or 2"
306                    .to_string(),
307            ));
308        }
309        let source = value.get("source").and_then(Value::as_str);
310        if !matches!(
311            source,
312            Some(
313                "native"
314                    | "claude_code"
315                    | "codex"
316                    | "gemini"
317                    | "goose"
318                    | "opencode"
319                    | "pi"
320                    | "grok"
321            )
322        ) {
323            return Err(Error::InvalidSession(
324                "sidecar header must declare a supported `source`".to_string(),
325            ));
326        }
327        Self::from_native_str(s)
328    }
329}
330
331pub(super) fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
332    // `str::contains` runs the standard library's substring searcher, which is compiled
333    // optimized even in debug builds; a windowed byte compare here dominated the backward
334    // anchor search over large tool records.
335    if !line.contains("\"user\"") {
336        return false;
337    }
338    let Ok(value) = serde_json::from_str::<Value>(line) else {
339        return false;
340    };
341    match source {
342        Some(SessionSource::Codex) => {
343            value.get("type").and_then(Value::as_str) == Some("response_item")
344                && value
345                    .get("payload")
346                    .and_then(|payload| payload.get("type"))
347                    .and_then(Value::as_str)
348                    == Some("message")
349                && value
350                    .get("payload")
351                    .and_then(|payload| payload.get("role"))
352                    .and_then(Value::as_str)
353                    == Some("user")
354        }
355        Some(SessionSource::ClaudeCode) => {
356            value.get("type").and_then(Value::as_str) == Some("user")
357                && value
358                    .get("message")
359                    .and_then(|message| message.get("content"))
360                    .is_some_and(|content| match content {
361                        Value::String(text) => !text.trim().is_empty(),
362                        Value::Array(parts) => parts.iter().any(|part| {
363                            part.get("type").and_then(Value::as_str) == Some("text")
364                                && part
365                                    .get("text")
366                                    .and_then(Value::as_str)
367                                    .is_some_and(|text| !text.trim().is_empty())
368                        }),
369                        _ => false,
370                    })
371        }
372        Some(SessionSource::Gemini) => {
373            value.get("type").and_then(Value::as_str) == Some("user")
374                && value.get("content").is_some_and(|content| match content {
375                    Value::String(text) => !text.trim().is_empty(),
376                    Value::Array(parts) => parts.iter().any(|part| {
377                        part.get("text")
378                            .and_then(Value::as_str)
379                            .is_some_and(|text| !text.trim().is_empty())
380                    }),
381                    _ => false,
382                })
383        }
384        _ => false,
385    }
386}
387
388pub(super) fn capture_native_residue(
389    meta: &mut SessionMeta,
390    source: &str,
391    record_index: usize,
392    raw_line: &str,
393    record: &Value,
394    kind: &str,
395) {
396    // A record that IS a restored envelope carrier never re-captures itself.
397    if record.get(SUPERCODE_NATIVE_RESIDUE_KEY).is_some()
398        || record.get(SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY).is_some()
399        || record.get(SUPERCODE_CODEX_PROVENANCE_KEY).is_some()
400    {
401        return;
402    }
403    // A transcript that RESTORED a foreign envelope holds residue in transit
404    // back to its own source; local capture never clobbers it (the local
405    // records still survive this format's own raw diagonal).
406    if meta
407        .native_residue_source
408        .as_deref()
409        .is_some_and(|existing| existing != source)
410    {
411        return;
412    }
413    meta.native_residue.push(serde_json::json!({
414        "record_index": record_index,
415        "kind": kind,
416        "raw": raw_line,
417    }));
418    meta.native_residue_source = Some(source.to_string());
419}
420
421/// PARITY-23: the GENERALIZED portable residue key (envelope v2). One key
422/// for every source format; `_supercode_codex_provenance` (v1) stays
423/// readable forever for artifacts written before the generalization.
424pub(super) const SUPERCODE_NATIVE_RESIDUE_KEY: &str = "_supercode_native_residue";
425
426/// PARITY-23 dev/05: the residue TOMBSTONE — a tiny always-embedded summary
427/// (`{version, source, kinds, records, records_sha256}`) that survives when
428/// a caller deliberately strips the heavy records key to shed weight. A
429/// summary without its records makes the deletion DETECTABLE: load succeeds
430/// and reports exactly which source-native metadata can no longer be
431/// restored, instead of the loss being silent.
432pub(super) const SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY: &str = "_supercode_native_residue_summary";
433
434pub(super) fn native_residue_summary(envelope: &Value) -> Value {
435    let records = envelope
436        .get("records")
437        .and_then(Value::as_array)
438        .cloned()
439        .unwrap_or_default();
440    let mut kinds: Vec<String> = records
441        .iter()
442        .filter_map(|entry| entry.get("kind").and_then(Value::as_str))
443        .map(str::to_string)
444        .collect();
445    kinds.sort();
446    kinds.dedup();
447    serde_json::json!({
448        "version": 2,
449        "source": envelope.get("source").cloned().unwrap_or(Value::Null),
450        "kinds": kinds,
451        "records": records.len(),
452        "records_sha256": envelope.get("records_sha256").cloned().unwrap_or(Value::Null),
453    })
454}
455
456/// Canonical-JSON digest over the residue records — the tamper/corruption
457/// binding (PARITY-23 dev/04). Uses the same canonical ordering rules as the
458/// audit's `canonicalJson` so the digest is stable across serializers.
459fn residue_records_sha256(records: &[Value]) -> String {
460    fn canonical(value: &Value, out: &mut String) {
461        match value {
462            Value::Array(items) => {
463                out.push('[');
464                for (index, item) in items.iter().enumerate() {
465                    if index > 0 {
466                        out.push(',');
467                    }
468                    canonical(item, out);
469                }
470                out.push(']');
471            }
472            Value::Object(map) => {
473                out.push('{');
474                let mut keys: Vec<&String> = map.keys().collect();
475                keys.sort();
476                for (index, key) in keys.iter().enumerate() {
477                    if index > 0 {
478                        out.push(',');
479                    }
480                    out.push_str(&serde_json::to_string(key).unwrap_or_default());
481                    out.push(':');
482                    canonical(&map[*key], out);
483                }
484                out.push('}');
485            }
486            other => out.push_str(&other.to_string()),
487        }
488    }
489    let mut text = String::from("[");
490    for (index, record) in records.iter().enumerate() {
491        if index > 0 {
492            text.push(',');
493        }
494        canonical(record, &mut text);
495    }
496    text.push(']');
497    let mut hasher = blake3::Hasher::new();
498    hasher.update(text.as_bytes());
499    hasher.finalize().to_hex().to_string()
500}
501
502/// Envelope v2 for the session's native residue. Today the only capture
503/// store is `meta.codex_provenance` (source `codex`); per-format inventories
504/// land incrementally per the PARITY-23 design doc.
505pub(super) fn native_residue_envelope(meta: &SessionMeta) -> Option<Value> {
506    if !meta.codex_provenance.is_empty() {
507        return Some(serde_json::json!({
508            "version": 2,
509            "source": "codex",
510            "records": &meta.codex_provenance,
511            "records_sha256": residue_records_sha256(&meta.codex_provenance),
512        }));
513    }
514    let source = meta.native_residue_source.as_deref()?;
515    (!meta.native_residue.is_empty()).then(|| {
516        serde_json::json!({
517            "version": 2,
518            "source": source,
519            "records": &meta.native_residue,
520            "records_sha256": residue_records_sha256(&meta.native_residue),
521        })
522    })
523}
524
525/// Restore a v2 residue envelope, digest-verified (PARITY-23 dev/04):
526/// version/source/digest problems FAIL CLOSED with a diagnostic naming what
527/// cannot be restored — never silent fabrication. v1 envelopes delegate to
528/// the original codex restore path unchanged.
529pub(super) fn restore_native_residue(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
530    match extension.get("version").and_then(Value::as_u64) {
531        Some(2) => {}
532        other => {
533            return Err(Error::InvalidSession(format!(
534                "invalid portable native residue: expected version 2, found {other:?} —                  source-native records cannot be restored from this envelope"
535            )));
536        }
537    }
538    let source = extension.get("source").and_then(Value::as_str);
539    if !matches!(source, Some("codex") | Some("claude_code") | Some("grok")) {
540        return Err(Error::InvalidSession(format!(
541            "invalid portable native residue: unsupported source {source:?} —              this build restores codex, claude_code and grok residue; the records are preserved raw but not replayed"
542        )));
543    }
544    let Some(records) = extension.get("records").and_then(Value::as_array) else {
545        return Err(Error::InvalidSession(
546            "invalid portable native residue: `records` must be an array".to_string(),
547        ));
548    };
549    let Some(claimed) = extension.get("records_sha256").and_then(Value::as_str) else {
550        return Err(Error::InvalidSession(
551            "invalid portable native residue: `records_sha256` digest is missing —              cannot verify the residue was not tampered with; refusing to restore"
552                .to_string(),
553        ));
554    };
555    let actual = residue_records_sha256(records);
556    if claimed != actual {
557        return Err(Error::InvalidSession(
558            "invalid portable native residue: records digest mismatch — the residue was              modified or corrupted after export; refusing to restore source-native records"
559                .to_string(),
560        ));
561    }
562    if let Some(source) = source.filter(|source| *source != "codex") {
563        // Digest verified: validate each record kind against the raw line
564        // (the same fail-loud rule the codex path applies).
565        let mut restored = Vec::with_capacity(records.len());
566        for entry in records {
567            let (Some(_), Some(kind), Some(raw)) = (
568                entry.get("record_index").and_then(Value::as_u64),
569                entry.get("kind").and_then(Value::as_str),
570                entry.get("raw").and_then(Value::as_str),
571            ) else {
572                return Err(Error::InvalidSession(
573                    "invalid portable native residue: each record needs record_index/kind/raw"
574                        .to_string(),
575                ));
576            };
577            let Ok(record) = serde_json::from_str::<Value>(raw) else {
578                return Err(Error::InvalidSession(
579                    "invalid portable native residue: raw is not valid JSON".to_string(),
580                ));
581            };
582            let matches = match source {
583                "claude_code" => claude_residue_kind(&record) == Some(kind),
584                "grok" => grok_residue_kind(&record).as_deref() == Some(kind),
585                _ => unreachable!("source whitelist checked above"),
586            };
587            if !matches {
588                return Err(Error::InvalidSession(format!(
589                    "invalid portable native residue: kind `{kind}` does not match raw record"
590                )));
591            }
592            restored.push(entry.clone());
593        }
594        if restored.is_empty() {
595            return Err(Error::InvalidSession(
596                "invalid portable native residue: `records` must not be empty".to_string(),
597            ));
598        }
599        meta.native_residue = restored;
600        meta.native_residue_source = Some(source.to_string());
601        return Ok(true);
602    }
603    // Digest verified: the inner record validation and meta rebuild are the
604    // v1 rules exactly.
605    restore_codex_provenance(&serde_json::json!({"version": 1, "records": records}), meta)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
614        let base = Session::from_native_messages(Vec::new());
615        let mut native = base.to_native_jsonl_v2(&[]);
616        native.push_str("{\"supercode_turn\":1}\n");
617
618        let parsed = Session::from_native_str(&native).unwrap();
619        assert_eq!(parsed.parse_error_lines, 1);
620        assert!(parsed.messages.is_empty());
621        assert_eq!(
622            parsed.raw.last().map(String::as_str),
623            Some("{\"supercode_turn\":1}")
624        );
625    }
626
627    #[test]
628    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
629        let imported = Session::from_claude_code_str(
630            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
631        )
632        .unwrap();
633        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
634        native.push_str("{\"supercode_turn\":1}\n");
635
636        let parsed = Session::from_native_str(&native).unwrap();
637        let error = parsed
638            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
639            .unwrap_err();
640        assert!(error.to_string().contains("parse loss"), "{error}");
641    }
642
643    #[test]
644    fn sidecar_loader_requires_a_supported_native_header() {
645        for malformed in [
646            "",
647            "not-json\n",
648            "{}\n",
649            "{\"supercode_native\":2}\n",
650            "{\"supercode_native\":99,\"source\":\"native\"}\n",
651        ] {
652            let error = Session::from_sidecar_str(malformed).unwrap_err();
653            assert!(error.to_string().contains("sidecar header"), "{error}");
654        }
655    }
656}