Skip to main content

zeppelin_embed/ingest/
wal_payload.rs

1//! Versioned payloads inside the frozen task-08 WAL record frame.
2
3use crate::meta::{ColumnId, PredicateValue};
4
5use super::{DocId, DocumentVersion, IngestDocument, Revision};
6
7// Operation ids are an append-only persisted contract. Once assigned, an id
8// is never reused, even if its record kind is retired:
9//   1 = vector/document upsert v1
10//   2 = document delete v1
11//   3 = metadata edit v1
12//   4 = vector/document upsert with canonical ts v1
13//   5 = vector/document upsert with opaque stored metadata v1
14//   6 = vector/document upsert with canonical ts and opaque stored metadata v1
15//   7 = document upsert v2 with a u32 field-presence bitmap
16//   8 = one member of a multi-record upsert batch: `index:u32`,
17//       `count:u32`, then the unchanged upsert-v2 payload (ZE-216)
18//
19// Upsert-v2 bitmap bits are append-only persisted meanings:
20//   bit 0 = vector (`dims:u32`, then `f32[dims]`)
21//   bit 1 = UTF-8 text (`len:u32`, then bytes)
22//   bit 2 = canonical timestamp (`i64`)
23//   bit 3 = opaque stored metadata (`len:u32`, then bytes)
24//   bit 4 = typed columns (count plus typed length-delimited entries)
25//   bit 5 = reserved-unused for a future per-record epoch tag
26//   bits 6..31 = reserved-unused
27/// Upsert payload v1.
28pub const UPSERT_V1: u16 = 1;
29/// Delete payload v1.
30pub const DELETE_V1: u16 = 2;
31/// Metadata-edit payload v1.
32pub const METADATA_EDIT_V1: u16 = 3;
33/// Upsert payload with canonical timestamp v1.
34pub const UPSERT_WITH_TIMESTAMP_V1: u16 = 4;
35/// Upsert payload with opaque stored metadata v1.
36pub const UPSERT_WITH_METADATA_V1: u16 = 5;
37/// Upsert payload with canonical timestamp and opaque stored metadata v1.
38pub const UPSERT_WITH_TIMESTAMP_AND_METADATA_V1: u16 = 6;
39/// Bitmap-based upsert payload v2.
40pub const UPSERT_V2: u16 = 7;
41/// One member of a multi-record upsert batch: `[index:u32][count:u32]`, then
42/// an upsert-v2 payload. Replay applies a batch only when members
43/// `0..count` are all present.
44pub const UPSERT_V2_BATCH_MEMBER: u16 = 8;
45
46/// Upsert-v2 vector field bit.
47pub const UPSERT_V2_VECTOR: u32 = 1 << 0;
48/// Upsert-v2 text field bit.
49pub const UPSERT_V2_TEXT: u32 = 1 << 1;
50/// Upsert-v2 timestamp field bit.
51pub const UPSERT_V2_TIMESTAMP: u32 = 1 << 2;
52/// Upsert-v2 opaque-metadata field bit.
53pub const UPSERT_V2_METADATA: u32 = 1 << 3;
54/// Upsert-v2 typed-column field bit.
55pub const UPSERT_V2_TYPED_COLUMNS: u32 = 1 << 4;
56/// Reserved-unused upsert-v2 bit for a future per-record epoch tag.
57pub const UPSERT_V2_EPOCH_TAG_RESERVED: u32 = 1 << 5;
58
59const UPSERT_V2_KNOWN_FIELDS: u32 = UPSERT_V2_VECTOR
60    | UPSERT_V2_TEXT
61    | UPSERT_V2_TIMESTAMP
62    | UPSERT_V2_METADATA
63    | UPSERT_V2_TYPED_COLUMNS;
64
65const PAYLOAD_VERSION: u16 = 1;
66const COMMON_HEADER_LEN: usize = 4;
67const DOCUMENT_VERSION_LEN: usize = 24;
68const UPSERT_PREFIX_LEN: usize = COMMON_HEADER_LEN + DOCUMENT_VERSION_LEN + 4;
69const TIMESTAMPED_UPSERT_PREFIX_LEN: usize = COMMON_HEADER_LEN + DOCUMENT_VERSION_LEN + 8 + 4;
70const METADATA_UPSERT_PREFIX_LEN: usize = COMMON_HEADER_LEN + DOCUMENT_VERSION_LEN + 4 + 4;
71const TIMESTAMPED_METADATA_UPSERT_PREFIX_LEN: usize =
72    COMMON_HEADER_LEN + DOCUMENT_VERSION_LEN + 8 + 4 + 4;
73const DELETE_PREFIX_LEN: usize = COMMON_HEADER_LEN + 4;
74const METADATA_PREFIX_LEN: usize = COMMON_HEADER_LEN + DOCUMENT_VERSION_LEN + 4 + 1 + 3 + 4;
75
76const VALUE_NULL: u8 = 0;
77const VALUE_ID128: u8 = 6;
78const VALUE_U64: u8 = 1;
79const VALUE_I64: u8 = 2;
80const VALUE_F64: u8 = 3;
81const VALUE_BOOL: u8 = 4;
82const VALUE_STRING: u8 = 5;
83
84/// Typed operation-payload encoding or decoding failure.
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub enum PayloadError {
87    /// A WAL operation id has no assigned payload contract.
88    UnknownOperation(u16),
89    /// Invalid transaction identity, generation or sequence range.
90    TransactionBinding,
91    /// A count or byte length does not fit its frozen field.
92    LengthOverflow,
93    /// The payload is shorter than the required fixed prefix or declared body.
94    Truncated,
95    /// The payload version is not v1.
96    Version(u16),
97    /// A must-be-zero flags field was non-zero.
98    Flags(u16),
99    /// A must-be-zero reserved metadata field was non-zero.
100    Reserved(u32),
101    /// An upsert contained no vector coordinates.
102    EmptyVector,
103    /// A delete contained no document ids.
104    EmptyDelete,
105    /// A vector coordinate was NaN or infinite.
106    NonFiniteVector {
107        /// Zero-based vector coordinate.
108        index: usize,
109    },
110    /// A metadata value kind has no assigned v1 meaning.
111    ValueKind(u8),
112    /// A value body has the wrong width for its declared kind.
113    ValueLength {
114        /// Declared metadata value kind.
115        kind: u8,
116        /// Required body width.
117        expected: usize,
118        /// Declared body width.
119        actual: usize,
120    },
121    /// A Boolean metadata byte was neither zero nor one.
122    Boolean(u8),
123    /// A string metadata body was not valid UTF-8.
124    Utf8,
125    /// Bytes remain after the declared payload.
126    TrailingBytes(usize),
127    /// An upsert-v2 bitmap named a reserved or unimplemented field.
128    FieldBitmap(u32),
129    /// Active lexical reconstruction failed during WAL replay.
130    Lexical(String),
131    /// Active typed-column reconstruction failed during WAL replay.
132    Columns(String),
133    /// A batch member position is not `index < count` with `count >= 2`.
134    BatchPosition {
135        /// Persisted zero-based member index.
136        index: u32,
137        /// Persisted member count.
138        count: u32,
139    },
140    /// A batch member does not continue the batch the log was replaying.
141    OrphanBatchMember {
142        /// Persisted zero-based member index.
143        index: u32,
144        /// Persisted member count.
145        count: u32,
146    },
147}
148
149impl std::fmt::Display for PayloadError {
150    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            Self::TransactionBinding => write!(formatter, "invalid transaction binding"),
153            Self::UnknownOperation(op) => {
154                write!(formatter, "WAL mutation operation {op} is unknown")
155            }
156            Self::LengthOverflow => formatter.write_str("WAL mutation payload length overflow"),
157            Self::Truncated => formatter.write_str("WAL mutation payload is truncated"),
158            Self::Version(version) => write!(
159                formatter,
160                "WAL mutation payload version {version} is unsupported"
161            ),
162            Self::Flags(flags) => write!(
163                formatter,
164                "WAL mutation payload flags {flags:#06x} are non-zero"
165            ),
166            Self::Reserved(value) => write!(
167                formatter,
168                "WAL metadata payload reserved bytes are {value:#08x}, expected zero"
169            ),
170            Self::EmptyVector => formatter.write_str("WAL upsert vector is empty"),
171            Self::EmptyDelete => formatter.write_str("WAL delete document list is empty"),
172            Self::NonFiniteVector { index } => write!(
173                formatter,
174                "WAL upsert vector coordinate {index} is non-finite"
175            ),
176            Self::ValueKind(kind) => {
177                write!(formatter, "WAL metadata value kind {kind} is unknown")
178            }
179            Self::ValueLength {
180                kind,
181                expected,
182                actual,
183            } => write!(
184                formatter,
185                "WAL metadata value kind {kind} needs {expected} bytes, got {actual}"
186            ),
187            Self::Boolean(value) => write!(
188                formatter,
189                "WAL metadata Boolean byte {value} is not canonical zero or one"
190            ),
191            Self::Utf8 => formatter.write_str("WAL metadata string is not valid UTF-8"),
192            Self::TrailingBytes(bytes) => {
193                write!(formatter, "WAL mutation payload has {bytes} trailing bytes")
194            }
195            Self::FieldBitmap(bitmap) => write!(
196                formatter,
197                "WAL upsert-v2 field bitmap {bitmap:#010x} names reserved fields"
198            ),
199            Self::Lexical(detail) => write!(formatter, "WAL lexical replay failed: {detail}"),
200            Self::Columns(detail) => write!(formatter, "WAL column replay failed: {detail}"),
201            Self::BatchPosition { index, count } => write!(
202                formatter,
203                "WAL batch member {index} of {count} is not a valid position"
204            ),
205            Self::OrphanBatchMember { index, count } => write!(
206                formatter,
207                "WAL batch member {index} of {count} continues no earlier member"
208            ),
209        }
210    }
211}
212
213impl std::error::Error for PayloadError {}
214
215/// Owned typed value carried by one metadata-edit record.
216#[derive(Clone, Debug, PartialEq)]
217pub enum MetadataValue {
218    /// Clears a nullable metadata value.
219    Null,
220    /// Unsigned integer value.
221    U64(u64),
222    /// Full document identifier, encoded as sixteen little-endian bytes.
223    Id128(DocId),
224    /// Signed integer value.
225    I64(i64),
226    /// IEEE-754 value, preserving its exact bits.
227    F64(f64),
228    /// Boolean value.
229    Bool(bool),
230    /// UTF-8 string value.
231    String(String),
232}
233
234/// One structured document metadata mutation.
235#[derive(Clone, Debug, PartialEq)]
236pub struct MetadataEdit {
237    version: DocumentVersion,
238    column: ColumnId,
239    value: MetadataValue,
240}
241
242impl MetadataEdit {
243    /// Constructs one metadata edit for a specific document revision.
244    #[must_use]
245    pub const fn new(version: DocumentVersion, column: ColumnId, value: MetadataValue) -> Self {
246        Self {
247            version,
248            column,
249            value,
250        }
251    }
252
253    /// Returns the document/revision key to edit.
254    #[must_use]
255    pub const fn version(&self) -> DocumentVersion {
256        self.version
257    }
258
259    /// Returns the schema-local column id.
260    #[must_use]
261    pub const fn column(&self) -> ColumnId {
262        self.column
263    }
264
265    /// Returns the owned typed replacement value.
266    #[must_use]
267    pub const fn value(&self) -> &MetadataValue {
268        &self.value
269    }
270}
271
272/// One decoded record from the append-only mutation operation space.
273#[derive(Clone, Debug, PartialEq)]
274pub enum MutationPayload {
275    /// Vector/document upsert.
276    Upsert(IngestDocument),
277    /// Document tombstones.
278    Delete(Vec<DocId>),
279    /// Structured metadata edit.
280    MetadataEdit(MetadataEdit),
281    /// One upsert inside a multi-record batch.
282    BatchMember {
283        /// Zero-based position in the batch.
284        index: u32,
285        /// Number of members in the batch.
286        count: u32,
287        /// The member's upsert.
288        document: IngestDocument,
289    },
290}
291
292/// Encodes one upsert payload as little-endian
293/// `[version:u16, flags:u16, doc_id:u128, revision:u64, dims:u32, f32[dims]]`.
294pub fn encode_upsert(document: &IngestDocument) -> Result<Vec<u8>, PayloadError> {
295    encode_upsert_body(document, None, false)
296}
297
298/// Encodes one upsert with its canonical `ts` value before vector geometry.
299pub fn encode_upsert_with_timestamp(document: &IngestDocument) -> Result<Vec<u8>, PayloadError> {
300    encode_upsert_body(document, Some(document.timestamp()), false)
301}
302
303/// Encodes one upsert with opaque stored metadata.
304pub fn encode_upsert_with_metadata(document: &IngestDocument) -> Result<Vec<u8>, PayloadError> {
305    encode_upsert_body(document, None, true)
306}
307
308/// Encodes one timestamped upsert with opaque stored metadata.
309pub fn encode_upsert_with_timestamp_and_metadata(
310    document: &IngestDocument,
311) -> Result<Vec<u8>, PayloadError> {
312    encode_upsert_body(document, Some(document.timestamp()), true)
313}
314
315/// Encodes one bitmap-based upsert v2. The operation id carries the payload
316/// version, so the first four payload bytes are the field-presence bitmap.
317pub fn encode_upsert_v2(document: &IngestDocument) -> Result<Vec<u8>, PayloadError> {
318    let mut bitmap = UPSERT_V2_VECTOR;
319    if document.text().is_some() {
320        bitmap |= UPSERT_V2_TEXT;
321    }
322    if document.has_timestamp() {
323        bitmap |= UPSERT_V2_TIMESTAMP;
324    }
325    if document.has_metadata() {
326        bitmap |= UPSERT_V2_METADATA;
327    }
328    if document.has_columns() {
329        bitmap |= UPSERT_V2_TYPED_COLUMNS;
330    }
331    validate_vector(document.vector())?;
332    let dims = u32::try_from(document.vector().len()).map_err(|_| PayloadError::LengthOverflow)?;
333    let mut payload = Vec::new();
334    payload.extend_from_slice(&bitmap.to_le_bytes());
335    append_version(&mut payload, document.version());
336    payload.extend_from_slice(&dims.to_le_bytes());
337    for value in document.vector() {
338        payload.extend_from_slice(&value.to_bits().to_le_bytes());
339    }
340    if let Some(text) = document.text() {
341        append_length_prefixed(text.as_bytes(), &mut payload)?;
342    }
343    if document.has_timestamp() {
344        payload.extend_from_slice(&document.timestamp().to_le_bytes());
345    }
346    if document.has_metadata() {
347        append_length_prefixed(document.metadata(), &mut payload)?;
348    }
349    if document.has_columns() {
350        encode_typed_columns(document.columns(), &mut payload)?;
351    }
352    Ok(payload)
353}
354
355fn validate_vector(vector: &[f32]) -> Result<(), PayloadError> {
356    if vector.is_empty() {
357        return Err(PayloadError::EmptyVector);
358    }
359    if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
360        return Err(PayloadError::NonFiniteVector { index });
361    }
362    Ok(())
363}
364
365fn append_length_prefixed(bytes: &[u8], output: &mut Vec<u8>) -> Result<(), PayloadError> {
366    let length = u32::try_from(bytes.len()).map_err(|_| PayloadError::LengthOverflow)?;
367    output.extend_from_slice(&length.to_le_bytes());
368    output.extend_from_slice(bytes);
369    Ok(())
370}
371
372fn encode_typed_columns(
373    columns: &[(ColumnId, PredicateValue)],
374    output: &mut Vec<u8>,
375) -> Result<(), PayloadError> {
376    let count = u32::try_from(columns.len()).map_err(|_| PayloadError::LengthOverflow)?;
377    output.extend_from_slice(&count.to_le_bytes());
378    for (column, value) in columns {
379        let metadata = predicate_to_metadata(value);
380        let (kind, body) = encode_metadata_value(&metadata)?;
381        output.extend_from_slice(&column.get().to_le_bytes());
382        output.push(kind);
383        output.extend_from_slice(&[0_u8; 3]);
384        append_length_prefixed(&body, output)?;
385    }
386    Ok(())
387}
388
389pub(crate) fn encode_column_values(
390    columns: &[(ColumnId, PredicateValue)],
391) -> Result<Vec<u8>, PayloadError> {
392    let mut encoded = Vec::new();
393    encode_typed_columns(columns, &mut encoded)?;
394    Ok(encoded)
395}
396
397fn predicate_to_metadata(value: &PredicateValue) -> MetadataValue {
398    match value {
399        PredicateValue::Id128(value) => MetadataValue::Id128(*value),
400        PredicateValue::U64(value) => MetadataValue::U64(*value),
401        PredicateValue::I64(value) => MetadataValue::I64(*value),
402        PredicateValue::F64(value) => MetadataValue::F64(*value),
403        PredicateValue::Bool(value) => MetadataValue::Bool(*value),
404        PredicateValue::String(value) => MetadataValue::String(value.clone()),
405    }
406}
407
408fn encode_upsert_body(
409    document: &IngestDocument,
410    timestamp: Option<i64>,
411    include_metadata: bool,
412) -> Result<Vec<u8>, PayloadError> {
413    if document.vector().is_empty() {
414        return Err(PayloadError::EmptyVector);
415    }
416    let dims = u32::try_from(document.vector().len()).map_err(|_| PayloadError::LengthOverflow)?;
417    if let Some(index) = document
418        .vector()
419        .iter()
420        .position(|value| !value.is_finite())
421    {
422        return Err(PayloadError::NonFiniteVector { index });
423    }
424    let vector_bytes = document
425        .vector()
426        .len()
427        .checked_mul(std::mem::size_of::<f32>())
428        .ok_or(PayloadError::LengthOverflow)?;
429    let prefix = match (timestamp.is_some(), include_metadata) {
430        (false, false) => UPSERT_PREFIX_LEN,
431        (true, false) => TIMESTAMPED_UPSERT_PREFIX_LEN,
432        (false, true) => METADATA_UPSERT_PREFIX_LEN,
433        (true, true) => TIMESTAMPED_METADATA_UPSERT_PREFIX_LEN,
434    };
435    let capacity = prefix
436        .checked_add(if include_metadata {
437            document.metadata().len()
438        } else {
439            0
440        })
441        .and_then(|value| value.checked_add(vector_bytes))
442        .ok_or(PayloadError::LengthOverflow)?;
443    let mut payload = Vec::with_capacity(capacity);
444    append_header(&mut payload);
445    append_version(&mut payload, document.version());
446    if let Some(timestamp) = timestamp {
447        payload.extend_from_slice(&timestamp.to_le_bytes());
448    }
449    if include_metadata {
450        let metadata_len =
451            u32::try_from(document.metadata().len()).map_err(|_| PayloadError::LengthOverflow)?;
452        payload.extend_from_slice(&metadata_len.to_le_bytes());
453    }
454    payload.extend_from_slice(&dims.to_le_bytes());
455    if include_metadata {
456        payload.extend_from_slice(document.metadata());
457    }
458    for value in document.vector() {
459        payload.extend_from_slice(&value.to_bits().to_le_bytes());
460    }
461    Ok(payload)
462}
463
464/// Encodes one delete payload as little-endian
465/// `[version:u16, flags:u16, count:u32, doc_id:u128[count]]`.
466pub fn encode_delete(doc_ids: &[DocId]) -> Result<Vec<u8>, PayloadError> {
467    if doc_ids.is_empty() {
468        return Err(PayloadError::EmptyDelete);
469    }
470    let count = u32::try_from(doc_ids.len()).map_err(|_| PayloadError::LengthOverflow)?;
471    let body_bytes = doc_ids
472        .len()
473        .checked_mul(std::mem::size_of::<u128>())
474        .ok_or(PayloadError::LengthOverflow)?;
475    let capacity = DELETE_PREFIX_LEN
476        .checked_add(body_bytes)
477        .ok_or(PayloadError::LengthOverflow)?;
478    let mut payload = Vec::with_capacity(capacity);
479    append_header(&mut payload);
480    payload.extend_from_slice(&count.to_le_bytes());
481    for doc_id in doc_ids {
482        payload.extend_from_slice(&doc_id.get().to_le_bytes());
483    }
484    Ok(payload)
485}
486
487/// Encodes one metadata edit as little-endian fixed identity/type fields plus
488/// a length-delimited value body.
489pub fn encode_metadata_edit(edit: &MetadataEdit) -> Result<Vec<u8>, PayloadError> {
490    let (kind, body) = encode_metadata_value(edit.value())?;
491    let value_len = u32::try_from(body.len()).map_err(|_| PayloadError::LengthOverflow)?;
492    let capacity = METADATA_PREFIX_LEN
493        .checked_add(body.len())
494        .ok_or(PayloadError::LengthOverflow)?;
495    let mut payload = Vec::with_capacity(capacity);
496    append_header(&mut payload);
497    append_version(&mut payload, edit.version());
498    payload.extend_from_slice(&edit.column().get().to_le_bytes());
499    payload.push(kind);
500    payload.extend_from_slice(&[0_u8; 3]);
501    payload.extend_from_slice(&value_len.to_le_bytes());
502    payload.extend_from_slice(&body);
503    Ok(payload)
504}
505
506/// Decodes a payload according to its append-only operation id.
507pub fn decode_mutation(op: u16, payload: &[u8]) -> Result<MutationPayload, PayloadError> {
508    match op {
509        UPSERT_V1 => decode_upsert(payload).map(MutationPayload::Upsert),
510        DELETE_V1 => decode_delete(payload).map(MutationPayload::Delete),
511        METADATA_EDIT_V1 => decode_metadata_edit(payload).map(MutationPayload::MetadataEdit),
512        UPSERT_WITH_TIMESTAMP_V1 => {
513            decode_upsert_with_timestamp(payload).map(MutationPayload::Upsert)
514        }
515        UPSERT_WITH_METADATA_V1 => {
516            decode_upsert_with_metadata(payload).map(MutationPayload::Upsert)
517        }
518        UPSERT_WITH_TIMESTAMP_AND_METADATA_V1 => {
519            decode_upsert_with_timestamp_and_metadata(payload).map(MutationPayload::Upsert)
520        }
521        UPSERT_V2 => decode_upsert_v2(payload).map(MutationPayload::Upsert),
522        UPSERT_V2_BATCH_MEMBER => {
523            decode_upsert_v2_batch_member(payload).map(|(index, count, document)| {
524                MutationPayload::BatchMember {
525                    index,
526                    count,
527                    document,
528                }
529            })
530        }
531        unknown => Err(PayloadError::UnknownOperation(unknown)),
532    }
533}
534
535/// Frames an upsert-v2 payload as member `index` of a `count`-record batch.
536pub fn encode_upsert_v2_batch_member(
537    index: u32,
538    count: u32,
539    upsert_v2: &[u8],
540) -> Result<Vec<u8>, PayloadError> {
541    validate_batch_position(index, count)?;
542    let mut payload = Vec::new();
543    payload
544        .try_reserve_exact(upsert_v2.len().saturating_add(8))
545        .map_err(|_| PayloadError::LengthOverflow)?;
546    payload.extend_from_slice(&index.to_le_bytes());
547    payload.extend_from_slice(&count.to_le_bytes());
548    payload.extend_from_slice(upsert_v2);
549    Ok(payload)
550}
551
552/// Decodes one batch member into `(index, count, document)`.
553pub fn decode_upsert_v2_batch_member(
554    payload: &[u8],
555) -> Result<(u32, u32, IngestDocument), PayloadError> {
556    let mut cursor = Cursor::new(payload);
557    let index = cursor.read_u32()?;
558    let count = cursor.read_u32()?;
559    validate_batch_position(index, count)?;
560    let body = payload.get(8..).ok_or(PayloadError::Truncated)?;
561    decode_upsert_v2(body).map(|document| (index, count, document))
562}
563
564fn validate_batch_position(index: u32, count: u32) -> Result<(), PayloadError> {
565    if count < 2 || index >= count {
566        return Err(PayloadError::BatchPosition { index, count });
567    }
568    Ok(())
569}
570
571/// Decodes one bitmap-based upsert-v2 payload.
572pub fn decode_upsert_v2(payload: &[u8]) -> Result<IngestDocument, PayloadError> {
573    let mut cursor = Cursor::new(payload);
574    let bitmap = cursor.read_u32()?;
575    let reserved = bitmap & !UPSERT_V2_KNOWN_FIELDS;
576    if reserved != 0 {
577        return Err(PayloadError::FieldBitmap(reserved));
578    }
579    let version = cursor.read_version()?;
580    let vector = if bitmap & UPSERT_V2_VECTOR != 0 {
581        let dims = usize::try_from(cursor.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?;
582        if dims == 0 {
583            return Err(PayloadError::EmptyVector);
584        }
585        let mut vector = Vec::with_capacity(dims);
586        for index in 0..dims {
587            let value = f32::from_bits(cursor.read_u32()?);
588            if !value.is_finite() {
589                return Err(PayloadError::NonFiniteVector { index });
590            }
591            vector.push(value);
592        }
593        vector
594    } else {
595        Vec::new()
596    };
597    let text = if bitmap & UPSERT_V2_TEXT != 0 {
598        Some(cursor.read_string()?)
599    } else {
600        None
601    };
602    let timestamp = if bitmap & UPSERT_V2_TIMESTAMP != 0 {
603        Some(i64::from_le_bytes(cursor.take()?))
604    } else {
605        None
606    };
607    let metadata = if bitmap & UPSERT_V2_METADATA != 0 {
608        Some(cursor.read_length_prefixed()?.to_vec())
609    } else {
610        None
611    };
612    let columns = if bitmap & UPSERT_V2_TYPED_COLUMNS != 0 {
613        Some(decode_typed_columns(&mut cursor)?)
614    } else {
615        None
616    };
617    cursor.finish()?;
618    let mut document = IngestDocument::new(version, vector);
619    if let Some(text) = text {
620        document = document.with_text(text);
621    }
622    if let Some(timestamp) = timestamp {
623        document = document.with_timestamp(timestamp);
624    }
625    if let Some(metadata) = metadata {
626        document = document.with_metadata(metadata);
627    }
628    if let Some(columns) = columns {
629        document = document.with_columns(columns);
630    }
631    Ok(document)
632}
633
634fn decode_typed_columns(
635    cursor: &mut Cursor<'_>,
636) -> Result<Vec<(ColumnId, PredicateValue)>, PayloadError> {
637    let count = usize::try_from(cursor.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?;
638    let mut columns = Vec::with_capacity(count);
639    for _ in 0..count {
640        let column = ColumnId::new(cursor.read_u32()?);
641        let kind = cursor.read_u8()?;
642        let reserved = cursor.read_u24()?;
643        if reserved != 0 {
644            return Err(PayloadError::Reserved(reserved));
645        }
646        let body = cursor.read_length_prefixed()?;
647        let value = metadata_to_predicate(decode_metadata_value(kind, body)?)?;
648        columns.push((column, value));
649    }
650    Ok(columns)
651}
652
653pub(crate) fn decode_column_values(
654    encoded: &[u8],
655) -> Result<Vec<(ColumnId, PredicateValue)>, PayloadError> {
656    let mut cursor = Cursor::new(encoded);
657    let columns = decode_typed_columns(&mut cursor)?;
658    cursor.finish()?;
659    Ok(columns)
660}
661
662fn metadata_to_predicate(value: MetadataValue) -> Result<PredicateValue, PayloadError> {
663    match value {
664        MetadataValue::Null => Err(PayloadError::ValueKind(VALUE_NULL)),
665        MetadataValue::Id128(value) => Ok(PredicateValue::Id128(value)),
666        MetadataValue::U64(value) => Ok(PredicateValue::U64(value)),
667        MetadataValue::I64(value) => Ok(PredicateValue::I64(value)),
668        MetadataValue::F64(value) => Ok(PredicateValue::F64(value)),
669        MetadataValue::Bool(value) => Ok(PredicateValue::Bool(value)),
670        MetadataValue::String(value) => Ok(PredicateValue::String(value)),
671    }
672}
673
674/// Decodes and validates one complete upsert payload.
675pub fn decode_upsert(payload: &[u8]) -> Result<IngestDocument, PayloadError> {
676    decode_upsert_body(payload, false, false)
677}
678
679/// Decodes one complete upsert carrying the canonical `ts` value.
680pub fn decode_upsert_with_timestamp(payload: &[u8]) -> Result<IngestDocument, PayloadError> {
681    decode_upsert_body(payload, true, false)
682}
683
684/// Decodes one complete upsert carrying opaque stored metadata.
685pub fn decode_upsert_with_metadata(payload: &[u8]) -> Result<IngestDocument, PayloadError> {
686    decode_upsert_body(payload, false, true)
687}
688
689/// Decodes one complete timestamped upsert carrying opaque stored metadata.
690pub fn decode_upsert_with_timestamp_and_metadata(
691    payload: &[u8],
692) -> Result<IngestDocument, PayloadError> {
693    decode_upsert_body(payload, true, true)
694}
695
696fn decode_upsert_body(
697    payload: &[u8],
698    has_timestamp: bool,
699    has_metadata: bool,
700) -> Result<IngestDocument, PayloadError> {
701    let mut cursor = Cursor::new(payload);
702    cursor.require_header()?;
703    let version = cursor.read_version()?;
704    let timestamp = if has_timestamp {
705        i64::from_le_bytes(cursor.take()?)
706    } else {
707        0
708    };
709    let metadata_len = if has_metadata {
710        usize::try_from(cursor.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?
711    } else {
712        0
713    };
714    let dims = usize::try_from(cursor.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?;
715    if dims == 0 {
716        return Err(PayloadError::EmptyVector);
717    }
718    let body_bytes = dims
719        .checked_mul(std::mem::size_of::<f32>())
720        .ok_or(PayloadError::LengthOverflow)?;
721    let remaining = metadata_len
722        .checked_add(body_bytes)
723        .ok_or(PayloadError::LengthOverflow)?;
724    cursor.require_remaining(remaining)?;
725    let metadata = cursor.read_bytes(metadata_len)?.to_vec();
726    let mut vector = Vec::with_capacity(dims);
727    for index in 0..dims {
728        let value = f32::from_bits(cursor.read_u32()?);
729        if !value.is_finite() {
730            return Err(PayloadError::NonFiniteVector { index });
731        }
732        vector.push(value);
733    }
734    cursor.finish()?;
735    let mut document = IngestDocument::new(version, vector);
736    if has_timestamp {
737        document = document.with_timestamp(timestamp);
738    }
739    if has_metadata {
740        document = document.with_metadata(metadata);
741    }
742    Ok(document)
743}
744
745/// Decodes and validates one complete delete payload.
746pub fn decode_delete(payload: &[u8]) -> Result<Vec<DocId>, PayloadError> {
747    let mut cursor = Cursor::new(payload);
748    cursor.require_header()?;
749    let count = usize::try_from(cursor.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?;
750    if count == 0 {
751        return Err(PayloadError::EmptyDelete);
752    }
753    let body_bytes = count
754        .checked_mul(std::mem::size_of::<u128>())
755        .ok_or(PayloadError::LengthOverflow)?;
756    cursor.require_remaining(body_bytes)?;
757    let mut doc_ids = Vec::with_capacity(count);
758    for _ in 0..count {
759        doc_ids.push(DocId::new(cursor.read_u128()?));
760    }
761    cursor.finish()?;
762    Ok(doc_ids)
763}
764
765/// Decodes and validates one complete metadata-edit payload.
766pub fn decode_metadata_edit(payload: &[u8]) -> Result<MetadataEdit, PayloadError> {
767    let mut cursor = Cursor::new(payload);
768    cursor.require_header()?;
769    let version = cursor.read_version()?;
770    let column = ColumnId::new(cursor.read_u32()?);
771    let kind = cursor.read_u8()?;
772    let reserved = cursor.read_u24()?;
773    if reserved != 0 {
774        return Err(PayloadError::Reserved(reserved));
775    }
776    let value_len =
777        usize::try_from(cursor.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?;
778    let body = cursor.read_bytes(value_len)?;
779    let value = decode_metadata_value(kind, body)?;
780    cursor.finish()?;
781    Ok(MetadataEdit::new(version, column, value))
782}
783
784fn append_header(payload: &mut Vec<u8>) {
785    payload.extend_from_slice(&PAYLOAD_VERSION.to_le_bytes());
786    payload.extend_from_slice(&0_u16.to_le_bytes());
787}
788
789fn append_version(payload: &mut Vec<u8>, version: DocumentVersion) {
790    payload.extend_from_slice(&version.doc_id().get().to_le_bytes());
791    payload.extend_from_slice(&version.revision().get().to_le_bytes());
792}
793
794fn encode_metadata_value(value: &MetadataValue) -> Result<(u8, Vec<u8>), PayloadError> {
795    let encoded = match value {
796        MetadataValue::Null => (VALUE_NULL, Vec::new()),
797        MetadataValue::Id128(value) => (VALUE_ID128, value.get().to_le_bytes().to_vec()),
798        MetadataValue::U64(value) => (VALUE_U64, value.to_le_bytes().to_vec()),
799        MetadataValue::I64(value) => (VALUE_I64, value.to_le_bytes().to_vec()),
800        MetadataValue::F64(value) => (VALUE_F64, value.to_bits().to_le_bytes().to_vec()),
801        MetadataValue::Bool(value) => (VALUE_BOOL, vec![u8::from(*value)]),
802        MetadataValue::String(value) => {
803            u32::try_from(value.len()).map_err(|_| PayloadError::LengthOverflow)?;
804            (VALUE_STRING, value.as_bytes().to_vec())
805        }
806    };
807    Ok(encoded)
808}
809
810fn decode_metadata_value(kind: u8, body: &[u8]) -> Result<MetadataValue, PayloadError> {
811    match kind {
812        VALUE_NULL => {
813            require_value_len(kind, body, 0)?;
814            Ok(MetadataValue::Null)
815        }
816        VALUE_ID128 => {
817            require_value_len(kind, body, 16)?;
818            read_array(body)
819                .map(u128::from_le_bytes)
820                .map(DocId::new)
821                .map(MetadataValue::Id128)
822        }
823        VALUE_U64 => {
824            require_value_len(kind, body, 8)?;
825            read_array(body)
826                .map(u64::from_le_bytes)
827                .map(MetadataValue::U64)
828        }
829        VALUE_I64 => {
830            require_value_len(kind, body, 8)?;
831            read_array(body)
832                .map(i64::from_le_bytes)
833                .map(MetadataValue::I64)
834        }
835        VALUE_F64 => {
836            require_value_len(kind, body, 8)?;
837            read_array(body)
838                .map(u64::from_le_bytes)
839                .map(f64::from_bits)
840                .map(MetadataValue::F64)
841        }
842        VALUE_BOOL => {
843            require_value_len(kind, body, 1)?;
844            match body.first().copied().ok_or(PayloadError::Truncated)? {
845                0 => Ok(MetadataValue::Bool(false)),
846                1 => Ok(MetadataValue::Bool(true)),
847                value => Err(PayloadError::Boolean(value)),
848            }
849        }
850        VALUE_STRING => std::str::from_utf8(body)
851            .map(|value| MetadataValue::String(value.to_owned()))
852            .map_err(|_| PayloadError::Utf8),
853        unknown => Err(PayloadError::ValueKind(unknown)),
854    }
855}
856
857fn require_value_len(kind: u8, body: &[u8], expected: usize) -> Result<(), PayloadError> {
858    if body.len() == expected {
859        Ok(())
860    } else {
861        Err(PayloadError::ValueLength {
862            kind,
863            expected,
864            actual: body.len(),
865        })
866    }
867}
868
869fn read_array<const N: usize>(bytes: &[u8]) -> Result<[u8; N], PayloadError> {
870    <[u8; N]>::try_from(bytes).map_err(|_| PayloadError::Truncated)
871}
872
873struct Cursor<'a> {
874    bytes: &'a [u8],
875    offset: usize,
876}
877
878impl<'a> Cursor<'a> {
879    const fn new(bytes: &'a [u8]) -> Self {
880        Self { bytes, offset: 0 }
881    }
882
883    fn require_header(&mut self) -> Result<(), PayloadError> {
884        let version = self.read_u16()?;
885        if version != PAYLOAD_VERSION {
886            return Err(PayloadError::Version(version));
887        }
888        let flags = self.read_u16()?;
889        if flags != 0 {
890            return Err(PayloadError::Flags(flags));
891        }
892        Ok(())
893    }
894
895    fn read_version(&mut self) -> Result<DocumentVersion, PayloadError> {
896        Ok(DocumentVersion::new(
897            DocId::new(self.read_u128()?),
898            Revision::new(self.read_u64()?),
899        ))
900    }
901
902    fn take<const N: usize>(&mut self) -> Result<[u8; N], PayloadError> {
903        read_array(self.read_bytes(N)?)
904    }
905
906    fn read_bytes(&mut self, length: usize) -> Result<&'a [u8], PayloadError> {
907        let end = self
908            .offset
909            .checked_add(length)
910            .ok_or(PayloadError::LengthOverflow)?;
911        let bytes = self
912            .bytes
913            .get(self.offset..end)
914            .ok_or(PayloadError::Truncated)?;
915        self.offset = end;
916        Ok(bytes)
917    }
918
919    fn require_remaining(&self, length: usize) -> Result<(), PayloadError> {
920        if self.remaining() < length {
921            Err(PayloadError::Truncated)
922        } else {
923            Ok(())
924        }
925    }
926
927    fn read_u8(&mut self) -> Result<u8, PayloadError> {
928        self.take::<1>().map(u8::from_le_bytes)
929    }
930
931    fn read_u16(&mut self) -> Result<u16, PayloadError> {
932        self.take().map(u16::from_le_bytes)
933    }
934
935    fn read_u24(&mut self) -> Result<u32, PayloadError> {
936        let [low, middle, high] = self.take::<3>()?;
937        Ok(u32::from(low) | (u32::from(middle) << 8) | (u32::from(high) << 16))
938    }
939
940    fn read_u32(&mut self) -> Result<u32, PayloadError> {
941        self.take().map(u32::from_le_bytes)
942    }
943
944    fn read_u64(&mut self) -> Result<u64, PayloadError> {
945        self.take().map(u64::from_le_bytes)
946    }
947
948    fn read_u128(&mut self) -> Result<u128, PayloadError> {
949        self.take().map(u128::from_le_bytes)
950    }
951
952    fn read_length_prefixed(&mut self) -> Result<&'a [u8], PayloadError> {
953        let length = usize::try_from(self.read_u32()?).map_err(|_| PayloadError::LengthOverflow)?;
954        self.read_bytes(length)
955    }
956
957    fn read_string(&mut self) -> Result<String, PayloadError> {
958        std::str::from_utf8(self.read_length_prefixed()?)
959            .map(str::to_owned)
960            .map_err(|_| PayloadError::Utf8)
961    }
962
963    fn remaining(&self) -> usize {
964        self.bytes.len().saturating_sub(self.offset)
965    }
966
967    fn finish(self) -> Result<(), PayloadError> {
968        let remaining = self.remaining();
969        if remaining == 0 {
970            Ok(())
971        } else {
972            Err(PayloadError::TrailingBytes(remaining))
973        }
974    }
975}
976
977#[cfg(test)]
978#[allow(clippy::unwrap_used)]
979mod id128_tests {
980    use super::*;
981
982    #[test]
983    fn id128_wal_layout_is_sixteen_little_endian_bytes() {
984        let value = MetadataValue::Id128(DocId::new(0xffeeddccbbaa99887766554433221100));
985        let bytes = vec![
986            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
987            0xee, 0xff,
988        ];
989        assert_eq!(encode_metadata_value(&value).unwrap(), (6, bytes.clone()));
990        assert_eq!(decode_metadata_value(6, &bytes).unwrap(), value);
991        for length in [0, 8, 15, 17] {
992            assert!(decode_metadata_value(6, &vec![0; length]).is_err());
993        }
994    }
995}
996
997#[cfg(test)]
998#[allow(clippy::unwrap_used)]
999mod transaction_format_tests {
1000    use super::*;
1001    fn binding() -> TransactionBinding {
1002        TransactionBinding {
1003            transaction: 1,
1004            participant: 2,
1005            first_seq: 3,
1006            last_seq: 3,
1007            manifest_digest: 4,
1008            final_generation: 5,
1009        }
1010    }
1011    #[test]
1012    #[allow(clippy::indexing_slicing)]
1013    fn prepared_encoding_golden_and_rejections() {
1014        let body = encode_delete(&[DocId::new(6)]).unwrap();
1015        let bytes = encode_prepared(binding(), 0, 1, DELETE_V1, &body).unwrap();
1016        let hex = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
1017        assert_eq!(
1018            hex,
1019            "010000000100000000000000000000000000000002000000000000000000000000000000030000000000000003000000000000000400000000000000050000000000000000000000010000000200010000000100000006000000000000000000000000000000"
1020        );
1021        let decoded = decode_prepared(&bytes).unwrap();
1022        assert_eq!(decoded.binding, binding());
1023        assert_eq!(
1024            decoded.mutation,
1025            MutationPayload::Delete(vec![DocId::new(6)])
1026        );
1027        for end in 0..bytes.len() {
1028            assert!(decode_prepared(&bytes[..end]).is_err(), "{end}");
1029        }
1030        for offset in [0, 2, 4, 20, 36, 60, 68, 72, 76] {
1031            let mut bad = bytes.clone();
1032            bad[offset] = 255;
1033            if matches!(offset, 4 | 20 | 36 | 60) {
1034                // These are valid different bindings, but cannot satisfy the original decision.
1035                if let Ok(member) = decode_prepared(&bad) {
1036                    assert_eq!(
1037                        transaction_decision(member.binding, Some(binding())),
1038                        TransactionDecision::Mismatch
1039                    );
1040                }
1041            } else {
1042                assert!(decode_prepared(&bad).is_err(), "{offset}");
1043            }
1044        }
1045        assert!(encode_prepared(binding(), 0, 1, PREPARED_MUTATION_V1, &bytes).is_err());
1046        let mut trailing = bytes.clone();
1047        trailing.push(0);
1048        assert!(decode_prepared(&trailing).is_err());
1049    }
1050    #[test]
1051    fn transaction_decision_table() {
1052        assert_eq!(
1053            transaction_decision(binding(), None),
1054            TransactionDecision::Undecided
1055        );
1056        assert_eq!(
1057            transaction_decision(binding(), Some(binding())),
1058            TransactionDecision::Committed
1059        );
1060        for field in 0..6 {
1061            let mut changed = binding();
1062            match field {
1063                0 => changed.transaction += 1,
1064                1 => changed.participant += 1,
1065                2 => changed.first_seq += 1,
1066                3 => changed.last_seq += 1,
1067                4 => changed.manifest_digest += 1,
1068                _ => changed.final_generation += 1,
1069            }
1070            assert_eq!(
1071                transaction_decision(binding(), Some(changed)),
1072                TransactionDecision::Mismatch
1073            );
1074        }
1075        let mut invalid = binding();
1076        invalid.transaction = 0;
1077        assert_eq!(
1078            transaction_decision(invalid, None),
1079            TransactionDecision::Mismatch
1080        );
1081    }
1082    #[test]
1083    fn prepared_operation_is_recognized() {
1084        // Valid v1 envelope: tx=1, participant=2, range=3..3,
1085        // digest=4, generation=5, index=0, count=1, delete doc=6.
1086        let mut bytes = vec![1, 0, 0, 0];
1087        bytes.extend_from_slice(&1_u128.to_le_bytes());
1088        bytes.extend_from_slice(&2_u128.to_le_bytes());
1089        for value in [3_u64, 3, 4, 5] {
1090            bytes.extend_from_slice(&value.to_le_bytes());
1091        }
1092        bytes.extend_from_slice(&0_u32.to_le_bytes());
1093        bytes.extend_from_slice(&1_u32.to_le_bytes());
1094        bytes.extend_from_slice(&2_u16.to_le_bytes());
1095        bytes.extend_from_slice(&super::encode_delete(&[super::DocId::new(6)]).unwrap());
1096        assert!(super::decode_prepared(&bytes).is_ok());
1097    }
1098}
1099
1100/// Cross-namespace prepared mutation (append-only operation 9).
1101pub const PREPARED_MUTATION_V1: u16 = 9;
1102
1103/// Exact participant evidence bound by a root decision. All integers are LE;
1104/// identities are 128 bits, sequence/digest/generation fields are 64 bits.
1105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1106pub struct TransactionBinding {
1107    /// Coordinator transaction identity.
1108    pub transaction: u128,
1109    /// Stable participant identity, independent of its route.
1110    pub participant: u128,
1111    /// Inclusive first prepared WAL sequence.
1112    pub first_seq: u64,
1113    /// Inclusive last prepared WAL sequence.
1114    pub last_seq: u64,
1115    /// Xxh3-64 of the selected complete manifest bytes.
1116    pub manifest_digest: u64,
1117    /// Generation installed by this transaction.
1118    pub final_generation: u64,
1119}
1120impl TransactionBinding {
1121    /// Frozen 64-byte binding, shared by root and prepared frames.
1122    pub fn encode(self) -> Result<Vec<u8>, PayloadError> {
1123        self.member_count()?;
1124        let mut bytes = Vec::with_capacity(64);
1125        bytes.extend_from_slice(&self.transaction.to_le_bytes());
1126        bytes.extend_from_slice(&self.participant.to_le_bytes());
1127        for value in [
1128            self.first_seq,
1129            self.last_seq,
1130            self.manifest_digest,
1131            self.final_generation,
1132        ] {
1133            bytes.extend_from_slice(&value.to_le_bytes());
1134        }
1135        Ok(bytes)
1136    }
1137    /// Decodes exactly one frozen binding.
1138    pub fn decode(bytes: &[u8]) -> Result<Self, PayloadError> {
1139        let mut cursor = Cursor::new(bytes);
1140        let binding = Self {
1141            transaction: cursor.read_u128()?,
1142            participant: cursor.read_u128()?,
1143            first_seq: cursor.read_u64()?,
1144            last_seq: cursor.read_u64()?,
1145            manifest_digest: cursor.read_u64()?,
1146            final_generation: cursor.read_u64()?,
1147        };
1148        cursor.finish()?;
1149        binding.member_count()?;
1150        Ok(binding)
1151    }
1152    fn member_count(self) -> Result<u32, PayloadError> {
1153        if self.transaction == 0
1154            || self.participant == 0
1155            || self.first_seq == 0
1156            || self.final_generation == 0
1157        {
1158            return Err(PayloadError::TransactionBinding);
1159        }
1160        self.last_seq
1161            .checked_sub(self.first_seq)
1162            .and_then(|n| n.checked_add(1))
1163            .and_then(|n| u32::try_from(n).ok())
1164            .ok_or(PayloadError::TransactionBinding)
1165    }
1166}
1167
1168/// Decoded prepared member; it is not itself a committed mutation.
1169#[derive(Clone, Debug, PartialEq)]
1170pub struct PreparedMutation {
1171    /// Exact evidence required from the root decision.
1172    pub binding: TransactionBinding,
1173    /// Zero-based member position.
1174    pub index: u32,
1175    /// Number of members in the participant's prepared range.
1176    pub count: u32,
1177    /// Existing standalone operation (never another envelope).
1178    pub op: u16,
1179    /// Validated standalone mutation.
1180    pub mutation: MutationPayload,
1181}
1182
1183/// `[version:u16,flags:u16,binding:64,index:u32,count:u32,op:u16,body]`.
1184pub fn encode_prepared(
1185    binding: TransactionBinding,
1186    index: u32,
1187    count: u32,
1188    op: u16,
1189    body: &[u8],
1190) -> Result<Vec<u8>, PayloadError> {
1191    if count != binding.member_count()? || index >= count {
1192        return Err(PayloadError::BatchPosition { index, count });
1193    }
1194    standalone_mutation(op, body)?;
1195    let mut bytes = Vec::new();
1196    bytes
1197        .try_reserve_exact(
1198            body.len()
1199                .checked_add(78)
1200                .ok_or(PayloadError::LengthOverflow)?,
1201        )
1202        .map_err(|_| PayloadError::LengthOverflow)?;
1203    append_header(&mut bytes);
1204    bytes.extend_from_slice(&binding.encode()?);
1205    bytes.extend_from_slice(&index.to_le_bytes());
1206    bytes.extend_from_slice(&count.to_le_bytes());
1207    bytes.extend_from_slice(&op.to_le_bytes());
1208    bytes.extend_from_slice(body);
1209    Ok(bytes)
1210}
1211
1212/// Validates an envelope without deciding whether it committed.
1213pub fn decode_prepared(bytes: &[u8]) -> Result<PreparedMutation, PayloadError> {
1214    let mut cursor = Cursor::new(bytes);
1215    cursor.require_header()?;
1216    let binding = TransactionBinding::decode(cursor.read_bytes(64)?)?;
1217    let index = cursor.read_u32()?;
1218    let count = cursor.read_u32()?;
1219    if count != binding.member_count()? || index >= count {
1220        return Err(PayloadError::BatchPosition { index, count });
1221    }
1222    let op = cursor.read_u16()?;
1223    let mutation = standalone_mutation(op, cursor.read_bytes(cursor.remaining())?)?;
1224    Ok(PreparedMutation {
1225        binding,
1226        index,
1227        count,
1228        op,
1229        mutation,
1230    })
1231}
1232fn standalone_mutation(op: u16, bytes: &[u8]) -> Result<MutationPayload, PayloadError> {
1233    if !(UPSERT_V1..=UPSERT_V2).contains(&op) {
1234        return Err(PayloadError::UnknownOperation(op));
1235    }
1236    decode_mutation(op, bytes)
1237}
1238
1239/// Pure root-decision result; absence is explicitly uncommitted.
1240#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1241pub enum TransactionDecision {
1242    /// All participant evidence agrees.
1243    Committed,
1244    /// No decision exists for these frames.
1245    Undecided,
1246    /// A decision exists but contradicts the participant evidence.
1247    Mismatch,
1248}
1249/// Compares identities, inclusive WAL range, manifest digest and generation.
1250pub fn transaction_decision(
1251    prepared: TransactionBinding,
1252    selected: Option<TransactionBinding>,
1253) -> TransactionDecision {
1254    match selected {
1255        None if prepared.member_count().is_ok() => TransactionDecision::Undecided,
1256        Some(binding) if prepared.member_count().is_ok() && binding == prepared => {
1257            TransactionDecision::Committed
1258        }
1259        _ => TransactionDecision::Mismatch,
1260    }
1261}