Rindle docs and package mapSkip to main content

rindle/
journal_frame.rs

1//! The cross-process **journal frame envelope** — the typed header in front of every
2//! opaque change payload (`designs-implemented/211-HCTREE-LEADER-CDC-DESIGN.md` §4.1).
3//!
4//! Today's `_rindle_change_log` carries `kind`, `run_id`, `run_rows`, `rows_cum`, and
5//! `committed_at` as **columns**, and the plane reads them as columns: the epoch fence
6//! is a `run_id` point lookup at the cursor, C_grace retention reads `committed_at`,
7//! and the lag-shed fold groups on `kind` and accounts with the run totals. Under 211
8//! the master's log is `hct_journal(cid, query, snapshot, logptr)` — no home for those
9//! columns — so they move into this header, encoded in front of the payload bytes
10//! handed to `sqlite3_hct_journal_leader_commit`. The same envelope is what the S3
11//! shipping plane (`rindle-backup`, `designs-implemented/212-JOURNAL-S3-SHIPPING-DESIGN.md` §2.2)
12//! frames into archival segments, so one decode serves the sender, the shipper, and
13//! restore replay.
14//!
15//! Deliberately **not** in the header:
16//!
17//! - **cid** — assigned by the engine *inside* `leader_commit`, so the producer cannot
18//!   know it at encode time. The container carries it (the journal's `cid` column; the
19//!   segment framing). Keeping it out also means there is exactly one source of truth.
20//! - **chunk_seq** — one HCTree cid is still one atomic transaction, but its opaque payload may
21//!   use the versioned chunk container documented at
22//!   `encode_payload_chunks`. Chunk sequence is
23//!   implicit in container order, so it does not belong in the transaction header.
24//!
25//! Wire layout (all integers little-endian):
26//!
27//! ```text
28//! u8   header format version (1 = legacy, 2 = chunk container — see the constants)
29//! u8   kind (0 = rows, 1 = ddl, 2 = empty; 3 reserved — see [`FrameKind`])
30//! u8   flags (bit 0: run_id present, bit 1: run totals present; others reserved)
31//! i64  committed_at — commit wall clock, unix millis, stamped by the capture path
32//!      (identical across a run, like today's column); 0 = unknown (empty frames)
33//! [flag bit 0]  u8 run_id byte-length, then that many UTF-8 bytes
34//! [flag bit 1]  u64 run_rows, u64 rows_cum — precomputed in the capture path (the
35//!      in-txn `stamp_run_totals` UPDATE is impossible once the payload is fixed at
36//!      `leader_commit` time, 211 §4.1)
37//! …    payload — version-dependent:
38//!        v1: ONE opaque legacy blob (in practice a UTF-8 JSON `RowChange[]` array)
39//!        v2: the versioned ordered-chunk container below (absent for Empty frames)
40//! ```
41//!
42//! The version byte is **per frame**, not per container: repack re-encodes old frames
43//! to the current version (212 §2.2's aging-out policy), and a per-frame stamp is what
44//! lets one segment legally carry mixed vintages in between.
45
46/// The LEGACY envelope version: the payload is one opaque blob (in practice a UTF-8 JSON
47/// array). Current producers never write it; decode accepts it forever — the archival
48/// compatibility policy is "apply engine reads all versions ≥ N" (212 §2.2), so a
49/// reader older than a frame must error, never guess. (This policy is exactly why the
50/// chunk container gets its own frame version: embedded under v1, a pre-container
51/// reader would misreport it as JSON corruption instead of a clean version skew.)
52pub(crate) const FRAME_FORMAT_V1: u8 = 1;
53
54/// The CURRENT envelope version: the payload IS the versioned ordered-chunk container
55/// ([`encode_payload_chunks`]) — mandatory, so a corrupted container fails closed as a
56/// container decode error instead of being misread as a legacy payload — or absent for
57/// [`FrameKind::Empty`] frames. Written by [`FrameHeader::encode_frame`].
58pub(crate) const FRAME_FORMAT_V2: u8 = 2;
59
60/// The non-UTF-8 sentinel reserving the versioned payload-container namespace. Legacy journal
61/// payloads are UTF-8 JSON arrays, so they can never collide with this prefix.
62const PAYLOAD_CONTAINER_SENTINEL: u8 = 0x89;
63const PAYLOAD_CONTAINER_MAGIC: &[u8; 4] = b"RJP\0";
64const PAYLOAD_CONTAINER_V1: u8 = 1;
65const PAYLOAD_CONTAINER_HEADER_LEN: usize = 1 + PAYLOAD_CONTAINER_MAGIC.len() + 1 + 4;
66
67/// Ceiling on the chunks in one container, enforced on BOTH sides. Real producers emit at
68/// most a handful (a 64 MiB transaction over an 8 MiB chunk bound), never a fraction of this;
69/// the cap exists so a corrupted or hostile chunk count — which decode otherwise has to trust
70/// for a `Vec` reservation of one fat pointer per counted chunk — cannot amplify one frame
71/// into an unbounded allocation. 65,536 chunks is ≈ a 1 MiB pointer table at most.
72pub(crate) const MAX_CONTAINER_CHUNKS: usize = 65_536;
73
74/// Encode ordered opaque chunks into one HCTree journal payload without flattening their
75/// boundaries. The enclosing [`FrameHeader`] remains the atomic transaction/cid; this inner
76/// container only restores the old `(offset, chunk_seq, last)` replay grain.
77///
78/// Wire layout (container v1, little-endian):
79///
80/// ```text
81/// u8   0x89 (not valid at the start of a UTF-8 JSON payload)
82/// u8[4] "RJP\\0"
83/// u8   container version (= 1)
84/// u32  chunk count (non-zero, at most [`MAX_CONTAINER_CHUNKS`])
85/// repeated chunk count times:
86///   u32 chunk byte length (non-zero)
87///   u8[] chunk bytes
88/// ```
89///
90/// Chunk contents are deliberately opaque here. Rindle currently puts compact JSON
91/// `RowChange[]` arrays in them because that is the follower wire codec, but HCTree itself does
92/// not require JSON. A zero-length chunk is malformed on both sides: no producer emits one, and
93/// admitting them at decode would let a corrupt count region masquerade as millions of chunks.
94// The allocating wrapper around `encode_payload_chunks_into`, which is what the
95// frame writer actually calls. Closing this module proved nothing uses it; kept
96// because four intra-doc links name it as `decode_payload_chunks`'s counterpart.
97#[allow(dead_code)]
98pub(crate) fn encode_payload_chunks(chunks: &[&[u8]]) -> Result<Vec<u8>, PayloadEncodeError> {
99    let mut out = Vec::with_capacity(payload_container_len(chunks)?);
100    encode_payload_chunks_into(chunks, &mut out).map(|()| out)
101}
102
103/// The exact encoded length of the container for `chunks` (validating the same bounds as the
104/// encoder, so an `encode_*` caller can size one buffer exactly).
105fn payload_container_len(chunks: &[&[u8]]) -> Result<usize, PayloadEncodeError> {
106    if chunks.is_empty() {
107        return Err(PayloadEncodeError::Empty);
108    }
109    if chunks.len() > MAX_CONTAINER_CHUNKS {
110        return Err(PayloadEncodeError::TooManyChunks(chunks.len()));
111    }
112    let mut len = PAYLOAD_CONTAINER_HEADER_LEN;
113    for chunk in chunks {
114        len = payload_container_len_step(len, chunk.len())?;
115    }
116    Ok(len)
117}
118
119/// Validate and add one `u32 length || bytes` entry to an in-progress container length.
120/// Kept separate from allocation so the overflow boundary can be model-checked over the
121/// complete `usize` domain.
122fn payload_container_len_step(
123    current: usize,
124    chunk_len: usize,
125) -> Result<usize, PayloadEncodeError> {
126    if chunk_len == 0 {
127        return Err(PayloadEncodeError::EmptyChunk);
128    }
129    u32::try_from(chunk_len).map_err(|_| PayloadEncodeError::ChunkTooLarge(chunk_len))?;
130    current
131        .checked_add(4)
132        .and_then(|n| n.checked_add(chunk_len))
133        .ok_or(PayloadEncodeError::ContainerTooLarge)
134}
135
136/// Append the validated container to `out` (callers size `out` via [`payload_container_len`]).
137fn encode_payload_chunks_into(
138    chunks: &[&[u8]],
139    out: &mut Vec<u8>,
140) -> Result<(), PayloadEncodeError> {
141    payload_container_len(chunks)?;
142    out.push(PAYLOAD_CONTAINER_SENTINEL);
143    out.extend_from_slice(PAYLOAD_CONTAINER_MAGIC);
144    out.push(PAYLOAD_CONTAINER_V1);
145    out.extend_from_slice(&(chunks.len() as u32).to_le_bytes());
146    for chunk in chunks {
147        out.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
148        out.extend_from_slice(chunk);
149    }
150    Ok(())
151}
152
153/// Decode a journal payload into its ordered chunks. The container is REQUIRED here: a payload
154/// without the reserved sentinel is a decode error, never a legacy fallback — sniffing would
155/// silently misread a container whose first byte was corrupted as one giant opaque chunk.
156/// Legacy (pre-container) payloads are a property of the FRAME version, handled by
157/// [`FrameHeader::decode_chunks`], not of the bytes.
158pub(crate) fn decode_payload_chunks(payload: &[u8]) -> Result<Vec<&[u8]>, PayloadDecodeError> {
159    if payload.first().copied() != Some(PAYLOAD_CONTAINER_SENTINEL) {
160        return Err(PayloadDecodeError::MissingSentinel);
161    }
162    if payload.len() < PAYLOAD_CONTAINER_HEADER_LEN {
163        return Err(PayloadDecodeError::Truncated);
164    }
165    if &payload[1..1 + PAYLOAD_CONTAINER_MAGIC.len()] != PAYLOAD_CONTAINER_MAGIC {
166        return Err(PayloadDecodeError::InvalidMagic);
167    }
168    let version_at = 1 + PAYLOAD_CONTAINER_MAGIC.len();
169    let version = payload[version_at];
170    if version != PAYLOAD_CONTAINER_V1 {
171        return Err(PayloadDecodeError::UnknownVersion(version));
172    }
173    let count_at = version_at + 1;
174    let count = u32::from_le_bytes(
175        payload[count_at..count_at + 4]
176            .try_into()
177            .expect("four-byte split"),
178    ) as usize;
179    if count == 0 {
180        return Err(PayloadDecodeError::Empty);
181    }
182    if count > MAX_CONTAINER_CHUNKS {
183        return Err(PayloadDecodeError::TooManyChunks(count));
184    }
185
186    let mut rest = &payload[PAYLOAD_CONTAINER_HEADER_LEN..];
187    // Every chunk needs at least its four-byte length prefix. Check this before reserving from the
188    // untrusted count so a tiny corrupt payload cannot request a multi-gigabyte allocation.
189    if count > rest.len() / 4 {
190        return Err(PayloadDecodeError::Truncated);
191    }
192    let mut chunks = Vec::with_capacity(count);
193    for _ in 0..count {
194        if rest.len() < 4 {
195            return Err(PayloadDecodeError::Truncated);
196        }
197        let len = u32::from_le_bytes(rest[..4].try_into().expect("four-byte split")) as usize;
198        if len == 0 {
199            return Err(PayloadDecodeError::EmptyChunk);
200        }
201        rest = &rest[4..];
202        if rest.len() < len {
203            return Err(PayloadDecodeError::Truncated);
204        }
205        let (chunk, tail) = rest.split_at(len);
206        chunks.push(chunk);
207        rest = tail;
208    }
209    if !rest.is_empty() {
210        return Err(PayloadDecodeError::TrailingBytes(rest.len()));
211    }
212    Ok(chunks)
213}
214
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub enum PayloadEncodeError {
217    Empty,
218    TooManyChunks(usize),
219    ChunkTooLarge(usize),
220    /// A zero-length chunk — no producer emits one (see `encode_payload_chunks`).
221    EmptyChunk,
222    ContainerTooLarge,
223}
224
225impl std::fmt::Display for PayloadEncodeError {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        match self {
228            PayloadEncodeError::Empty => {
229                write!(f, "a chunk container must contain at least one chunk")
230            }
231            PayloadEncodeError::TooManyChunks(n) => {
232                write!(
233                    f,
234                    "chunk count {n} exceeds the {MAX_CONTAINER_CHUNKS}-chunk container limit"
235                )
236            }
237            PayloadEncodeError::ChunkTooLarge(n) => {
238                write!(f, "chunk length {n} exceeds the u32 container limit")
239            }
240            PayloadEncodeError::EmptyChunk => {
241                write!(f, "a chunk container cannot hold a zero-length chunk")
242            }
243            PayloadEncodeError::ContainerTooLarge => {
244                write!(f, "chunk container length overflows addressable memory")
245            }
246        }
247    }
248}
249
250impl std::error::Error for PayloadEncodeError {}
251
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub enum PayloadDecodeError {
254    /// The payload does not start with the container sentinel — not a container at all.
255    MissingSentinel,
256    Truncated,
257    InvalidMagic,
258    UnknownVersion(u8),
259    Empty,
260    /// The declared chunk count exceeds `MAX_CONTAINER_CHUNKS` — corrupt or hostile.
261    TooManyChunks(usize),
262    /// A declared zero-length chunk (see `encode_payload_chunks`).
263    EmptyChunk,
264    TrailingBytes(usize),
265}
266
267impl std::fmt::Display for PayloadDecodeError {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        match self {
270            PayloadDecodeError::MissingSentinel => {
271                write!(f, "payload is not a chunk container (sentinel byte absent)")
272            }
273            PayloadDecodeError::Truncated => write!(f, "payload chunk container is truncated"),
274            PayloadDecodeError::InvalidMagic => {
275                write!(f, "payload chunk container has invalid magic")
276            }
277            PayloadDecodeError::UnknownVersion(v) => {
278                write!(f, "unknown payload chunk container version {v}")
279            }
280            PayloadDecodeError::Empty => write!(f, "payload chunk container has no chunks"),
281            PayloadDecodeError::TooManyChunks(n) => {
282                write!(
283                    f,
284                    "payload chunk container declares {n} chunks, over the \
285                     {MAX_CONTAINER_CHUNKS}-chunk limit"
286                )
287            }
288            PayloadDecodeError::EmptyChunk => {
289                write!(f, "payload chunk container declares a zero-length chunk")
290            }
291            PayloadDecodeError::TrailingBytes(n) => {
292                write!(f, "payload chunk container has {n} trailing byte(s)")
293            }
294        }
295    }
296}
297
298impl std::error::Error for PayloadDecodeError {}
299
300/// The entry kind — today's `_rindle_change_log.kind` discriminator plus the shapes
301/// the journal substrate adds.
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub enum FrameKind {
304    /// Row deltas: the payload contains ordered flat-change chunks.
305    Rows = 0,
306    /// A schema change riding the log in-band (`RELAY-DDL-DESIGN.md`): replay mutates
307    /// the schema at exactly this offset. DDL entries are fold barriers (212 §4.2) and
308    /// the reason no frame ever meets the wrong schema epoch (212 §3.1).
309    Ddl = 1,
310    /// An empty entry with **no payload**: the engine itself writes empty journal
311    /// records on post-allocation validation failure, and zero-fills crash gaps at
312    /// reopen (211 §4). They ship as empty frames so cid contiguity is preserved
313    /// end-to-end (212 §2.1). `committed_at` is 0 (unknown) — the engine's records
314    /// carry no wall clock.
315    Empty = 2,
316    // 3 is RESERVED for spill manifests (211 §6): a journal-side payload indirection
317    // for giant transactions. Spill manifests are resolved — chunks inlined — at ship
318    // time, so they never appear in segments (212 §2.2) and no cross-process reader
319    // decodes them yet. Claiming the discriminant now keeps the space unambiguous.
320}
321
322/// The run accounting pair (`run_rows`, `rows_cum`) — today's stamped columns, moved
323/// into the header and precomputed in the capture path (211 §4.1).
324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
325pub struct RunTotals {
326    /// Total change-row count of this entry's run.
327    pub run_rows: u64,
328    /// Cumulative change-row count at this entry's head (the master's running total).
329    pub rows_cum: u64,
330}
331
332/// The decoded envelope header. See the module docs for the wire layout.
333#[derive(Clone, Debug, PartialEq, Eq)]
334pub struct FrameHeader {
335    pub kind: FrameKind,
336    /// Commit wall clock, unix millis; 0 = unknown (engine-written [`FrameKind::Empty`]
337    /// records). Timestamp→cid PITR resolution scans this field and must skip empties
338    /// (212 §3). App-stamped pre-commit, so only *approximately* monotone across cids —
339    /// resolvers tolerate local clock steps.
340    pub committed_at: i64,
341    /// The run identity the epoch fence checks (`subscribe_run_id_matches` becomes a
342    /// payload decode under 211 §4.1).
343    pub run_id: Option<String>,
344    pub run_totals: Option<RunTotals>,
345}
346
347/// Encode-side misuse. Every variant is a producer bug, not a data-reachable state —
348/// surfaced as errors (not asserts) because the encoder sits on the commit path.
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub enum FrameEncodeError {
351    /// `run_id` exceeds the u8 length prefix (255 bytes). Run ids are uuid-sized.
352    RunIdTooLong(usize),
353    /// [`FrameKind::Empty`] with a non-empty payload — an empty entry *means* "no
354    /// payload existed" (engine-written record); bytes here would be silently lost
355    /// semantics.
356    EmptyFrameWithPayload,
357    /// The v2 chunk container rejected the chunks (empty set, zero-length chunk, …).
358    Payload(PayloadEncodeError),
359}
360
361impl std::fmt::Display for FrameEncodeError {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        match self {
364            FrameEncodeError::RunIdTooLong(n) => {
365                write!(f, "run_id is {n} bytes; the length prefix caps it at 255")
366            }
367            FrameEncodeError::EmptyFrameWithPayload => {
368                write!(f, "an Empty frame must carry no payload")
369            }
370            FrameEncodeError::Payload(e) => write!(f, "invalid frame payload chunks: {e}"),
371        }
372    }
373}
374
375impl std::error::Error for FrameEncodeError {}
376
377/// Decode-side failures. Fail-closed by construction: unknown versions, kinds, or flag
378/// bits are errors, never best-effort skips — a frame will be replayed by a binary
379/// years younger than the one that wrote it, and *that* direction must be exact.
380#[derive(Clone, Debug, PartialEq, Eq)]
381pub enum FrameDecodeError {
382    /// The bytes end before the header (or a length-prefixed field) completes.
383    Truncated,
384    UnknownFormat(u8),
385    UnknownKind(u8),
386    /// A reserved flag bit is set — written by a newer producer; refuse rather than
387    /// misparse the field section.
388    UnknownFlags(u8),
389    RunIdNotUtf8,
390    /// An [`FrameKind::Empty`] frame carrying payload bytes (see
391    /// [`FrameEncodeError::EmptyFrameWithPayload`]).
392    EmptyFrameWithPayload,
393    /// A v2 frame whose mandatory chunk container failed to decode.
394    Payload(PayloadDecodeError),
395}
396
397impl std::fmt::Display for FrameDecodeError {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            FrameDecodeError::Truncated => write!(f, "frame truncated mid-header"),
401            FrameDecodeError::UnknownFormat(v) => write!(f, "unknown frame format version {v}"),
402            FrameDecodeError::UnknownKind(k) => write!(f, "unknown frame kind {k}"),
403            FrameDecodeError::UnknownFlags(b) => write!(f, "unknown frame flag bits {b:#04x}"),
404            FrameDecodeError::RunIdNotUtf8 => write!(f, "run_id is not valid UTF-8"),
405            FrameDecodeError::EmptyFrameWithPayload => {
406                write!(f, "an Empty frame must carry no payload")
407            }
408            FrameDecodeError::Payload(e) => write!(f, "invalid frame payload container: {e}"),
409        }
410    }
411}
412
413impl std::error::Error for FrameDecodeError {}
414
415const FLAG_RUN_ID: u8 = 1 << 0;
416const FLAG_RUN_TOTALS: u8 = 1 << 1;
417const KNOWN_FLAGS: u8 = FLAG_RUN_ID | FLAG_RUN_TOTALS;
418
419impl FrameHeader {
420    /// The engine-written empty record (post-allocation validation failure, crash
421    /// zero-fill) as a frame: kind [`FrameKind::Empty`], no wall clock, no run.
422    pub fn empty() -> Self {
423        FrameHeader {
424            kind: FrameKind::Empty,
425            committed_at: 0,
426            run_id: None,
427            run_totals: None,
428        }
429    }
430
431    /// The encoded header length (fields only, no payload) — sizes single-allocation buffers.
432    fn header_len(&self) -> usize {
433        11 + self.run_id.as_ref().map_or(0, |id| 1 + id.len())
434            + if self.run_totals.is_some() { 16 } else { 0 }
435    }
436
437    /// Append the header fields under `version` to `out` (validating the u8 run_id prefix).
438    fn encode_header_into(&self, version: u8, out: &mut Vec<u8>) -> Result<(), FrameEncodeError> {
439        if let Some(id) = &self.run_id {
440            if id.len() > u8::MAX as usize {
441                return Err(FrameEncodeError::RunIdTooLong(id.len()));
442            }
443        }
444        out.push(version);
445        out.push(self.kind as u8);
446        let mut flags = 0u8;
447        if self.run_id.is_some() {
448            flags |= FLAG_RUN_ID;
449        }
450        if self.run_totals.is_some() {
451            flags |= FLAG_RUN_TOTALS;
452        }
453        out.push(flags);
454        out.extend_from_slice(&self.committed_at.to_le_bytes());
455        if let Some(id) = &self.run_id {
456            out.push(id.len() as u8);
457            out.extend_from_slice(id.as_bytes());
458        }
459        if let Some(t) = &self.run_totals {
460            out.extend_from_slice(&t.run_rows.to_le_bytes());
461            out.extend_from_slice(&t.rows_cum.to_le_bytes());
462        }
463        Ok(())
464    }
465
466    /// Encode `header ‖ payload` as a LEGACY v1 frame — one opaque payload blob. Production
467    /// paths write v2 via [`encode_frame`](Self::encode_frame); this stays for the
468    /// decode-forever compatibility fixtures and for tests that treat payloads as opaque.
469    pub fn encode(&self, payload: &[u8]) -> Result<Vec<u8>, FrameEncodeError> {
470        if self.kind == FrameKind::Empty && !payload.is_empty() {
471            return Err(FrameEncodeError::EmptyFrameWithPayload);
472        }
473        let mut out = Vec::with_capacity(self.header_len() + payload.len());
474        self.encode_header_into(FRAME_FORMAT_V1, &mut out)?;
475        out.extend_from_slice(payload);
476        Ok(out)
477    }
478
479    /// Encode `header ‖ chunk container` as one v2 frame in a SINGLE buffer — the bytes handed
480    /// to `leader_commit` (and, framed under a cid, stored in segments). The container is built
481    /// in place, so a maximum-size transaction costs one output allocation, not a container copy
482    /// plus a frame copy. [`FrameKind::Empty`] takes NO chunks (an empty entry *means* "no
483    /// payload existed"); every other kind takes at least one.
484    pub fn encode_frame(&self, chunks: &[&[u8]]) -> Result<Vec<u8>, FrameEncodeError> {
485        if self.kind == FrameKind::Empty {
486            if !chunks.is_empty() {
487                return Err(FrameEncodeError::EmptyFrameWithPayload);
488            }
489            let mut out = Vec::with_capacity(self.header_len());
490            self.encode_header_into(FRAME_FORMAT_V2, &mut out)?;
491            return Ok(out);
492        }
493        let container_len = payload_container_len(chunks).map_err(FrameEncodeError::Payload)?;
494        let mut out = Vec::with_capacity(self.header_len() + container_len);
495        self.encode_header_into(FRAME_FORMAT_V2, &mut out)?;
496        encode_payload_chunks_into(chunks, &mut out).map_err(FrameEncodeError::Payload)?;
497        Ok(out)
498    }
499
500    /// Decode a full frame into its header and ordered payload chunks — the ONE decode every
501    /// chunk consumer shares. A v1 frame's opaque legacy payload is returned as a single chunk;
502    /// a v2 frame's chunk container is mandatory (a payload without it is a decode error, never
503    /// a legacy guess); an [`FrameKind::Empty`] frame decodes to no chunks.
504    pub fn decode_chunks(bytes: &[u8]) -> Result<(FrameHeader, Vec<&[u8]>), FrameDecodeError> {
505        let (&version, _) = bytes.split_first().ok_or(FrameDecodeError::Truncated)?;
506        let (header, payload) = Self::decode(bytes)?;
507        let chunks = if header.kind == FrameKind::Empty {
508            Vec::new()
509        } else if version == FRAME_FORMAT_V1 {
510            vec![payload]
511        } else {
512            decode_payload_chunks(payload).map_err(FrameDecodeError::Payload)?
513        };
514        Ok((header, chunks))
515    }
516
517    /// Decode a `header ‖ payload` buffer (either frame version); returns the header and the
518    /// RAW payload slice. The payload's shape depends on the frame version — chunk consumers
519    /// use [`decode_chunks`](Self::decode_chunks); this is for header-only readers and code
520    /// that forwards the payload opaquely.
521    pub fn decode(bytes: &[u8]) -> Result<(FrameHeader, &[u8]), FrameDecodeError> {
522        let (&version, rest) = bytes.split_first().ok_or(FrameDecodeError::Truncated)?;
523        if version != FRAME_FORMAT_V1 && version != FRAME_FORMAT_V2 {
524            return Err(FrameDecodeError::UnknownFormat(version));
525        }
526        let (&kind_byte, rest) = rest.split_first().ok_or(FrameDecodeError::Truncated)?;
527        let kind = match kind_byte {
528            0 => FrameKind::Rows,
529            1 => FrameKind::Ddl,
530            2 => FrameKind::Empty,
531            other => return Err(FrameDecodeError::UnknownKind(other)),
532        };
533        let (&flags, rest) = rest.split_first().ok_or(FrameDecodeError::Truncated)?;
534        if flags & !KNOWN_FLAGS != 0 {
535            return Err(FrameDecodeError::UnknownFlags(flags & !KNOWN_FLAGS));
536        }
537        if rest.len() < 8 {
538            return Err(FrameDecodeError::Truncated);
539        }
540        let (ts, mut rest) = rest.split_at(8);
541        let committed_at = i64::from_le_bytes(ts.try_into().expect("8-byte split"));
542        let run_id = if flags & FLAG_RUN_ID != 0 {
543            let (&len, tail) = rest.split_first().ok_or(FrameDecodeError::Truncated)?;
544            if tail.len() < len as usize {
545                return Err(FrameDecodeError::Truncated);
546            }
547            let (id, tail) = tail.split_at(len as usize);
548            rest = tail;
549            Some(
550                std::str::from_utf8(id)
551                    .map_err(|_| FrameDecodeError::RunIdNotUtf8)?
552                    .to_owned(),
553            )
554        } else {
555            None
556        };
557        let run_totals = if flags & FLAG_RUN_TOTALS != 0 {
558            if rest.len() < 16 {
559                return Err(FrameDecodeError::Truncated);
560            }
561            let (raw, tail) = rest.split_at(16);
562            rest = tail;
563            Some(RunTotals {
564                run_rows: u64::from_le_bytes(raw[..8].try_into().expect("8-byte split")),
565                rows_cum: u64::from_le_bytes(raw[8..].try_into().expect("8-byte split")),
566            })
567        } else {
568            None
569        };
570        if kind == FrameKind::Empty && !rest.is_empty() {
571            return Err(FrameDecodeError::EmptyFrameWithPayload);
572        }
573        Ok((
574            FrameHeader {
575                kind,
576                committed_at,
577                run_id,
578                run_totals,
579            },
580            rest,
581        ))
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    fn full_header() -> FrameHeader {
590        FrameHeader {
591            kind: FrameKind::Rows,
592            committed_at: 1_752_000_000_123,
593            run_id: Some("w:0000000000000abc".to_owned()),
594            run_totals: Some(RunTotals {
595                run_rows: 42,
596                rows_cum: 9_000_000_001,
597            }),
598        }
599    }
600
601    #[test]
602    fn round_trips_every_field_combination() {
603        let payloads: &[&[u8]] = &[b"", b"[{\"op\":\"add\"}]"];
604        for kind in [FrameKind::Rows, FrameKind::Ddl] {
605            for run_id in [None, Some("r-1".to_owned())] {
606                for run_totals in [
607                    None,
608                    Some(RunTotals {
609                        run_rows: 7,
610                        rows_cum: 100,
611                    }),
612                ] {
613                    for payload in payloads {
614                        let header = FrameHeader {
615                            kind,
616                            committed_at: -5, // negative stays intact (i64, pre-epoch clock)
617                            run_id: run_id.clone(),
618                            run_totals,
619                        };
620                        let bytes = header.encode(payload).unwrap();
621                        let (back, tail) = FrameHeader::decode(&bytes).unwrap();
622                        assert_eq!(back, header);
623                        assert_eq!(tail, *payload);
624                    }
625                }
626            }
627        }
628    }
629
630    #[test]
631    fn empty_frame_round_trips_and_rejects_payload() {
632        let bytes = FrameHeader::empty().encode(b"").unwrap();
633        let (back, tail) = FrameHeader::decode(&bytes).unwrap();
634        assert_eq!(back, FrameHeader::empty());
635        assert!(tail.is_empty());
636
637        assert_eq!(
638            FrameHeader::empty().encode(b"x"),
639            Err(FrameEncodeError::EmptyFrameWithPayload)
640        );
641        // Decode side: an Empty header followed by bytes is a torn/forged frame.
642        let mut forged = bytes;
643        forged.push(0xff);
644        assert_eq!(
645            FrameHeader::decode(&forged),
646            Err(FrameDecodeError::EmptyFrameWithPayload)
647        );
648    }
649
650    #[test]
651    fn truncation_at_every_boundary_is_an_error_not_a_panic() {
652        let bytes = full_header().encode(b"payload").unwrap();
653        let payload_start = bytes.len() - "payload".len();
654        for cut in 0..payload_start {
655            assert_eq!(
656                FrameHeader::decode(&bytes[..cut]),
657                Err(FrameDecodeError::Truncated),
658                "cut at {cut}"
659            );
660        }
661        // Any cut at or past the payload start decodes fine (payload is opaque bytes).
662        assert!(FrameHeader::decode(&bytes[..payload_start]).is_ok());
663    }
664
665    #[test]
666    fn unknown_version_kind_and_flags_fail_closed() {
667        let bytes = full_header().encode(b"p").unwrap();
668
669        let mut v = bytes.clone();
670        v[0] = 3;
671        assert_eq!(
672            FrameHeader::decode(&v),
673            Err(FrameDecodeError::UnknownFormat(3))
674        );
675
676        let mut k = bytes.clone();
677        k[1] = 3; // the reserved spill-manifest discriminant: not decodable in v1
678        assert_eq!(
679            FrameHeader::decode(&k),
680            Err(FrameDecodeError::UnknownKind(3))
681        );
682
683        let mut f = bytes;
684        f[2] |= 1 << 5;
685        assert_eq!(
686            FrameHeader::decode(&f),
687            Err(FrameDecodeError::UnknownFlags(1 << 5))
688        );
689    }
690
691    #[test]
692    fn run_id_limits() {
693        let header = FrameHeader {
694            run_id: Some("x".repeat(256)),
695            ..full_header()
696        };
697        assert_eq!(header.encode(b""), Err(FrameEncodeError::RunIdTooLong(256)));
698
699        let max = FrameHeader {
700            run_id: Some("y".repeat(255)),
701            ..full_header()
702        };
703        let bytes = max.encode(b"tail").unwrap();
704        let (back, tail) = FrameHeader::decode(&bytes).unwrap();
705        assert_eq!(back.run_id.as_deref().map(str::len), Some(255));
706        assert_eq!(tail, b"tail");
707    }
708
709    #[test]
710    fn non_utf8_run_id_is_rejected() {
711        let mut bytes = FrameHeader {
712            run_id: Some("ab".to_owned()),
713            run_totals: None,
714            ..full_header()
715        }
716        .encode(b"")
717        .unwrap();
718        // Corrupt the run_id bytes in place (they are the last two bytes).
719        let n = bytes.len();
720        bytes[n - 2] = 0xff;
721        bytes[n - 1] = 0xfe;
722        assert_eq!(
723            FrameHeader::decode(&bytes),
724            Err(FrameDecodeError::RunIdNotUtf8)
725        );
726    }
727
728    #[test]
729    fn payload_chunks_round_trip_without_flattening_boundaries() {
730        let chunks: &[&[u8]] = &[b"[{\"n\":1}]", b"[]", b"[{\"n\":2},{\"n\":3}]"];
731        let encoded = encode_payload_chunks(chunks).unwrap();
732        assert_eq!(encoded[0], PAYLOAD_CONTAINER_SENTINEL);
733        assert_eq!(decode_payload_chunks(&encoded).unwrap(), chunks);
734    }
735
736    #[test]
737    fn v2_frames_round_trip_chunks_in_one_buffer() {
738        let header = full_header();
739        let chunks: &[&[u8]] = &[b"[{\"n\":1}]", b"[{\"n\":2},{\"n\":3}]"];
740        let bytes = header.encode_frame(chunks).unwrap();
741        assert_eq!(
742            bytes[0], FRAME_FORMAT_V2,
743            "container frames must be version-stamped so a pre-container reader errors with \
744             UnknownFormat, never a JSON parse failure misread as corruption"
745        );
746        let (back, decoded) = FrameHeader::decode_chunks(&bytes).unwrap();
747        assert_eq!(back, header);
748        assert_eq!(decoded, chunks);
749
750        // The v2 Empty frame: no chunks in, no chunks out, and no payload bytes on the wire.
751        let empty = FrameHeader::empty().encode_frame(&[]).unwrap();
752        assert_eq!(empty[0], FRAME_FORMAT_V2);
753        let (back, decoded) = FrameHeader::decode_chunks(&empty).unwrap();
754        assert_eq!(back, FrameHeader::empty());
755        assert!(decoded.is_empty());
756    }
757
758    #[test]
759    fn v1_legacy_frames_decode_as_one_chunk_forever() {
760        // The archival policy: every shipped v1 frame (payload = one opaque JSON array) stays
761        // replayable by all future readers.
762        let legacy = b"[{\"op\":\"add\"}]";
763        let bytes = full_header().encode(legacy).unwrap();
764        assert_eq!(bytes[0], FRAME_FORMAT_V1);
765        let (_, chunks) = FrameHeader::decode_chunks(&bytes).unwrap();
766        assert_eq!(chunks, vec![legacy.as_slice()]);
767    }
768
769    #[test]
770    fn encode_frame_rejects_producer_misuse() {
771        assert_eq!(
772            full_header().encode_frame(&[]),
773            Err(FrameEncodeError::Payload(PayloadEncodeError::Empty))
774        );
775        assert_eq!(
776            full_header().encode_frame(&[b"ok", b""]),
777            Err(FrameEncodeError::Payload(PayloadEncodeError::EmptyChunk))
778        );
779        assert_eq!(
780            FrameHeader::empty().encode_frame(&[b"x"]),
781            Err(FrameEncodeError::EmptyFrameWithPayload)
782        );
783    }
784
785    #[test]
786    fn v2_frame_with_a_non_container_payload_fails_closed() {
787        // A corrupted sentinel must NOT demote the payload to "legacy" — that guess would hand
788        // downstream JSON parsers a binary container body and misreport corruption.
789        let mut bytes = full_header().encode_frame(&[b"[{\"n\":1}]"]).unwrap();
790        let payload_at = bytes.len() - (PAYLOAD_CONTAINER_HEADER_LEN + 4 + 9);
791        assert_eq!(bytes[payload_at], PAYLOAD_CONTAINER_SENTINEL);
792        bytes[payload_at] ^= 1;
793        assert_eq!(
794            FrameHeader::decode_chunks(&bytes),
795            Err(FrameDecodeError::Payload(
796                PayloadDecodeError::MissingSentinel
797            ))
798        );
799    }
800
801    #[test]
802    fn hostile_chunk_counts_cannot_amplify_allocation() {
803        // A container body of nothing but zero-length chunk prefixes passes a length-only
804        // plausibility check while declaring payload_len/4 chunks — one fat pointer each. The
805        // zero-length rejection kills the amplification at the first prefix.
806        let mut crafted = Vec::new();
807        crafted.push(PAYLOAD_CONTAINER_SENTINEL);
808        crafted.extend_from_slice(PAYLOAD_CONTAINER_MAGIC);
809        crafted.push(PAYLOAD_CONTAINER_V1);
810        crafted.extend_from_slice(&1024u32.to_le_bytes());
811        crafted.extend_from_slice(&vec![0u8; 4 * 1024]);
812        assert_eq!(
813            decode_payload_chunks(&crafted),
814            Err(PayloadDecodeError::EmptyChunk)
815        );
816
817        // And a count over the absolute cap is rejected before any reservation at all.
818        let mut over_cap = Vec::new();
819        over_cap.push(PAYLOAD_CONTAINER_SENTINEL);
820        over_cap.extend_from_slice(PAYLOAD_CONTAINER_MAGIC);
821        over_cap.push(PAYLOAD_CONTAINER_V1);
822        over_cap.extend_from_slice(&((MAX_CONTAINER_CHUNKS as u32) + 1).to_le_bytes());
823        over_cap.extend_from_slice(&vec![1u8; 5 * (MAX_CONTAINER_CHUNKS + 1)]);
824        assert_eq!(
825            decode_payload_chunks(&over_cap),
826            Err(PayloadDecodeError::TooManyChunks(MAX_CONTAINER_CHUNKS + 1))
827        );
828    }
829
830    #[test]
831    fn malformed_payload_containers_fail_closed() {
832        assert_eq!(encode_payload_chunks(&[]), Err(PayloadEncodeError::Empty));
833        assert_eq!(
834            encode_payload_chunks(&[b"one", b""]),
835            Err(PayloadEncodeError::EmptyChunk)
836        );
837
838        assert_eq!(
839            decode_payload_chunks(b"[{\"op\":\"add\"}]"),
840            Err(PayloadDecodeError::MissingSentinel),
841            "a non-container payload is an error here — legacy handling is the FRAME version's \
842             job (decode_chunks), never a byte-sniffing fallback"
843        );
844
845        let valid = encode_payload_chunks(&[b"one", b"two"]).unwrap();
846        for cut in 1..valid.len() {
847            let decoded = decode_payload_chunks(&valid[..cut]);
848            assert!(decoded.is_err(), "cut at {cut} unexpectedly decoded");
849        }
850
851        let mut bad_magic = valid.clone();
852        bad_magic[1] ^= 1;
853        assert_eq!(
854            decode_payload_chunks(&bad_magic),
855            Err(PayloadDecodeError::InvalidMagic)
856        );
857
858        let mut future = valid.clone();
859        future[1 + PAYLOAD_CONTAINER_MAGIC.len()] = 2;
860        assert_eq!(
861            decode_payload_chunks(&future),
862            Err(PayloadDecodeError::UnknownVersion(2))
863        );
864
865        let mut empty = valid.clone();
866        let count_at = 1 + PAYLOAD_CONTAINER_MAGIC.len() + 1;
867        empty[count_at..count_at + 4].copy_from_slice(&0u32.to_le_bytes());
868        assert_eq!(
869            decode_payload_chunks(&empty),
870            Err(PayloadDecodeError::Empty)
871        );
872
873        let mut impossible_count = valid.clone();
874        impossible_count[count_at..count_at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
875        assert_eq!(
876            decode_payload_chunks(&impossible_count),
877            Err(PayloadDecodeError::TooManyChunks(u32::MAX as usize))
878        );
879
880        let mut trailing = valid;
881        trailing.push(0);
882        assert_eq!(
883            decode_payload_chunks(&trailing),
884            Err(PayloadDecodeError::TrailingBytes(1))
885        );
886    }
887
888    #[test]
889    fn payload_length_step_pins_validation_and_overflow_boundaries() {
890        assert_eq!(
891            payload_container_len_step(PAYLOAD_CONTAINER_HEADER_LEN, 1),
892            Ok(PAYLOAD_CONTAINER_HEADER_LEN + 5)
893        );
894        assert_eq!(
895            payload_container_len_step(PAYLOAD_CONTAINER_HEADER_LEN, 0),
896            Err(PayloadEncodeError::EmptyChunk)
897        );
898        assert_eq!(
899            payload_container_len_step(usize::MAX, 1),
900            Err(PayloadEncodeError::ContainerTooLarge)
901        );
902        #[cfg(target_pointer_width = "64")]
903        assert_eq!(
904            payload_container_len_step(PAYLOAD_CONTAINER_HEADER_LEN, u32::MAX as usize + 1),
905            Err(PayloadEncodeError::ChunkTooLarge(u32::MAX as usize + 1))
906        );
907    }
908}
909
910#[cfg(kani)]
911mod kani_proofs {
912    use super::*;
913
914    /// Complete proof that one container-length step accepts exactly a non-zero `u32`
915    /// length whose prefix and bytes fit after `current`, and classifies every rejection.
916    #[kani::proof]
917    fn proof_payload_container_len_step_is_exact_complete() {
918        let current: usize = kani::any();
919        let chunk_len: usize = kani::any();
920        let actual = payload_container_len_step(current, chunk_len);
921
922        if chunk_len == 0 {
923            assert_eq!(actual, Err(PayloadEncodeError::EmptyChunk));
924        } else if chunk_len > u32::MAX as usize {
925            assert_eq!(actual, Err(PayloadEncodeError::ChunkTooLarge(chunk_len)));
926        } else if chunk_len > usize::MAX - 4 || current > usize::MAX - 4 - chunk_len {
927            assert_eq!(actual, Err(PayloadEncodeError::ContainerTooLarge));
928        } else {
929            assert_eq!(actual, Ok(current + 4 + chunk_len));
930        }
931    }
932
933    /// Bounded arbitrary-input totality for the raw frame header decoder. The result is not
934    /// prescribed, but every important success/error class must be reachable and no byte/length
935    /// combination may panic or perform an invalid access.
936    #[kani::proof]
937    #[kani::unwind(14)]
938    fn proof_frame_header_decode_never_panics_up_to_12_bytes_bounded() {
939        let bytes: [u8; 12] = kani::any();
940        let len: u8 = kani::any();
941        kani::assume(len <= bytes.len() as u8);
942
943        let decoded = FrameHeader::decode(&bytes[..len as usize]);
944        kani::cover!(decoded.is_ok());
945        kani::cover!(matches!(decoded, Err(FrameDecodeError::Truncated)));
946        kani::cover!(matches!(decoded, Err(FrameDecodeError::UnknownFormat(_))));
947        kani::cover!(matches!(decoded, Err(FrameDecodeError::UnknownKind(_))));
948        kani::cover!(matches!(decoded, Err(FrameDecodeError::UnknownFlags(_))));
949        kani::cover!(matches!(
950            decoded,
951            Err(FrameDecodeError::EmptyFrameWithPayload)
952        ));
953    }
954
955    /// Bounded UTF-8 branch proof for every possible one-byte run id and every timestamp.
956    /// ASCII must decode exactly; a lone high byte must be rejected rather than panic.
957    #[kani::proof]
958    #[kani::unwind(15)]
959    fn proof_frame_header_decode_one_byte_run_id_is_total_bounded() {
960        let committed_at: i64 = kani::any();
961        let id_byte: u8 = kani::any();
962        let mut bytes = [0u8; 13];
963        bytes[0] = FRAME_FORMAT_V1;
964        bytes[1] = FrameKind::Rows as u8;
965        bytes[2] = FLAG_RUN_ID;
966        bytes[3..11].copy_from_slice(&committed_at.to_le_bytes());
967        bytes[11] = 1;
968        bytes[12] = id_byte;
969
970        let decoded = FrameHeader::decode(&bytes);
971        if id_byte.is_ascii() {
972            let (header, payload) = decoded.unwrap();
973            assert_eq!(header.kind, FrameKind::Rows);
974            assert_eq!(header.committed_at, committed_at);
975            assert_eq!(header.run_id.unwrap().as_bytes(), &[id_byte]);
976            assert!(payload.is_empty());
977        } else {
978            assert_eq!(decoded, Err(FrameDecodeError::RunIdNotUtf8));
979        }
980    }
981
982    /// Bounded arbitrary-input totality for the ordered-chunk container decoder. Successful
983    /// paths and structural postconditions are witnessed independently by the raw-layout
984    /// harnesses below; this harness leaves every input byte unconstrained and asks Kani's
985    /// safety checks to inspect every decoder branch.
986    #[kani::proof]
987    #[kani::unwind(10)]
988    fn proof_decode_payload_chunks_never_panics_up_to_18_bytes_bounded() {
989        let bytes: [u8; 18] = kani::any();
990        let len: u8 = kani::any();
991        kani::assume(len <= bytes.len() as u8);
992
993        let _ = decode_payload_chunks(&bytes[..len as usize]);
994    }
995
996    fn assert_payload_container_one_chunk_layout_and_round_trip(payload: &[u8]) {
997        let chunks = [payload];
998        let encoded = encode_payload_chunks(&chunks).unwrap();
999
1000        assert_eq!(
1001            encoded.len(),
1002            PAYLOAD_CONTAINER_HEADER_LEN + 4 + payload.len()
1003        );
1004        assert_eq!(encoded[0], 0x89);
1005        assert_eq!(&encoded[1..5], b"RJP\0");
1006        assert_eq!(encoded[5], 1);
1007        assert_eq!(&encoded[6..10], &1u32.to_le_bytes());
1008        assert_eq!(&encoded[10..14], &(payload.len() as u32).to_le_bytes());
1009        assert_eq!(&encoded[14..], payload);
1010        assert_eq!(decode_payload_chunks(&encoded).unwrap(), chunks);
1011    }
1012
1013    /// Bounded one-byte chunk proof against the documented raw container layout, followed by
1014    /// a logical round trip over every possible payload byte.
1015    #[kani::proof]
1016    #[kani::unwind(18)]
1017    fn proof_payload_container_one_byte_chunk_layout_and_round_trip_bounded() {
1018        let payload: [u8; 1] = kani::any();
1019        assert_payload_container_one_chunk_layout_and_round_trip(&payload);
1020    }
1021
1022    /// Bounded two-byte chunk proof. Splitting fixed lengths keeps container allocation from
1023    /// multiplying a symbolic slice-length branch through every subsequent byte operation.
1024    #[kani::proof]
1025    #[kani::unwind(18)]
1026    fn proof_payload_container_two_byte_chunk_layout_and_round_trip_bounded() {
1027        let payload: [u8; 2] = kani::any();
1028        assert_payload_container_one_chunk_layout_and_round_trip(&payload);
1029    }
1030
1031    /// Bounded two-chunk proof of boundary preservation and the second absolute entry offset.
1032    /// Every byte is symbolic; the fixed 1-byte/2-byte shape isolates the offset obligation.
1033    #[kani::proof]
1034    #[kani::unwind(24)]
1035    fn proof_payload_container_two_chunk_layout_and_round_trip_bounded() {
1036        let first: [u8; 1] = kani::any();
1037        let second: [u8; 2] = kani::any();
1038        let first_len = first.len();
1039        let second_len = second.len();
1040        let chunks: [&[u8]; 2] = [&first, &second];
1041        let encoded = encode_payload_chunks(&chunks).unwrap();
1042        let second_prefix = 14 + first_len;
1043        let second_payload = second_prefix + 4;
1044
1045        assert_eq!(
1046            encoded.len(),
1047            PAYLOAD_CONTAINER_HEADER_LEN + 8 + first_len + second_len
1048        );
1049        assert_eq!(&encoded[6..10], &2u32.to_le_bytes());
1050        assert_eq!(&encoded[10..14], &(first_len as u32).to_le_bytes());
1051        assert_eq!(&encoded[14..second_prefix], &first);
1052        assert_eq!(
1053            &encoded[second_prefix..second_payload],
1054            &(second_len as u32).to_le_bytes()
1055        );
1056        assert_eq!(&encoded[second_payload..], &second);
1057        assert_eq!(decode_payload_chunks(&encoded).unwrap(), chunks);
1058    }
1059
1060    fn assert_v1_frame_no_options_layout_and_round_trip(kind: FrameKind) {
1061        let header = FrameHeader {
1062            kind,
1063            committed_at: 0,
1064            run_id: None,
1065            run_totals: None,
1066        };
1067        let encoded = header.encode(b"x").unwrap();
1068
1069        assert_eq!(encoded.len(), 12);
1070        assert_eq!(encoded[0], 1);
1071        assert_eq!(encoded[1], header.kind as u8);
1072        assert_eq!(encoded[2], 0);
1073        assert_eq!(&encoded[3..11], &header.committed_at.to_le_bytes());
1074        assert_eq!(&encoded[11..], b"x");
1075        assert_eq!(FrameHeader::decode(&encoded), Ok((header, b"x".as_slice())));
1076    }
1077
1078    /// Bounded v1 Rows-frame proof with no optional fields. Complete primitive fields are
1079    /// isolated below; raw assertions here come directly from the documented layout.
1080    #[kani::proof]
1081    #[kani::unwind(18)]
1082    fn proof_v1_rows_frame_no_options_layout_and_round_trip_bounded() {
1083        assert_v1_frame_no_options_layout_and_round_trip(FrameKind::Rows);
1084    }
1085
1086    /// Bounded v1 DDL-frame proof with no optional fields. Splitting the fixed kind shapes keeps
1087    /// CBMC from multiplying an enum branch through allocation and slice decoding.
1088    #[kani::proof]
1089    #[kani::unwind(18)]
1090    fn proof_v1_ddl_frame_no_options_layout_and_round_trip_bounded() {
1091        assert_v1_frame_no_options_layout_and_round_trip(FrameKind::Ddl);
1092    }
1093
1094    /// Complete primitive-domain proof for the run-totals layout: every timestamp and both
1095    /// `u64` counters are checked at their documented offsets and through the decoder.
1096    #[kani::proof]
1097    #[kani::unwind(30)]
1098    fn proof_v1_frame_totals_layout_and_round_trip_complete() {
1099        let header = FrameHeader {
1100            kind: FrameKind::Rows,
1101            committed_at: kani::any(),
1102            run_id: None,
1103            run_totals: Some(RunTotals {
1104                run_rows: kani::any(),
1105                rows_cum: kani::any(),
1106            }),
1107        };
1108        let encoded = header.encode(b"").unwrap();
1109        let totals = header.run_totals.unwrap();
1110
1111        assert_eq!(encoded.len(), 27);
1112        assert_eq!(encoded[0], 1);
1113        assert_eq!(encoded[1], header.kind as u8);
1114        assert_eq!(encoded[2], FLAG_RUN_TOTALS);
1115        assert_eq!(&encoded[3..11], &header.committed_at.to_le_bytes());
1116        assert_eq!(&encoded[11..19], &totals.run_rows.to_le_bytes());
1117        assert_eq!(&encoded[19..27], &totals.rows_cum.to_le_bytes());
1118        assert_eq!(FrameHeader::decode(&encoded), Ok((header, b"".as_slice())));
1119    }
1120
1121    /// Bounded raw-layout proof for both optional fields. Their round-trip obligations are split
1122    /// between the one-byte run-id and complete totals harnesses above; this representative checks
1123    /// their combined flag and offsets without making encoder/decoder agreement the oracle.
1124    #[kani::proof]
1125    #[kani::unwind(38)]
1126    fn proof_v1_frame_both_options_layout_bounded() {
1127        let header = FrameHeader {
1128            kind: FrameKind::Ddl,
1129            committed_at: -5,
1130            run_id: Some("r-1".to_owned()),
1131            run_totals: Some(RunTotals {
1132                run_rows: 7,
1133                rows_cum: 100,
1134            }),
1135        };
1136        let encoded = header.encode(b"x").unwrap();
1137
1138        assert_eq!(encoded.len(), 32);
1139        assert_eq!(encoded[0], 1);
1140        assert_eq!(encoded[1], FrameKind::Ddl as u8);
1141        assert_eq!(encoded[2], FLAG_RUN_ID | FLAG_RUN_TOTALS);
1142        assert_eq!(&encoded[3..11], &(-5i64).to_le_bytes());
1143        assert_eq!(encoded[11], 3);
1144        assert_eq!(&encoded[12..15], b"r-1");
1145        assert_eq!(&encoded[15..23], &7u64.to_le_bytes());
1146        assert_eq!(&encoded[23..31], &100u64.to_le_bytes());
1147        assert_eq!(&encoded[31..], b"x");
1148    }
1149
1150    /// Bounded end-to-end v2 composition proof. The preceding harnesses quantify the header
1151    /// fields and container bytes separately; this fixed representative proves the shipping
1152    /// `encode_frame`/`decode_chunks` wiring without multiplying those two symbolic heaps.
1153    #[kani::proof]
1154    #[kani::unwind(28)]
1155    fn proof_v2_frame_one_chunk_round_trip_bounded() {
1156        let header = FrameHeader {
1157            kind: FrameKind::Rows,
1158            committed_at: -1,
1159            run_id: None,
1160            run_totals: None,
1161        };
1162        let chunks: [&[u8]; 1] = [b"x"];
1163        let encoded = header.encode_frame(&chunks).unwrap();
1164
1165        assert_eq!(encoded[0], 2);
1166        assert_eq!(encoded[11], 0x89);
1167        assert_eq!(
1168            FrameHeader::decode_chunks(&encoded),
1169            Ok((header, chunks.to_vec()))
1170        );
1171    }
1172}