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