Skip to main content

brink_runtime/
transcript.rs

1//! Transcript binary serialization (`.brkt` format).
2//!
3//! A transcript is a serialized `Vec<OutputPart>` — the append-only log of
4//! all output parts produced during story execution. Combined with an `.inkb`
5//! program and optional `.inkl` locale data, a transcript can be re-rendered
6//! in any language without re-executing the story.
7//!
8//! ## Binary format
9//!
10//! ```text
11//! Header (16 bytes):
12//!   b"BRKT"           magic (4)
13//!   u16 LE            version = 1 (2)
14//!   u16 LE            reserved (2)
15//!   u32 LE            source_checksum (4)
16//!   u32 LE            content CRC-32 (4)
17//!
18//! Body:
19//!   u32 LE            top-level part count
20//!   [Part]*           encoded top-level parts
21//!
22//!   u32 LE            fragment count
23//!   ( u32 LE          this fragment's part count
24//!     [Part]*          encoded fragment parts
25//!   )*
26//!
27//!   ( u32 LE          this fragment's tag count      -- #953, trailing section
28//!     [str]*           tags, in fragment order        (see below)
29//!   )*
30//! ```
31//!
32//! Both "part count" fields above count only **persisted** parts —
33//! `OutputPart::Checkpoint` is a transient capture marker that is filtered
34//! out before encoding (see [`is_persisted`]) and contributes zero bytes to
35//! `[Part]*`, so the count must exclude it too or a reader following this
36//! doc to extend the format would write `parts.len()` and produce a byte
37//! stream whose declared count disagrees with what it actually encoded.
38//! `OutputPart::ElementAttach`/`ElementAttachEnd` (issue #2108) are the same
39//! kind of transient, zero-byte marker for the identical reason —
40//! deliberately in-memory-only; see that variant's own doc.
41//!
42//! The fragment section and the trailing fragment-tags section are both
43//! **backward-compat optional**: `read_transcript` treats "no bytes left"
44//! at either boundary as "this section is absent", not as truncated input,
45//! and falls back to an empty `Vec` (zero fragments, or every fragment's
46//! `tags: Vec::new()`) rather than erroring. This lets a `.brkt` written
47//! before a section existed keep decoding under a newer reader.
48//!
49//! The fragment-tags section is written as a distinct trailing section
50//! *after* every fragment's parts — one `(tag count, [str]*)` block per
51//! fragment, in the same order the fragments themselves were written —
52//! rather than inlined into each fragment's own record. An inline layout
53//! could not tell "this fragment has a tags section" apart from "the next
54//! fragment's part bytes happen to start here" once a `.brkt` written
55//! before tags existed was read by tags-aware code; the trailing-section
56//! layout sidesteps that ambiguity by using the same "any bytes left?"
57//! probe already used for the fragment section itself. Fixes #953:
58//! `Fragment::tags` was silently dropped by this codec. See
59//! `write_transcript`/`read_transcript` below for the code-level version of
60//! this note.
61//!
62//! A third trailing section (as the `.inkb` v6 bump is expected to add) is
63//! **not yet safe** to bolt on the same way: see
64//! `docs/brkt-trailing-section-findings.md` for a traced report of exactly
65//! what breaks and why, written as input to #1519's design pass.
66
67use alloc::string::String;
68use alloc::sync::Arc;
69use alloc::vec::Vec;
70
71use brink_format::{DefinitionId, LineFlags, MAX_DECODE_DEPTH, MapKey, NameId, OrderedMap, Value};
72
73use crate::output::{OutputPart, resolve_lines};
74use crate::program::Program;
75
76// ── Format constants ──────────────────────────────────────────────────────
77
78const MAGIC: &[u8; 4] = b"BRKT";
79const VERSION: u16 = 1;
80const HEADER_SIZE: usize = 16;
81
82// Part tags
83const TAG_TEXT: u8 = 0x01;
84const TAG_LINE_REF: u8 = 0x02;
85const TAG_VALUE_REF: u8 = 0x03;
86const TAG_NEWLINE: u8 = 0x04;
87const TAG_SPRING: u8 = 0x05;
88const TAG_GLUE: u8 = 0x06;
89const TAG_TAG: u8 = 0x07;
90
91// Value tags (matching inkb encoding)
92const VAL_INT: u8 = 0x00;
93const VAL_FLOAT: u8 = 0x01;
94const VAL_BOOL: u8 = 0x02;
95const VAL_STRING: u8 = 0x03;
96const VAL_LIST: u8 = 0x04;
97const VAL_DIVERT_TARGET: u8 = 0x05;
98const VAL_NULL: u8 = 0x06;
99// Shared numeric surface with the `.inkb` format (`inkb::VAL_VAR_POINTER`). A
100// `VariablePointer` is preserved distinctly (T1c, #700) so a `ref`-bound
101// closure env entry round-trips losslessly — the old code collapsed it to
102// `VAL_DIVERT_TARGET`, which is fine for a bare pointer (display-identical) but
103// would corrupt a captured `ref` cell inside a persisted function value.
104const VAL_VAR_POINTER: u8 = 0x07;
105const VAL_FRAGMENT_REF: u8 = 0x08;
106// v4 collection tags — shared numeric surface with the `.inkb` format
107// (`docs/format-v4-rfc.md` §1). Reachable today via `Opcode::EmitValue`, which
108// pushes any popped stack value (including a binding/external return that since
109// #525 can be a collection) into `OutputPart::ValueRef`.
110const VAL_ARRAY: u8 = 0x09;
111const VAL_MAP: u8 = 0x0A;
112// T1c function-value tags — shared numeric surface with the `.inkb` format
113// (`docs/format-v4-rfc.md` §1). A function value can ride the append-only
114// transcript / journal / speculation snapshots as an ordinary value (spec §6:
115// "function values save like every other value"), e.g. via `Opcode::EmitValue`
116// or a saved binding.
117const VAL_FN_REF: u8 = 0x0B;
118const VAL_CLOSURE: u8 = 0x0C;
119// T1d handle tag — shared numeric surface with the `.inkb` format
120// (`docs/format-v4-rfc.md` §1: `kind NameId, u64 id`). A handle received
121// from a binding rides the append-only transcript / journal / speculation
122// snapshots as an ordinary value (`docs/t1d-spec.md` §2: "handles appear in
123// saves, journals, and speculation snapshots as ordinary values"), e.g. via
124// `Opcode::EmitValue` or a saved binding.
125const VAL_HANDLE: u8 = 0x0D;
126// TM-4 record tag — shared numeric surface with the `.inkb` format
127// (`docs/format-v4-rfc.md` §1: `ShapeId`, then field values in shape order).
128const VAL_RECORD: u8 = 0x0F;
129// T1e projection tag — shared numeric surface with the `.inkb` format
130// (`docs/format-v4-rfc.md` §1: cell reference, u8 segment count, segments).
131// A projection rides the append-only transcript / journal / speculation
132// snapshots as an ordinary value (`docs/t1e-spec.md` §3: "Saves/journal/
133// speculation: ordinary values"), e.g. via `Opcode::EmitValue`.
134const VAL_PROJECTION: u8 = 0x0E;
135/// NS-A1 Option value tag (`docs/stdlib-spec.md` §1.4): flag byte (0 =
136/// `none`, 1 = `some`), then the inner value when `some` — mirrors the
137/// `.inkb` `VAL_OPTION` wire form exactly, like every tag above.
138const VAL_OPTION: u8 = 0x10;
139/// NS-A5 range value tag (`docs/stdlib-spec.md` §7, F7): start i32, end
140/// i32, one flag byte (0 = `..` exclusive, 1 = `..=` inclusive) — mirrors
141/// the `.inkb` `VAL_RANGE` wire form exactly. Flat: never recurses, no
142/// depth accounting (a range holds two ints, never another value).
143const VAL_RANGE: u8 = 0x11;
144// NS-A8 tower value tags (`docs/tower-mini-spec.md` T5): explicit
145// little-endian f32 lanes — vec/quat `x, y(, z, w)`, matrices column-major
146// column-by-column — mirroring the `.inkb` wire form exactly, like every
147// tag above. NEVER glam's memory layout: lanes go through glam's explicit
148// `to_array`/`from_array`/`to_cols_array`/`from_cols_array` conversions.
149const VAL_VEC2: u8 = 0x12;
150const VAL_VEC3: u8 = 0x13;
151const VAL_VEC4: u8 = 0x14;
152const VAL_QUAT: u8 = 0x15;
153const VAL_MAT2: u8 = 0x16;
154const VAL_MAT3: u8 = 0x17;
155const VAL_MAT4: u8 = 0x18;
156/// NS-A7 weighted tables (`docs/stdlib-spec.md` §8): mirrors the `.inkb`
157/// `VAL_WEIGHTED` wire form exactly — u32 entry count, then per entry an
158/// i32 weight and a recursively-encoded value. Depth-counted like the
159/// collection tags; the reader enforces the evidence-by-construction
160/// invariant (non-empty, weights ≥ 1).
161const VAL_WEIGHTED: u8 = 0x19;
162const PROJ_SEG_INDEX: u8 = 0x00;
163const PROJ_SEG_KEY: u8 = 0x01;
164
165// ── Error type ────────────────────────────────────────────────────────────
166
167/// Errors from transcript serialization/deserialization.
168#[derive(Debug, thiserror::Error)]
169pub enum TranscriptError {
170    #[error("invalid magic: expected BRKT")]
171    InvalidMagic,
172    #[error("unsupported version: {0}")]
173    UnsupportedVersion(u16),
174    #[error("checksum mismatch: transcript {transcript:#010x} != program {program:#010x}")]
175    ChecksumMismatch { transcript: u32, program: u32 },
176    #[error("integrity check failed: content CRC-32 mismatch")]
177    IntegrityCheckFailed,
178    #[error("unexpected end of data")]
179    UnexpectedEof,
180    #[error("invalid part tag: {0:#04x}")]
181    InvalidPartTag(u8),
182    #[error("invalid value tag: {0:#04x}")]
183    InvalidValueTag(u8),
184    #[error("invalid UTF-8")]
185    InvalidUtf8,
186    #[error("invalid definition ID")]
187    InvalidDefinitionId,
188    #[error("value nesting exceeded max decode depth ({0})")]
189    MaxDepthExceeded(usize),
190    /// A `VAL_MAP` entry list carried the same key twice. A legitimate
191    /// writer never emits this — `OrderedMap::insert` de-duplicates on the
192    /// write side — so a repeated key is a corrupt or crafted `.brkt`; the
193    /// content-based `OrderedMap` `Eq` (issue #909) assumes each key appears
194    /// once, so this is rejected rather than silently keeping the last
195    /// occurrence (issue #985).
196    #[error("duplicate key in map value")]
197    DuplicateMapKey,
198}
199
200// ── Write ─────────────────────────────────────────────────────────────────
201
202/// Serialize a transcript to the `.brkt` binary format.
203///
204/// Checkpoint parts are filtered out (they are transient capture markers
205/// that should never appear in a persisted transcript).
206#[expect(clippy::cast_possible_truncation)]
207pub fn write_transcript(
208    parts: &[OutputPart],
209    source_checksum: u32,
210    fragments: &crate::output::Fragments,
211) -> Vec<u8> {
212    let mut body = Vec::new();
213
214    // Count non-Checkpoint parts
215    let count = parts.iter().filter(|p| is_persisted(p)).count() as u32;
216    write_u32(&mut body, count);
217
218    for part in parts {
219        encode_part(part, &mut body);
220    }
221
222    // Serialize fragments
223    write_u32(&mut body, fragments.len() as u32);
224    for fragment in fragments.iter() {
225        let filtered_count = fragment.parts.iter().filter(|p| is_persisted(p)).count() as u32;
226        write_u32(&mut body, filtered_count);
227        for part in fragment.parts {
228            encode_part(part, &mut body);
229        }
230    }
231
232    // Serialize fragment tags, appended as a trailing section *after* every
233    // fragment's parts (rather than inline per-fragment) so that a `.brkt`
234    // file written before this section existed remains readable: the reader
235    // detects the section's absence via a plain "any bytes left?" check (the
236    // same backward-compat idiom already used for the fragment section
237    // itself, above) and falls back to empty tags, instead of misreading a
238    // later fragment's part bytes as an earlier fragment's tag bytes (which
239    // an inline per-fragment layout could not distinguish after the fact).
240    // Fixes #953: `Fragment::tags` was silently dropped by this codec.
241    for fragment in fragments.iter() {
242        write_u32(&mut body, fragment.tags.len() as u32);
243        for tag in fragment.tags {
244            write_str(&mut body, tag);
245        }
246    }
247
248    // Build header
249    let content_crc = crc32(&body);
250    let mut buf = Vec::with_capacity(HEADER_SIZE + body.len());
251    buf.extend_from_slice(MAGIC);
252    write_u16(&mut buf, VERSION);
253    write_u16(&mut buf, 0); // reserved
254    write_u32(&mut buf, source_checksum);
255    write_u32(&mut buf, content_crc);
256    buf.extend(body);
257    buf
258}
259
260// ── Read ──────────────────────────────────────────────────────────────────
261
262/// A decoded transcript: the output parts, the source program's checksum
263/// (to verify compatibility before rendering), and the captured fragments
264/// (for re-rendering choice display text and computed substrings).
265///
266/// The caller should validate `source_checksum` against the program's
267/// checksum (via [`Program::source_checksum`](crate::Program::source_checksum))
268/// before passing `parts` to [`render_transcript`].
269#[derive(Debug, Clone)]
270pub struct TranscriptData {
271    pub parts: Vec<OutputPart>,
272    pub source_checksum: u32,
273    pub fragments: crate::output::Fragments,
274}
275
276/// Deserialize a transcript from the `.brkt` binary format.
277pub fn read_transcript(bytes: &[u8]) -> Result<TranscriptData, TranscriptError> {
278    if bytes.len() < HEADER_SIZE {
279        return Err(TranscriptError::UnexpectedEof);
280    }
281
282    // Validate header
283    if &bytes[0..4] != MAGIC {
284        return Err(TranscriptError::InvalidMagic);
285    }
286    let mut off = 4;
287    let version = read_u16(bytes, &mut off)?;
288    if version != VERSION {
289        return Err(TranscriptError::UnsupportedVersion(version));
290    }
291    let _reserved = read_u16(bytes, &mut off)?;
292    let source_checksum = read_u32(bytes, &mut off)?;
293    let expected_crc = read_u32(bytes, &mut off)?;
294
295    // Validate body integrity
296    let body = &bytes[HEADER_SIZE..];
297    if crc32(body) != expected_crc {
298        return Err(TranscriptError::IntegrityCheckFailed);
299    }
300
301    // Decode parts
302    let mut off = HEADER_SIZE;
303    let count = read_u32(bytes, &mut off)? as usize;
304    let mut parts = Vec::with_capacity(count);
305
306    for _ in 0..count {
307        parts.push(decode_part(bytes, &mut off)?);
308    }
309
310    // Deserialize fragments
311    let fragment_count = if off < bytes.len() {
312        read_u32(bytes, &mut off)? as usize
313    } else {
314        0 // backward compat: old transcripts without fragments
315    };
316    let mut fragments = Vec::with_capacity(fragment_count);
317    for _ in 0..fragment_count {
318        let frag_part_count = read_u32(bytes, &mut off)? as usize;
319        let mut frag_parts = Vec::with_capacity(frag_part_count);
320        for _ in 0..frag_part_count {
321            frag_parts.push(decode_part(bytes, &mut off)?);
322        }
323        fragments.push(crate::output::Fragment {
324            parts: frag_parts,
325            tags: Vec::new(),
326        });
327    }
328
329    // Fragment tags (fixes #953): a trailing section written after every
330    // fragment's parts (see `write_transcript`'s matching comment). Older
331    // transcripts written before this section existed end exactly at the
332    // fragment section, so `off == bytes.len()` there and every fragment
333    // keeps the empty `tags` it was constructed with above — the same
334    // observable (if buggy) behavior those files always had, preserved for
335    // backward compatibility rather than erroring on legacy saves.
336    if off < bytes.len() {
337        for fragment in &mut fragments {
338            let tag_count = read_u32(bytes, &mut off)? as usize;
339            let mut tags = Vec::with_capacity(tag_count.min(bytes.len().saturating_sub(off)));
340            for _ in 0..tag_count {
341                tags.push(read_str(bytes, &mut off)?);
342            }
343            fragment.tags = tags;
344        }
345    }
346
347    Ok(TranscriptData {
348        parts,
349        source_checksum,
350        fragments: crate::output::Fragments::from(fragments),
351    })
352}
353
354// ── Render ────────────────────────────────────────────────────────────────
355
356/// Re-render a transcript against the given line tables.
357///
358/// Applies glue resolution, Spring spacing, and line trimming — the same
359/// pipeline as `flush_lines` — producing `(text, tags)` tuples per line.
360pub fn render_transcript(
361    parts: &[OutputPart],
362    program: &Program,
363    line_tables: &[Vec<brink_format::LineEntry>],
364    resolver: Option<&dyn brink_format::PluralResolver>,
365    fragments: &crate::output::Fragments,
366) -> Vec<(String, Vec<String>)> {
367    // Element-attachment data (issue #2108) is dropped here — moot in
368    // practice, since `OutputPart::ElementAttach`/`ElementAttachEnd` are not
369    // persisted (`is_persisted`, below), so a `.brkt`-sourced `parts` slice
370    // never contains any to begin with. This function's public contract
371    // (`(text, tags)`) stays unchanged either way.
372    resolve_lines(parts, program, line_tables, resolver, fragments)
373        .into_iter()
374        .map(|(text, tags, _element, _source)| (text, tags))
375        .collect()
376}
377
378/// Like [`render_transcript`], but keeps each line's provenance — the first
379/// `LineRef`'s line-table `source_location` (the same rule the live
380/// delivery stream uses, W7/#3300). The studio's re-render road (RULED
381/// 2026-08-30, "Studio saves carry the structural transcript") needs the
382/// provenance chips to survive a restore, not just the text.
383pub fn render_transcript_with_source(
384    parts: &[OutputPart],
385    program: &Program,
386    line_tables: &[Vec<brink_format::LineEntry>],
387    resolver: Option<&dyn brink_format::PluralResolver>,
388    fragments: &crate::output::Fragments,
389) -> Vec<(String, Vec<String>, Option<brink_format::SourceLocation>)> {
390    resolve_lines(parts, program, line_tables, resolver, fragments)
391        .into_iter()
392        .map(|(text, tags, _element, source)| (text, tags, source))
393        .collect()
394}
395
396// ── Part codec ────────────────────────────────────────────────────────────
397//
398// One shared encode/decode pair for `OutputPart`, used by both the
399// top-level part list and each fragment's part list in `write_transcript`/
400// `read_transcript`. Before this, the two call sites hand-duplicated the
401// same match arms; #953 was exactly that duplication silently dropping
402// `Fragment::tags` because the two loops drifted. Any new `OutputPart`
403// variant that always writes bytes needs exactly one new arm here, reached
404// from both loops. A new *transient* (zero-byte) variant — like
405// `OutputPart::Checkpoint` — additionally needs its own arm added to
406// `is_persisted` below, which both part-count filters share; see that
407// function's doc comment.
408
409/// Returns whether `part` is written to the persisted `.brkt` format.
410///
411/// `OutputPart::Checkpoint` is the one transient capture marker that is
412/// filtered out (it writes zero bytes in [`encode_part`]). This predicate is
413/// shared by both of `write_transcript`'s part-count computations (the
414/// top-level count and each fragment's `filtered_count`) so they cannot
415/// drift from each other or from `encode_part`'s zero-byte arm. Any future
416/// transient variant must be added here *and* to `encode_part`'s zero-byte
417/// arm in lockstep — otherwise the written count disagrees with the emitted
418/// bytes and `read_transcript` misreads the part list.
419fn is_persisted(part: &OutputPart) -> bool {
420    !matches!(
421        part,
422        OutputPart::Checkpoint | OutputPart::ElementAttach(..) | OutputPart::ElementAttachEnd
423    )
424}
425
426/// Encode a single [`OutputPart`] (its tag byte plus payload) onto `buf`.
427/// `OutputPart::Checkpoint` writes nothing — it is a transient capture
428/// marker filtered out of the persisted `.brkt` format by the caller's part
429/// count (see `write_transcript` and [`is_persisted`]).
430#[expect(clippy::cast_possible_truncation)]
431fn encode_part(part: &OutputPart, buf: &mut Vec<u8>) {
432    match part {
433        OutputPart::Text(s) => {
434            write_u8(buf, TAG_TEXT);
435            write_str(buf, s);
436        }
437        OutputPart::LineRef {
438            container_idx,
439            line_idx,
440            slots,
441            flags,
442        } => {
443            write_u8(buf, TAG_LINE_REF);
444            write_u32(buf, *container_idx);
445            write_u16(buf, *line_idx);
446            write_u8(buf, flags.bits());
447            write_u16(buf, slots.len() as u16);
448            for val in slots {
449                encode_value(val, buf);
450            }
451        }
452        OutputPart::ValueRef(val) => {
453            write_u8(buf, TAG_VALUE_REF);
454            encode_value(val, buf);
455        }
456        OutputPart::Newline => write_u8(buf, TAG_NEWLINE),
457        OutputPart::Spring => write_u8(buf, TAG_SPRING),
458        OutputPart::Glue => write_u8(buf, TAG_GLUE),
459        OutputPart::Tag(s) => {
460            write_u8(buf, TAG_TAG);
461            write_str(buf, s);
462        }
463        // `Checkpoint` is filtered out (transient capture marker).
464        // `ElementAttach`/`ElementAttachEnd` (issue #2108) are the same kind
465        // of transient, in-memory-only marker — see `is_persisted`'s doc
466        // and `OutputPart::ElementAttach`'s own doc for why they never
467        // reach the `.brkt` wire format either.
468        OutputPart::Checkpoint | OutputPart::ElementAttach(..) | OutputPart::ElementAttachEnd => {}
469    }
470}
471
472/// Decode a single [`OutputPart`] (its tag byte plus payload) from `bytes`
473/// at `*off`, advancing `*off` past it. The counterpart of [`encode_part`].
474fn decode_part(bytes: &[u8], off: &mut usize) -> Result<OutputPart, TranscriptError> {
475    let tag = read_u8(bytes, off)?;
476    let part = match tag {
477        TAG_TEXT => OutputPart::Text(read_str(bytes, off)?),
478        TAG_LINE_REF => {
479            let container_idx = read_u32(bytes, off)?;
480            let line_idx = read_u16(bytes, off)?;
481            let flags_bits = read_u8(bytes, off)?;
482            let flags = LineFlags::from_bits_truncate(flags_bits);
483            let slot_count = read_u16(bytes, off)? as usize;
484            let mut slots = Vec::with_capacity(slot_count);
485            for _ in 0..slot_count {
486                slots.push(decode_value(bytes, off, 0)?);
487            }
488            OutputPart::LineRef {
489                container_idx,
490                line_idx,
491                slots,
492                flags,
493            }
494        }
495        TAG_VALUE_REF => OutputPart::ValueRef(decode_value(bytes, off, 0)?),
496        TAG_NEWLINE => OutputPart::Newline,
497        TAG_SPRING => OutputPart::Spring,
498        TAG_GLUE => OutputPart::Glue,
499        TAG_TAG => OutputPart::Tag(read_str(bytes, off)?),
500        _ => return Err(TranscriptError::InvalidPartTag(tag)),
501    };
502    Ok(part)
503}
504
505// ── Codec helpers (self-contained, no dependency on brink-format internals) ──
506
507fn write_u8(buf: &mut Vec<u8>, v: u8) {
508    buf.push(v);
509}
510
511fn write_u16(buf: &mut Vec<u8>, v: u16) {
512    buf.extend_from_slice(&v.to_le_bytes());
513}
514
515fn write_u32(buf: &mut Vec<u8>, v: u32) {
516    buf.extend_from_slice(&v.to_le_bytes());
517}
518
519fn write_u64(buf: &mut Vec<u8>, v: u64) {
520    buf.extend_from_slice(&v.to_le_bytes());
521}
522
523fn write_i32(buf: &mut Vec<u8>, v: i32) {
524    buf.extend_from_slice(&v.to_le_bytes());
525}
526
527#[expect(clippy::cast_possible_truncation)]
528fn write_str(buf: &mut Vec<u8>, s: &str) {
529    write_u32(buf, s.len() as u32);
530    buf.extend_from_slice(s.as_bytes());
531}
532
533fn write_def_id(buf: &mut Vec<u8>, id: DefinitionId) {
534    write_u64(buf, id.to_raw());
535}
536
537fn read_u8(buf: &[u8], off: &mut usize) -> Result<u8, TranscriptError> {
538    if *off >= buf.len() {
539        return Err(TranscriptError::UnexpectedEof);
540    }
541    let v = buf[*off];
542    *off += 1;
543    Ok(v)
544}
545
546fn read_u16(buf: &[u8], off: &mut usize) -> Result<u16, TranscriptError> {
547    if *off + 2 > buf.len() {
548        return Err(TranscriptError::UnexpectedEof);
549    }
550    let v = u16::from_le_bytes([buf[*off], buf[*off + 1]]);
551    *off += 2;
552    Ok(v)
553}
554
555fn read_u32(buf: &[u8], off: &mut usize) -> Result<u32, TranscriptError> {
556    if *off + 4 > buf.len() {
557        return Err(TranscriptError::UnexpectedEof);
558    }
559    let v = u32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
560    *off += 4;
561    Ok(v)
562}
563
564fn read_i32(buf: &[u8], off: &mut usize) -> Result<i32, TranscriptError> {
565    if *off + 4 > buf.len() {
566        return Err(TranscriptError::UnexpectedEof);
567    }
568    let v = i32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
569    *off += 4;
570    Ok(v)
571}
572
573fn read_f32(buf: &[u8], off: &mut usize) -> Result<f32, TranscriptError> {
574    if *off + 4 > buf.len() {
575        return Err(TranscriptError::UnexpectedEof);
576    }
577    let v = f32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
578    *off += 4;
579    Ok(v)
580}
581
582fn read_u64(buf: &[u8], off: &mut usize) -> Result<u64, TranscriptError> {
583    if *off + 8 > buf.len() {
584        return Err(TranscriptError::UnexpectedEof);
585    }
586    let v = u64::from_le_bytes([
587        buf[*off],
588        buf[*off + 1],
589        buf[*off + 2],
590        buf[*off + 3],
591        buf[*off + 4],
592        buf[*off + 5],
593        buf[*off + 6],
594        buf[*off + 7],
595    ]);
596    *off += 8;
597    Ok(v)
598}
599
600fn read_str(buf: &[u8], off: &mut usize) -> Result<String, TranscriptError> {
601    let len = read_u32(buf, off)? as usize;
602    if *off + len > buf.len() {
603        return Err(TranscriptError::UnexpectedEof);
604    }
605    let bytes = &buf[*off..*off + len];
606    *off += len;
607    String::from_utf8(bytes.to_vec()).map_err(|_| TranscriptError::InvalidUtf8)
608}
609
610fn read_def_id(buf: &[u8], off: &mut usize) -> Result<DefinitionId, TranscriptError> {
611    let raw = read_u64(buf, off)?;
612    DefinitionId::from_raw(raw).ok_or(TranscriptError::InvalidDefinitionId)
613}
614
615// ── Value encoding ────────────────────────────────────────────────────────
616
617#[expect(clippy::cast_possible_truncation)]
618#[expect(
619    clippy::too_many_lines,
620    reason = "one match arm per value tag — T1e's VAL_PROJECTION arm pushed this past 100"
621)]
622fn encode_value(v: &Value, buf: &mut Vec<u8>) {
623    match v {
624        Value::Int(n) => {
625            write_u8(buf, VAL_INT);
626            write_i32(buf, *n);
627        }
628        Value::Float(n) => {
629            write_u8(buf, VAL_FLOAT);
630            buf.extend_from_slice(&n.to_le_bytes());
631        }
632        Value::Bool(b) => {
633            write_u8(buf, VAL_BOOL);
634            write_u8(buf, u8::from(*b));
635        }
636        Value::String(s) => {
637            write_u8(buf, VAL_STRING);
638            write_str(buf, s);
639        }
640        Value::List(lv) => {
641            write_u8(buf, VAL_LIST);
642            write_u32(buf, lv.items.len() as u32);
643            for item in &lv.items {
644                write_def_id(buf, *item);
645            }
646            write_u32(buf, lv.origins.len() as u32);
647            for origin in &lv.origins {
648                write_def_id(buf, *origin);
649            }
650        }
651        Value::DivertTarget(id) => {
652            write_u8(buf, VAL_DIVERT_TARGET);
653            write_def_id(buf, *id);
654        }
655        Value::VariablePointer(id) => {
656            write_u8(buf, VAL_VAR_POINTER);
657            write_def_id(buf, *id);
658        }
659        Value::FragmentRef(idx) => {
660            write_u8(buf, VAL_FRAGMENT_REF);
661            write_u32(buf, *idx);
662        }
663        // TempPointer is runtime-only.
664        Value::TempPointer { .. } | Value::Null => {
665            write_u8(buf, VAL_NULL);
666        }
667        // Collections encode as trees (v4, `docs/format-v4-rfc.md` §1): a length
668        // prefix then the recursively-encoded elements / key-value pairs. Arc
669        // sharing is not preserved on the wire (value-model-spec §5).
670        Value::Array(items) => {
671            write_u8(buf, VAL_ARRAY);
672            write_u32(buf, items.len() as u32);
673            for item in items.iter() {
674                encode_value(item, buf);
675            }
676        }
677        Value::Map(map) => {
678            write_u8(buf, VAL_MAP);
679            write_u32(buf, map.len() as u32);
680            for (key, val) in map.iter() {
681                encode_map_key(key, buf);
682                encode_value(val, buf);
683            }
684        }
685        Value::Record { shape, fields } => {
686            write_u8(buf, VAL_RECORD);
687            write_u32(buf, shape.0);
688            write_u32(buf, fields.len() as u32);
689            for field in fields.iter() {
690                encode_value(field, buf);
691            }
692        }
693        // Function values (T1c, spec §6): save like every other value. `FnRef`
694        // is the fn token; `Closure` adds a u32-counted env of `{NameId, kind
695        // u8, value}` entries — the named/moded env the rehydration check reads.
696        Value::FnRef(target) => {
697            write_u8(buf, VAL_FN_REF);
698            write_def_id(buf, *target);
699        }
700        Value::Closure(c) => {
701            write_u8(buf, VAL_CLOSURE);
702            write_def_id(buf, c.target);
703            write_u32(buf, c.env.len() as u32);
704            for entry in &c.env {
705                write_u16(buf, entry.name.0);
706                write_u8(buf, u8::from(entry.is_ref));
707                encode_value(&entry.payload, buf);
708            }
709        }
710        // Handle values (T1d, spec §5: "the journal records returned tokens";
711        // §2: "handles appear in saves, journals, and speculation snapshots
712        // as ordinary values"). Token equality holds at this level; rebinding
713        // to a live resource happens at the host boundary, not here.
714        Value::Handle { kind, id } => {
715            write_u8(buf, VAL_HANDLE);
716            write_u16(buf, kind.0);
717            write_u64(buf, *id);
718        }
719        // Projection values (T1e, spec §3: "Saves/journal/speculation:
720        // ordinary values"). Segment kind `2=range` is RESERVED and never
721        // written — `ProjSegment` has no variant to produce it.
722        Value::Projection(p) => {
723            write_u8(buf, VAL_PROJECTION);
724            write_def_id(buf, p.cell);
725            write_u8(buf, p.segments.len() as u8);
726            for seg in &p.segments {
727                match seg {
728                    brink_format::ProjSegment::Index(n) => {
729                        write_u8(buf, PROJ_SEG_INDEX);
730                        write_i32(buf, *n);
731                    }
732                    brink_format::ProjSegment::Key(v) => {
733                        write_u8(buf, PROJ_SEG_KEY);
734                        encode_value(v, buf);
735                    }
736                }
737            }
738        }
739        // Option values (NS-A1, `docs/stdlib-spec.md` §1.4): an Option in a
740        // global/frame slot journals as an ordinary value, same as every
741        // variant above.
742        Value::OptionVal(inner) => {
743            write_u8(buf, VAL_OPTION);
744            match inner {
745                None => write_u8(buf, 0),
746                Some(v) => {
747                    write_u8(buf, 1);
748                    encode_value(v, buf);
749                }
750            }
751        }
752        // Range values (NS-A5, F7): a range in a global/frame slot journals
753        // as an ordinary value — this is exactly the FlowFrame iterator-
754        // spill durability the F7 ruling demanded (`for i in 0..n` across
755        // an `await` parks its snapshot range in the frame record). The
756        // written form is preserved.
757        Value::Range {
758            start,
759            end,
760            inclusive,
761        } => {
762            write_u8(buf, VAL_RANGE);
763            write_i32(buf, *start);
764            write_i32(buf, *end);
765            write_u8(buf, u8::from(*inclusive));
766        }
767        // Tower values (NS-A8, `docs/tower-mini-spec.md` T5): explicit
768        // little-endian f32 lanes in the pinned order, via glam's explicit
769        // array conversions — mirrors the `.inkb` wire form exactly.
770        Value::Vec2(v) => {
771            write_u8(buf, VAL_VEC2);
772            write_f32_lanes(buf, &v.to_array());
773        }
774        Value::Vec3(v) => {
775            write_u8(buf, VAL_VEC3);
776            write_f32_lanes(buf, &v.to_array());
777        }
778        Value::Vec4(v) => {
779            write_u8(buf, VAL_VEC4);
780            write_f32_lanes(buf, &v.to_array());
781        }
782        Value::Quat(q) => {
783            write_u8(buf, VAL_QUAT);
784            write_f32_lanes(buf, &q.to_array());
785        }
786        Value::Mat2(m) => {
787            write_u8(buf, VAL_MAT2);
788            write_f32_lanes(buf, &m.to_cols_array());
789        }
790        Value::Mat3(m) => {
791            write_u8(buf, VAL_MAT3);
792            write_f32_lanes(buf, &m.to_cols_array());
793        }
794        Value::Mat4(m) => {
795            write_u8(buf, VAL_MAT4);
796            write_f32_lanes(buf, &m.to_cols_array());
797        }
798        Value::Weighted(w) => {
799            write_u8(buf, VAL_WEIGHTED);
800            write_u32(buf, w.entries.len() as u32);
801            for (weight, value) in &w.entries {
802                write_i32(buf, *weight);
803                encode_value(value, buf);
804            }
805        }
806    }
807}
808
809/// NS-A8 (`docs/tower-mini-spec.md` T5): write tower lanes as explicit
810/// little-endian f32s, one by one — the hand-serialized tower wire form
811/// (same helper shape as the `.inkb` writer's).
812fn write_f32_lanes(buf: &mut Vec<u8>, lanes: &[f32]) {
813    for lane in lanes {
814        buf.extend_from_slice(&lane.to_le_bytes());
815    }
816}
817
818/// NS-A8 (`docs/tower-mini-spec.md` T5): read `N` explicit little-endian
819/// f32 lanes; the caller rebuilds the glam value through its explicit
820/// `from_array`/`from_cols_array` constructor.
821fn read_f32_lanes<const N: usize>(
822    buf: &[u8],
823    off: &mut usize,
824) -> Result<[f32; N], TranscriptError> {
825    let mut lanes = [0.0f32; N];
826    for lane in &mut lanes {
827        *lane = read_f32(buf, off)?;
828    }
829    Ok(lanes)
830}
831
832/// Encode a [`MapKey`] using the scalar `VAL_*` tag surface (`int`/`string`/
833/// `bool` — the v1 key domain). Self-describing so the reader rejects a
834/// non-scalar key tag.
835fn encode_map_key(key: &MapKey, buf: &mut Vec<u8>) {
836    match key {
837        MapKey::Int(n) => {
838            write_u8(buf, VAL_INT);
839            write_i32(buf, *n);
840        }
841        MapKey::Str(s) => {
842            write_u8(buf, VAL_STRING);
843            write_str(buf, s);
844        }
845        MapKey::Bool(b) => {
846            write_u8(buf, VAL_BOOL);
847            write_u8(buf, u8::from(*b));
848        }
849    }
850}
851
852#[expect(
853    clippy::too_many_lines,
854    reason = "one match arm per value tag — T1e's VAL_PROJECTION arm pushed this past 100"
855)]
856fn decode_value(buf: &[u8], off: &mut usize, depth: usize) -> Result<Value, TranscriptError> {
857    if depth > MAX_DECODE_DEPTH {
858        return Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH));
859    }
860    let tag = read_u8(buf, off)?;
861    match tag {
862        VAL_INT => Ok(Value::Int(read_i32(buf, off)?)),
863        VAL_FLOAT => Ok(Value::Float(read_f32(buf, off)?)),
864        VAL_BOOL => {
865            let b = read_u8(buf, off)?;
866            Ok(Value::Bool(b != 0))
867        }
868        VAL_STRING => {
869            let s = read_str(buf, off)?;
870            Ok(Value::String(Arc::from(s.as_str())))
871        }
872        VAL_LIST => {
873            let item_count = read_u32(buf, off)? as usize;
874            let mut items = Vec::with_capacity(item_count);
875            for _ in 0..item_count {
876                items.push(read_def_id(buf, off)?);
877            }
878            let origin_count = read_u32(buf, off)? as usize;
879            let mut origins = Vec::with_capacity(origin_count);
880            for _ in 0..origin_count {
881                origins.push(read_def_id(buf, off)?);
882            }
883            Ok(Value::List(Arc::new(brink_format::ListValue {
884                items,
885                origins,
886            })))
887        }
888        VAL_DIVERT_TARGET => {
889            let id = read_def_id(buf, off)?;
890            Ok(Value::DivertTarget(id))
891        }
892        VAL_VAR_POINTER => {
893            let id = read_def_id(buf, off)?;
894            Ok(Value::VariablePointer(id))
895        }
896        VAL_FRAGMENT_REF => Ok(Value::FragmentRef(read_u32(buf, off)?)),
897        VAL_NULL => Ok(Value::Null),
898        VAL_ARRAY => {
899            let len = read_u32(buf, off)? as usize;
900            let mut items = Vec::with_capacity(len.min(buf.len().saturating_sub(*off)));
901            for _ in 0..len {
902                items.push(decode_value(buf, off, depth + 1)?);
903            }
904            Ok(Value::array(items))
905        }
906        VAL_MAP => {
907            let len = read_u32(buf, off)? as usize;
908            let mut map = OrderedMap::with_capacity(len.min(buf.len().saturating_sub(*off)));
909            for _ in 0..len {
910                let key = decode_map_key(buf, off)?;
911                let val = decode_value(buf, off, depth + 1)?;
912                // A repeated key would violate the content-based `OrderedMap`
913                // `Eq` (#909); reject rather than silently keeping the last
914                // occurrence (#985).
915                if map.contains_key(&key) {
916                    return Err(TranscriptError::DuplicateMapKey);
917                }
918                map.insert(key, val);
919            }
920            Ok(Value::map(map))
921        }
922        VAL_RECORD => {
923            let shape = brink_format::ShapeId(read_u32(buf, off)?);
924            let len = read_u32(buf, off)? as usize;
925            let mut fields = Vec::with_capacity(len.min(buf.len().saturating_sub(*off)));
926            for _ in 0..len {
927                fields.push(decode_value(buf, off, depth + 1)?);
928            }
929            Ok(Value::record(shape, fields))
930        }
931        VAL_FN_REF => Ok(Value::FnRef(read_def_id(buf, off)?)),
932        VAL_CLOSURE => {
933            let target = read_def_id(buf, off)?;
934            let count = read_u32(buf, off)? as usize;
935            let mut env = Vec::with_capacity(count.min(buf.len().saturating_sub(*off)));
936            for _ in 0..count {
937                let name = brink_format::NameId(read_u16(buf, off)?);
938                let is_ref = read_u8(buf, off)? != 0;
939                let payload = decode_value(buf, off, depth + 1)?;
940                env.push(brink_format::ClosureEnvEntry {
941                    name,
942                    is_ref,
943                    payload,
944                });
945            }
946            Ok(Value::closure(target, env))
947        }
948        // Handle values (T1d, `docs/format-v4-rfc.md` §1).
949        VAL_HANDLE => {
950            let kind = NameId(read_u16(buf, off)?);
951            let id = read_u64(buf, off)?;
952            Ok(Value::handle(kind, id))
953        }
954        // Projection values (T1e, `docs/format-v4-rfc.md` §1).
955        VAL_PROJECTION => {
956            let cell = read_def_id(buf, off)?;
957            let count = read_u8(buf, off)? as usize;
958            let mut segments = Vec::with_capacity(count.min(buf.len().saturating_sub(*off)));
959            for _ in 0..count {
960                let kind = read_u8(buf, off)?;
961                let seg = match kind {
962                    PROJ_SEG_INDEX => brink_format::ProjSegment::Index(read_i32(buf, off)?),
963                    PROJ_SEG_KEY => {
964                        brink_format::ProjSegment::Key(decode_value(buf, off, depth + 1)?)
965                    }
966                    other => return Err(TranscriptError::InvalidValueTag(other)),
967                };
968                segments.push(seg);
969            }
970            Ok(Value::projection(cell, segments))
971        }
972        // Option values (NS-A1): flag byte then inner-when-some; any other
973        // flag byte is corrupt input. Depth-counted like the collections.
974        VAL_OPTION => match read_u8(buf, off)? {
975            0 => Ok(Value::none()),
976            1 => Ok(Value::some(decode_value(buf, off, depth + 1)?)),
977            other => Err(TranscriptError::InvalidValueTag(other)),
978        },
979        // Range values (NS-A5, F7): flat — start, end, incl/excl flag; any
980        // other flag byte is corrupt input. No recursion, no depth.
981        VAL_RANGE => {
982            let start = read_i32(buf, off)?;
983            let end = read_i32(buf, off)?;
984            let inclusive = match read_u8(buf, off)? {
985                0 => false,
986                1 => true,
987                other => return Err(TranscriptError::InvalidValueTag(other)),
988            };
989            Ok(Value::range(start, end, inclusive))
990        }
991        // Tower values (NS-A8): fixed-size little-endian f32 lanes in the
992        // pinned order, rebuilt through glam's explicit array constructors.
993        // Leaves — no counts, no recursion, no depth concerns.
994        VAL_VEC2 => Ok(Value::Vec2(glam::Vec2::from_array(read_f32_lanes::<2>(
995            buf, off,
996        )?))),
997        VAL_VEC3 => Ok(Value::Vec3(glam::Vec3::from_array(read_f32_lanes::<3>(
998            buf, off,
999        )?))),
1000        VAL_VEC4 => Ok(Value::Vec4(glam::Vec4::from_array(read_f32_lanes::<4>(
1001            buf, off,
1002        )?))),
1003        VAL_QUAT => Ok(Value::Quat(glam::Quat::from_array(read_f32_lanes::<4>(
1004            buf, off,
1005        )?))),
1006        VAL_MAT2 => Ok(Value::Mat2(glam::Mat2::from_cols_array(&read_f32_lanes::<
1007            4,
1008        >(
1009            buf, off
1010        )?))),
1011        VAL_MAT3 => Ok(Value::Mat3(glam::Mat3::from_cols_array(&read_f32_lanes::<
1012            9,
1013        >(
1014            buf, off
1015        )?))),
1016        VAL_MAT4 => Ok(Value::Mat4(glam::Mat4::from_cols_array(&read_f32_lanes::<
1017            16,
1018        >(
1019            buf, off
1020        )?))),
1021        // Weighted tables (NS-A7): mirror of the `.inkb` reader, invariant
1022        // checks included — a violating payload is corrupt input.
1023        VAL_WEIGHTED => {
1024            let count = read_u32(buf, off)? as usize;
1025            if count == 0 {
1026                return Err(TranscriptError::InvalidValueTag(VAL_WEIGHTED));
1027            }
1028            let mut entries = Vec::with_capacity(count.min(1024));
1029            for _ in 0..count {
1030                let weight = read_i32(buf, off)?;
1031                if weight < 1 {
1032                    return Err(TranscriptError::InvalidValueTag(VAL_WEIGHTED));
1033                }
1034                let value = decode_value(buf, off, depth + 1)?;
1035                entries.push((weight, value));
1036            }
1037            Ok(Value::weighted(entries))
1038        }
1039        _ => Err(TranscriptError::InvalidValueTag(tag)),
1040    }
1041}
1042
1043/// Decode a [`MapKey`] written by `encode_map_key`: a scalar `VAL_*` tag then
1044/// its payload. Any other tag is rejected — only `int`/`string`/`bool` keys are
1045/// permitted (`docs/value-model-spec.md` §4).
1046fn decode_map_key(buf: &[u8], off: &mut usize) -> Result<MapKey, TranscriptError> {
1047    let tag = read_u8(buf, off)?;
1048    match tag {
1049        VAL_INT => Ok(MapKey::Int(read_i32(buf, off)?)),
1050        VAL_STRING => Ok(MapKey::Str(Arc::from(read_str(buf, off)?.as_str()))),
1051        VAL_BOOL => Ok(MapKey::Bool(read_u8(buf, off)? != 0)),
1052        _ => Err(TranscriptError::InvalidValueTag(tag)),
1053    }
1054}
1055
1056// ── CRC-32 ────────────────────────────────────────────────────────────────
1057
1058fn crc32(data: &[u8]) -> u32 {
1059    static TABLE: [u32; 256] = {
1060        let mut table = [0u32; 256];
1061        let mut i = 0u32;
1062        while i < 256 {
1063            let mut crc = i;
1064            let mut j = 0;
1065            while j < 8 {
1066                if crc & 1 != 0 {
1067                    crc = (crc >> 1) ^ 0xEDB8_8320;
1068                } else {
1069                    crc >>= 1;
1070                }
1071                j += 1;
1072            }
1073            table[i as usize] = crc;
1074            i += 1;
1075        }
1076        table
1077    };
1078
1079    let mut crc = 0xFFFF_FFFFu32;
1080    for &byte in data {
1081        let idx = ((crc ^ u32::from(byte)) & 0xFF) as usize;
1082        crc = (crc >> 8) ^ TABLE[idx];
1083    }
1084    crc ^ 0xFFFF_FFFF
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use brink_format::LineFlags;
1091
1092    #[test]
1093    fn round_trip_simple_parts() {
1094        let parts = vec![
1095            OutputPart::Text("Hello".to_string()),
1096            OutputPart::Spring,
1097            OutputPart::Newline,
1098            OutputPart::Tag("tag1".to_string()),
1099            OutputPart::Glue,
1100        ];
1101        let bytes = write_transcript(&parts, 0xDEAD_BEEF, &crate::output::Fragments::default());
1102        let data = read_transcript(&bytes).unwrap();
1103        assert_eq!(data.source_checksum, 0xDEAD_BEEF);
1104        assert_eq!(data.parts.len(), 5);
1105        assert!(matches!(&data.parts[0], OutputPart::Text(s) if s == "Hello"));
1106        assert!(matches!(&data.parts[1], OutputPart::Spring));
1107        assert!(matches!(&data.parts[2], OutputPart::Newline));
1108        assert!(matches!(&data.parts[3], OutputPart::Tag(s) if s == "tag1"));
1109        assert!(matches!(&data.parts[4], OutputPart::Glue));
1110    }
1111
1112    // #1443 review finding: `write_transcript`'s top-level `count` and each
1113    // fragment's `filtered_count` used to hand-duplicate the same
1114    // `!matches!(p, OutputPart::Checkpoint)` predicate. They now both call
1115    // the single shared `is_persisted` helper, which must stay in lockstep
1116    // with `encode_part`'s zero-byte arms: `Checkpoint` and — since issue
1117    // #2108 — `ElementAttach`/`ElementAttachEnd` are transient (zero
1118    // bytes); every other variant persists.
1119    #[test]
1120    fn is_persisted_filters_transient_markers_only() {
1121        assert!(!is_persisted(&OutputPart::Checkpoint));
1122        assert!(!is_persisted(&OutputPart::ElementAttach(
1123            "speaker".to_string(),
1124            "VENDOR".to_string()
1125        )));
1126        assert!(!is_persisted(&OutputPart::ElementAttachEnd));
1127        assert!(is_persisted(&OutputPart::Text("hi".to_string())));
1128        assert!(is_persisted(&OutputPart::LineRef {
1129            container_idx: 0,
1130            line_idx: 0,
1131            slots: Vec::new(),
1132            flags: LineFlags::empty(),
1133        }));
1134        assert!(is_persisted(&OutputPart::ValueRef(Value::Bool(true))));
1135        assert!(is_persisted(&OutputPart::Newline));
1136        assert!(is_persisted(&OutputPart::Spring));
1137        assert!(is_persisted(&OutputPart::Glue));
1138        assert!(is_persisted(&OutputPart::Tag("t".to_string())));
1139    }
1140
1141    // #1443: `write_transcript`/`read_transcript` used to hand-duplicate one
1142    // match arm per `OutputPart` tag for the top-level part list and again
1143    // for each fragment's part list (plus the #953 fix for `Fragment::tags`
1144    // landing in only one of the two copies, which is exactly how that tags
1145    // regression happened). Both loops now call the single shared
1146    // `encode_part`/`decode_part` pair. This pins that: encoding the same
1147    // `OutputPart` sequence through the top-level path and through a
1148    // fragment's path produces byte-identical part payloads, and both paths
1149    // decode back to equal parts — proving one shared codec, not two copies
1150    // that happen to still agree.
1151    #[test]
1152    fn top_level_and_fragment_part_codec_are_byte_identical() {
1153        let parts = vec![
1154            OutputPart::Text("Hello".to_string()),
1155            OutputPart::LineRef {
1156                container_idx: 3,
1157                line_idx: 9,
1158                slots: vec![Value::Int(1), Value::String(Arc::from("hi"))],
1159                flags: LineFlags::ALL_WS,
1160            },
1161            OutputPart::ValueRef(Value::Bool(true)),
1162            OutputPart::Spring,
1163            OutputPart::Newline,
1164            OutputPart::Glue,
1165            OutputPart::Tag("tag1".to_string()),
1166            OutputPart::Checkpoint, // filtered identically by both paths
1167        ];
1168
1169        // Independently reconstruct the expected encoded bytes by calling
1170        // the shared `encode_part` codec directly, one call per non-Checkpoint
1171        // part — this is what both `write_transcript` loops should be
1172        // producing under the hood.
1173        let mut expected = Vec::new();
1174        for part in &parts {
1175            if !matches!(part, OutputPart::Checkpoint) {
1176                encode_part(part, &mut expected);
1177            }
1178        }
1179
1180        // Top-level loop: header, then a u32 part count, then the parts.
1181        let top_level_bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1182        let top_level_part_bytes =
1183            &top_level_bytes[HEADER_SIZE + 4..HEADER_SIZE + 4 + expected.len()];
1184        assert_eq!(
1185            top_level_part_bytes,
1186            expected.as_slice(),
1187            "top-level part encoding must match the shared codec exactly"
1188        );
1189
1190        // Fragment loop: header, u32 top-level count (0), u32 fragment count
1191        // (1), u32 this-fragment's part count, then the same parts.
1192        let fragment = crate::output::Fragment {
1193            parts: parts.clone(),
1194            tags: Vec::new(),
1195        };
1196        let fragment_bytes =
1197            write_transcript(&[], 0, &crate::output::Fragments::from(vec![fragment]));
1198        let frag_start = HEADER_SIZE + 4 + 4 + 4;
1199        let fragment_part_bytes = &fragment_bytes[frag_start..frag_start + expected.len()];
1200        assert_eq!(
1201            fragment_part_bytes,
1202            expected.as_slice(),
1203            "fragment part encoding must match the shared codec exactly"
1204        );
1205
1206        // And decoding both paths yields the same, correct parts.
1207        let top_level_data = read_transcript(&top_level_bytes).unwrap();
1208        let fragment_data = read_transcript(&fragment_bytes).unwrap();
1209        assert_eq!(top_level_data.parts.len(), 7); // Checkpoint filtered
1210        assert_eq!(fragment_data.fragments.len(), 1);
1211        let fragment_parts = fragment_data.fragments.parts(0).unwrap();
1212        assert_eq!(fragment_parts.len(), 7);
1213        assert_eq!(top_level_data.parts, fragment_parts);
1214    }
1215
1216    /// NS-A8 (`docs/tower-mini-spec.md` T5): a tower value in an
1217    /// `OutputPart::ValueRef` crosses the `.brkt` round-trip as explicit
1218    /// little-endian lanes — including a NaN lane, compared here by lane
1219    /// bits (a NaN-bearing vector correctly never compares equal, T4).
1220    #[test]
1221    fn round_trip_value_ref_tower() {
1222        let parts = vec![
1223            OutputPart::ValueRef(Value::Vec3(glam::Vec3::new(1.5, -0.0, 3.0))),
1224            OutputPart::ValueRef(Value::Quat(glam::Quat::from_xyzw(0.5, -0.5, 0.5, 0.5))),
1225            OutputPart::ValueRef(Value::Mat2(glam::Mat2::from_cols_array(&[
1226                1.0, 2.0, 3.0, 4.0,
1227            ]))),
1228            OutputPart::ValueRef(Value::Vec2(glam::Vec2::new(f32::NAN, 7.0))),
1229        ];
1230        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1231        let data = read_transcript(&bytes).unwrap();
1232        assert_eq!(data.parts.len(), 4);
1233        assert!(
1234            matches!(&data.parts[0], OutputPart::ValueRef(v) if *v == Value::Vec3(glam::Vec3::new(1.5, -0.0, 3.0)))
1235        );
1236        assert!(
1237            matches!(&data.parts[1], OutputPart::ValueRef(v) if *v == Value::Quat(glam::Quat::from_xyzw(0.5, -0.5, 0.5, 0.5)))
1238        );
1239        assert!(
1240            matches!(&data.parts[2], OutputPart::ValueRef(v) if *v == Value::Mat2(glam::Mat2::from_cols_array(&[1.0, 2.0, 3.0, 4.0])))
1241        );
1242        let OutputPart::ValueRef(Value::Vec2(v)) = &data.parts[3] else {
1243            unreachable!("expected vec2 part, got {:?}", data.parts[3]);
1244        };
1245        assert_eq!(v.x.to_bits(), f32::NAN.to_bits(), "NaN lane bits drifted");
1246        assert_eq!(v.y.to_bits(), 7.0f32.to_bits());
1247    }
1248
1249    // A collection reaches the transcript through `Opcode::EmitValue`, which
1250    // pops any stack value — including a binding/external return that since #525
1251    // can be an `Array`/`Map` — into `OutputPart::ValueRef`. This locks the v4
1252    // tree encoding of that part: structural equality, insertion order, scalar
1253    // key types, and nesting all survive the `.brkt` round-trip (#526).
1254    #[test]
1255    fn round_trip_value_ref_collections() {
1256        use brink_format::{MapKey, OrderedMap};
1257
1258        let map: OrderedMap = [
1259            (MapKey::from("name"), Value::String(Arc::from("goblin"))),
1260            (
1261                MapKey::from(1),
1262                Value::array(vec![Value::Int(10), Value::Int(20)]),
1263            ),
1264            (MapKey::from(true), Value::Bool(false)),
1265        ]
1266        .into_iter()
1267        .collect();
1268        let array = Value::array(vec![
1269            Value::Int(1),
1270            Value::String(Arc::from("two")),
1271            Value::map(map.clone()),
1272            Value::Null,
1273        ]);
1274
1275        let parts = vec![
1276            OutputPart::ValueRef(array.clone()),
1277            OutputPart::ValueRef(Value::map(map.clone())),
1278        ];
1279        let bytes = write_transcript(&parts, 42, &crate::output::Fragments::default());
1280        let data = read_transcript(&bytes).unwrap();
1281
1282        assert_eq!(data.parts.len(), 2);
1283        match &data.parts[0] {
1284            OutputPart::ValueRef(v) => assert_eq!(*v, array),
1285            other => unreachable!("expected ValueRef(array), got {other:?}"),
1286        }
1287        match &data.parts[1] {
1288            OutputPart::ValueRef(v) => assert_eq!(*v, Value::map(map)),
1289            other => unreachable!("expected ValueRef(map), got {other:?}"),
1290        }
1291    }
1292
1293    // Function values (T1c, #700) persist through the transcript/journal as
1294    // ordinary values (spec §6). This locks the VAL_FN_REF / VAL_CLOSURE
1295    // encoding — the fn token, the bound-env names/modes, and both payload
1296    // shapes (`val` snapshot, `ref` VariablePointer) — across the round-trip.
1297    #[test]
1298    fn round_trip_value_ref_function_values() {
1299        use brink_format::{ClosureEnvEntry, DefinitionId, DefinitionTag, NameId};
1300
1301        let target = DefinitionId::new(DefinitionTag::Address, 7);
1302        let cell = DefinitionId::new(DefinitionTag::Address, 3);
1303        let fn_ref = Value::FnRef(target);
1304        let closure = Value::closure(
1305            target,
1306            vec![
1307                ClosureEnvEntry {
1308                    name: NameId(2),
1309                    is_ref: true,
1310                    payload: Value::VariablePointer(cell),
1311                },
1312                ClosureEnvEntry {
1313                    name: NameId(5),
1314                    is_ref: false,
1315                    payload: Value::Int(41),
1316                },
1317            ],
1318        );
1319
1320        let parts = vec![
1321            OutputPart::ValueRef(fn_ref.clone()),
1322            OutputPart::ValueRef(closure.clone()),
1323        ];
1324        let bytes = write_transcript(&parts, 7, &crate::output::Fragments::default());
1325        let data = read_transcript(&bytes).unwrap();
1326
1327        assert_eq!(data.parts.len(), 2);
1328        match &data.parts[0] {
1329            OutputPart::ValueRef(v) => assert_eq!(*v, fn_ref),
1330            other => unreachable!("expected ValueRef(fn_ref), got {other:?}"),
1331        }
1332        match &data.parts[1] {
1333            OutputPart::ValueRef(v) => assert_eq!(*v, closure),
1334            other => unreachable!("expected ValueRef(closure), got {other:?}"),
1335        }
1336    }
1337
1338    // Handle values (T1d, `docs/t1d-spec.md` §2/§5) persist through the
1339    // transcript/journal codec as ordinary values — "handles appear in
1340    // saves, journals, and speculation snapshots" per the spec. This locks
1341    // the VAL_HANDLE (0x0D) encode/decode arms: a bare handle, one nested
1342    // inside a collection, and the `u64::MAX` id to exercise the full
1343    // write_u64/read_u64 leg (not just small ids that might coincidentally
1344    // round-trip through a truncated path).
1345    #[test]
1346    fn round_trip_value_ref_handle() {
1347        let handle = Value::handle(NameId(9), u64::MAX);
1348        let nested = Value::array(vec![
1349            Value::handle(NameId(3), 0),
1350            Value::String(Arc::from("goblin")),
1351        ]);
1352
1353        let parts = vec![
1354            OutputPart::ValueRef(handle.clone()),
1355            OutputPart::ValueRef(nested.clone()),
1356        ];
1357        let bytes = write_transcript(&parts, 13, &crate::output::Fragments::default());
1358        let data = read_transcript(&bytes).unwrap();
1359
1360        assert_eq!(data.parts.len(), 2);
1361        match &data.parts[0] {
1362            OutputPart::ValueRef(v) => assert_eq!(*v, handle),
1363            other => unreachable!("expected ValueRef(handle), got {other:?}"),
1364        }
1365        match &data.parts[1] {
1366            OutputPart::ValueRef(v) => assert_eq!(*v, nested),
1367            other => unreachable!("expected ValueRef(nested handle), got {other:?}"),
1368        }
1369    }
1370
1371    /// T1e (`docs/t1e-spec.md` §3: "Saves/journal/speculation: ordinary
1372    /// values") — the transcript leg of the per-codec round-trip discipline
1373    /// (inkb/inkt/transcript, the wave-11 lesson): the `VAL_PROJECTION`
1374    /// (0x0E) encode/decode arms, a bare projection and one nested inside a
1375    /// collection, with a mixed index+key segment chain.
1376    #[test]
1377    fn round_trip_value_ref_projection() {
1378        use brink_format::ProjSegment;
1379
1380        let cell = DefinitionId::new(brink_format::DefinitionTag::GlobalVar, 42);
1381        let proj = Value::projection(
1382            cell,
1383            vec![
1384                ProjSegment::Key(Value::String("hp".into())),
1385                ProjSegment::Index(3),
1386            ],
1387        );
1388        let nested = Value::array(vec![Value::projection(cell, vec![]), Value::Bool(true)]);
1389
1390        let parts = vec![
1391            OutputPart::ValueRef(proj.clone()),
1392            OutputPart::ValueRef(nested.clone()),
1393        ];
1394        let bytes = write_transcript(&parts, 13, &crate::output::Fragments::default());
1395        let data = read_transcript(&bytes).unwrap();
1396
1397        assert_eq!(data.parts.len(), 2);
1398        match &data.parts[0] {
1399            OutputPart::ValueRef(v) => assert_eq!(*v, proj),
1400            other => unreachable!("expected ValueRef(projection), got {other:?}"),
1401        }
1402        match &data.parts[1] {
1403            OutputPart::ValueRef(v) => assert_eq!(*v, nested),
1404            other => unreachable!("expected ValueRef(nested projection), got {other:?}"),
1405        }
1406    }
1407
1408    #[test]
1409    fn round_trip_line_ref_with_slots() {
1410        let parts = vec![OutputPart::LineRef {
1411            container_idx: 42,
1412            line_idx: 7,
1413            slots: vec![Value::Int(123), Value::String(Arc::from("hello"))],
1414            flags: LineFlags::ALL_WS | LineFlags::EMPTY,
1415        }];
1416        let bytes = write_transcript(&parts, 1234, &crate::output::Fragments::default());
1417        let data = read_transcript(&bytes).unwrap();
1418        assert_eq!(data.parts.len(), 1);
1419        match &data.parts[0] {
1420            OutputPart::LineRef {
1421                container_idx,
1422                line_idx,
1423                slots,
1424                flags,
1425            } => {
1426                assert_eq!(*container_idx, 42);
1427                assert_eq!(*line_idx, 7);
1428                assert_eq!(slots.len(), 2);
1429                assert!(matches!(&slots[0], Value::Int(123)));
1430                assert!(flags.contains(LineFlags::ALL_WS));
1431                assert!(flags.contains(LineFlags::EMPTY));
1432            }
1433            other => unreachable!("expected LineRef, got {other:?}"),
1434        }
1435    }
1436
1437    #[test]
1438    fn checkpoint_filtered_on_write() {
1439        let parts = vec![
1440            OutputPart::Text("hello".to_string()),
1441            OutputPart::Checkpoint,
1442            OutputPart::Newline,
1443        ];
1444        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1445        let data = read_transcript(&bytes).unwrap();
1446        assert_eq!(data.parts.len(), 2); // Checkpoint filtered
1447        assert!(matches!(&data.parts[0], OutputPart::Text(_)));
1448        assert!(matches!(&data.parts[1], OutputPart::Newline));
1449    }
1450
1451    // ── #953: Fragment::tags round-trip ─────────────────────────────────────
1452    //
1453    // `write_transcript` never serialized `Fragment::tags` and
1454    // `read_transcript` always reconstructed an empty `Vec` — a transcript
1455    // with tagged fragments (live, populated data — see
1456    // `OutputBuffer::push_fragment_tag`) round-tripped to untagged. This
1457    // pins the fix: tags now travel through the `.brkt` codec.
1458    #[test]
1459    fn round_trip_fragment_tags() {
1460        let fragments = vec![
1461            crate::output::Fragment {
1462                parts: vec![OutputPart::Text("hp: 10".to_string())],
1463                tags: vec!["a_tag".to_string(), "b_tag".to_string()],
1464            },
1465            crate::output::Fragment {
1466                parts: vec![OutputPart::Newline],
1467                tags: Vec::new(),
1468            },
1469        ];
1470        let bytes = write_transcript(&[], 0, &crate::output::Fragments::from(fragments.clone()));
1471        let data = read_transcript(&bytes).unwrap();
1472
1473        assert_eq!(data.fragments.len(), 2);
1474        assert_eq!(
1475            data.fragments.tags(0).unwrap(),
1476            vec!["a_tag".to_string(), "b_tag".to_string()]
1477        );
1478        assert_eq!(data.fragments.parts(0).unwrap(), fragments[0].parts);
1479        assert!(data.fragments.tags(1).unwrap().is_empty());
1480    }
1481
1482    // Every `.brkt` file written before this fix has the fragment section
1483    // (fragment_count + per-fragment parts) with NO trailing tag section —
1484    // the reader must keep decoding those files (not error), falling back
1485    // to empty tags per fragment, exactly as it did before this fix. This
1486    // hand-builds that exact pre-fix byte shape rather than relying on the
1487    // current writer (which now always appends the tag section) so the
1488    // legacy shape is pinned even after the writer changes further.
1489    #[test]
1490    fn legacy_transcript_without_tag_section_reads_as_empty_tags() {
1491        let mut body = Vec::new();
1492        write_u32(&mut body, 0); // part count
1493        write_u32(&mut body, 1); // fragment count
1494        write_u32(&mut body, 1); // fragment 0's part count
1495        write_u8(&mut body, TAG_TEXT);
1496        write_str(&mut body, "legacy");
1497        // (no tag section appended — matches the pre-#953 writer)
1498
1499        let content_crc = crc32(&body);
1500        let mut bytes = Vec::with_capacity(HEADER_SIZE + body.len());
1501        bytes.extend_from_slice(MAGIC);
1502        write_u16(&mut bytes, VERSION);
1503        write_u16(&mut bytes, 0);
1504        write_u32(&mut bytes, 0xCAFE_BABE);
1505        write_u32(&mut bytes, content_crc);
1506        bytes.extend(body);
1507
1508        let data = read_transcript(&bytes).expect("legacy transcript must still decode");
1509        assert_eq!(data.fragments.len(), 1);
1510        assert!(
1511            matches!(&data.fragments.parts(0).unwrap()[0], OutputPart::Text(s) if s == "legacy")
1512        );
1513        assert!(data.fragments.tags(0).unwrap().is_empty());
1514    }
1515
1516    // The *other* backward-compat boundary this module's doc claims but only
1517    // `legacy_transcript_without_tag_section_reads_as_empty_tags` above pins:
1518    // a `.brkt` written before the fragment section existed at all (pre-
1519    // fragments feature), where the body ends right after the top-level part
1520    // list — no `fragment_count` `u32`, not even a zero one. `write_transcript`
1521    // has *always* written `fragments.len()` unconditionally (even `0` for an
1522    // empty slice — see the call site right after the part loop), so no call
1523    // through the real writer can ever produce this exact shape; it has to be
1524    // hand-built, same rationale as the tag-section test above. The read-side
1525    // `if off < bytes.len()` probe at the fragment-count read (this module's
1526    // `read_transcript`) is what is actually under test here: with zero bytes
1527    // left after the parts, it must fall back to "no fragments" rather than
1528    // erroring as truncated input.
1529    #[test]
1530    fn legacy_transcript_without_fragment_section_reads_as_no_fragments() {
1531        let mut body = Vec::new();
1532        write_u32(&mut body, 1); // part count
1533        write_u8(&mut body, TAG_TEXT);
1534        write_str(&mut body, "legacy");
1535        // (body ends here — no fragment section, no tag section, matches a
1536        // `.brkt` written before fragments existed at all)
1537
1538        let content_crc = crc32(&body);
1539        let mut bytes = Vec::with_capacity(HEADER_SIZE + body.len());
1540        bytes.extend_from_slice(MAGIC);
1541        write_u16(&mut bytes, VERSION);
1542        write_u16(&mut bytes, 0);
1543        write_u32(&mut bytes, 0xCAFE_BABE);
1544        write_u32(&mut bytes, content_crc);
1545        bytes.extend(body);
1546
1547        let data = read_transcript(&bytes).expect("legacy transcript must still decode");
1548        assert_eq!(data.parts.len(), 1);
1549        assert!(matches!(&data.parts[0], OutputPart::Text(s) if s == "legacy"));
1550        assert!(
1551            data.fragments.is_empty(),
1552            "a pre-fragments `.brkt` must decode with zero fragments, not error: {:?}",
1553            data.fragments
1554        );
1555    }
1556
1557    #[test]
1558    fn invalid_magic_errors() {
1559        let mut bytes = write_transcript(&[], 0, &crate::output::Fragments::default());
1560        bytes[0] = b'X';
1561        assert!(matches!(
1562            read_transcript(&bytes),
1563            Err(TranscriptError::InvalidMagic)
1564        ));
1565    }
1566
1567    #[test]
1568    fn integrity_check_errors() {
1569        let mut bytes = write_transcript(
1570            &[OutputPart::Newline],
1571            0,
1572            &crate::output::Fragments::default(),
1573        );
1574        // Corrupt a body byte
1575        if let Some(last) = bytes.last_mut() {
1576            *last ^= 0xFF;
1577        }
1578        assert!(matches!(
1579            read_transcript(&bytes),
1580            Err(TranscriptError::IntegrityCheckFailed)
1581        ));
1582    }
1583
1584    // ── Recursion-depth cap on VAL_ARRAY/VAL_MAP decode (#553, #561, #562) ──
1585    //
1586    // `decode_value` recurses into itself for VAL_ARRAY/VAL_MAP children with
1587    // no depth limit. A crafted transcript of nested single-element arrays
1588    // (~5 bytes/level) can stack-overflow the reader. These tests hand-build
1589    // a `Value` nested exactly at, and one past,
1590    // `brink_format::MAX_DECODE_DEPTH` (the single canonical definition
1591    // shared by every `decode_value` implementation, #561) and prove the
1592    // reader accepts the former and rejects the latter with a proper decode
1593    // error instead of overflowing the stack. Both the `VAL_ARRAY` recursion
1594    // branch and the parallel `VAL_MAP` branch are exercised at the boundary
1595    // (#562).
1596
1597    /// A `Value` wrapped in `depth` single-element arrays around a scalar
1598    /// leaf, matching the issue's "nested single-element arrays" shape.
1599    fn nested_array(depth: usize) -> Value {
1600        let mut v = Value::Int(42);
1601        for _ in 0..depth {
1602            v = Value::array(vec![v]);
1603        }
1604        v
1605    }
1606
1607    /// A `Value` wrapped in `depth` single-entry maps around a scalar leaf —
1608    /// the `VAL_MAP` analogue of [`nested_array`], exercising the parallel
1609    /// map recursion branch in `decode_value` (#562).
1610    fn nested_map(depth: usize) -> Value {
1611        use brink_format::{MapKey, OrderedMap};
1612
1613        let mut v = Value::Int(42);
1614        for _ in 0..depth {
1615            let mut map = OrderedMap::with_capacity(1);
1616            map.insert(MapKey::Int(0), v);
1617            v = Value::map(map);
1618        }
1619        v
1620    }
1621
1622    #[test]
1623    fn decode_value_accepts_max_depth_nesting() {
1624        // Exactly MAX_DECODE_DEPTH levels of nesting must still decode
1625        // cleanly — the cap must not clip legitimate (if unusual) data.
1626        let value = nested_array(MAX_DECODE_DEPTH);
1627        let parts = vec![OutputPart::ValueRef(value.clone())];
1628        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1629
1630        let data = read_transcript(&bytes).expect("depth exactly at cap must decode");
1631        match &data.parts[0] {
1632            OutputPart::ValueRef(v) => assert_eq!(*v, value),
1633            other => unreachable!("expected ValueRef, got {other:?}"),
1634        }
1635    }
1636
1637    #[test]
1638    fn decode_value_rejects_beyond_max_depth() {
1639        // One level past the cap must be rejected with a proper decode
1640        // error, not a stack overflow.
1641        let value = nested_array(MAX_DECODE_DEPTH + 1);
1642        let parts = vec![OutputPart::ValueRef(value)];
1643        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1644
1645        assert!(matches!(
1646            read_transcript(&bytes),
1647            Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH))
1648        ));
1649    }
1650
1651    #[test]
1652    fn decode_value_rejects_deeply_crafted_nesting() {
1653        // The actual attack scenario the issue describes: a much deeper
1654        // chain than any legitimate story would produce (well beyond the
1655        // cap, but shallow enough that constructing/encoding the fixture
1656        // itself — which has no depth cap by design; only the
1657        // untrusted-input decode path is guarded — doesn't hit unrelated
1658        // recursion limits). The reader must reject it promptly rather than
1659        // recursing hundreds of frames deep.
1660        let value = nested_array(8 * MAX_DECODE_DEPTH);
1661        let parts = vec![OutputPart::ValueRef(value)];
1662        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1663
1664        assert!(matches!(
1665            read_transcript(&bytes),
1666            Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH))
1667        ));
1668    }
1669
1670    // ── #562: parallel VAL_MAP recursion branch at the boundary ────────────
1671
1672    #[test]
1673    fn decode_value_accepts_max_depth_map_nesting() {
1674        // Exactly MAX_DECODE_DEPTH levels of map nesting must still decode
1675        // cleanly — the cap must not clip legitimate (if unusual) data.
1676        let value = nested_map(MAX_DECODE_DEPTH);
1677        let parts = vec![OutputPart::ValueRef(value.clone())];
1678        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1679
1680        let data = read_transcript(&bytes).expect("map depth exactly at cap must decode");
1681        match &data.parts[0] {
1682            OutputPart::ValueRef(v) => assert_eq!(*v, value),
1683            other => unreachable!("expected ValueRef, got {other:?}"),
1684        }
1685    }
1686
1687    #[test]
1688    fn decode_value_rejects_beyond_max_depth_map_nesting() {
1689        // One level past the cap must be rejected with a proper decode
1690        // error, not a stack overflow.
1691        let value = nested_map(MAX_DECODE_DEPTH + 1);
1692        let parts = vec![OutputPart::ValueRef(value)];
1693        let bytes = write_transcript(&parts, 0, &crate::output::Fragments::default());
1694
1695        assert!(matches!(
1696            read_transcript(&bytes),
1697            Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH))
1698        ));
1699    }
1700
1701    // Issue #985 (follow-up to #909): `OrderedMap`'s `Eq` is content-based
1702    // and assumes each key appears at most once. A legitimate `write_transcript`
1703    // never emits a duplicate `VAL_MAP` key — `OrderedMap::insert`
1704    // de-duplicates on the write side, so `encode_value` can't be driven
1705    // into producing one from an in-memory `Value`. This hand-builds the raw
1706    // `VAL_MAP` bytes (the crafted/corrupt-payload scenario the issue
1707    // describes) with the same `int` key twice, proving the reader rejects
1708    // it with a decode error rather than silently keeping the last
1709    // occurrence and handing back an invariant-violating `OrderedMap`.
1710    fn duplicate_int_key_map_body() -> Vec<u8> {
1711        let mut body = Vec::new();
1712        write_u32(&mut body, 1); // part count
1713        write_u8(&mut body, TAG_VALUE_REF);
1714        write_u8(&mut body, VAL_MAP);
1715        write_u32(&mut body, 2); // entry count
1716        write_u8(&mut body, VAL_INT);
1717        write_i32(&mut body, 0);
1718        write_u8(&mut body, VAL_INT);
1719        write_i32(&mut body, 1);
1720        write_u8(&mut body, VAL_INT);
1721        write_i32(&mut body, 0);
1722        write_u8(&mut body, VAL_INT);
1723        write_i32(&mut body, 2);
1724        write_u32(&mut body, 0); // fragment count
1725        body
1726    }
1727
1728    fn wrap_body_as_transcript(body: &[u8]) -> Vec<u8> {
1729        let content_crc = crc32(body);
1730        let mut bytes = Vec::with_capacity(HEADER_SIZE + body.len());
1731        bytes.extend_from_slice(MAGIC);
1732        write_u16(&mut bytes, VERSION);
1733        write_u16(&mut bytes, 0);
1734        write_u32(&mut bytes, 0);
1735        write_u32(&mut bytes, content_crc);
1736        bytes.extend_from_slice(body);
1737        bytes
1738    }
1739
1740    #[test]
1741    fn decode_value_rejects_duplicate_map_key() {
1742        let bytes = wrap_body_as_transcript(&duplicate_int_key_map_body());
1743        assert!(matches!(
1744            read_transcript(&bytes),
1745            Err(TranscriptError::DuplicateMapKey)
1746        ));
1747    }
1748
1749    #[test]
1750    fn decode_value_accepts_distinct_map_keys() {
1751        let mut body = Vec::new();
1752        write_u32(&mut body, 1); // part count
1753        write_u8(&mut body, TAG_VALUE_REF);
1754        write_u8(&mut body, VAL_MAP);
1755        write_u32(&mut body, 2); // entry count
1756        write_u8(&mut body, VAL_INT);
1757        write_i32(&mut body, 0);
1758        write_u8(&mut body, VAL_INT);
1759        write_i32(&mut body, 1);
1760        write_u8(&mut body, VAL_INT);
1761        write_i32(&mut body, 5);
1762        write_u8(&mut body, VAL_INT);
1763        write_i32(&mut body, 2);
1764        write_u32(&mut body, 0); // fragment count
1765
1766        let bytes = wrap_body_as_transcript(&body);
1767        let data = read_transcript(&bytes).expect("distinct keys must decode cleanly");
1768        match &data.parts[0] {
1769            OutputPart::ValueRef(Value::Map(map)) => assert_eq!(map.len(), 2),
1770            other => unreachable!("expected ValueRef(map), got {other:?}"),
1771        }
1772    }
1773}