Skip to main content

plugmem_core/
journal.rs

1//! Journal record framing.
2//!
3//! The journal is an append-only sequence of framed records:
4//!
5//! ```text
6//! [len u32 LE][check u32 LE][op u8][payload ...]     len = 1 + payload
7//! ```
8//!
9//! `check` is the low 32 bits of xxh3-64 over `[op][payload]`. Framing is
10//! op-agnostic — the op byte's meaning (Remember, Revise, …) belongs to
11//! the engine's replay layer, which lands with the verbs.
12//!
13//! # Torn tails vs corruption
14//!
15//! There is exactly one writer and appends are sequential, so a crash can
16//! only leave a *prefix* of the last record. That yields a clean rule for
17//! [`scan`]:
18//!
19//! - a record whose frame extends past the end of the buffer is the torn
20//!   tail: the scan succeeds, drops it, and reports `truncated_tail`;
21//! - a complete frame with a bad checksum that ends exactly at the buffer
22//!   end is also treated as a torn tail (a torn write inside the payload
23//!   of the final record looks like this);
24//! - any other inconsistency — a bad checksum mid-stream, a `len` of 0
25//!   (no valid record has one, and a torn prefix of ≥ 4 bytes always
26//!   carries a valid `len`) — is [`Error::Corrupt`].
27
28use alloc::vec::Vec;
29
30use xxhash_rust::xxh3::xxh3_64;
31
32use crate::error::Error;
33
34/// Serialized width of a `u32` field.
35const U32_BYTES: usize = core::mem::size_of::<u32>();
36/// Serialized width of a `u64` field.
37const U64_BYTES: usize = core::mem::size_of::<u64>();
38/// Serialized width of an `f32` field.
39const F32_BYTES: usize = core::mem::size_of::<f32>();
40
41/// Frame header size: `len` (u32) + `check` (u32).
42const HEADER: usize = U32_BYTES + U32_BYTES;
43
44/// One decoded journal record, borrowing the scanned buffer.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize))]
47pub struct JournalEntry<'a> {
48    /// Operation tag (engine-defined op table).
49    pub op: u8,
50    /// The operation's binary payload.
51    pub payload: &'a [u8],
52}
53
54/// Result of scanning a journal buffer.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct JournalScan<'a> {
57    /// All valid records, in append order.
58    pub entries: Vec<JournalEntry<'a>>,
59    /// `true` when a torn tail record was dropped (crash between appends —
60    /// reported in the open report, not an error).
61    pub truncated_tail: bool,
62}
63
64/// Checksum over the contiguous `[op][payload]` body slice: low 32 bits
65/// of xxh3-64.
66fn body_checksum(body: &[u8]) -> u32 {
67    xxh3_64(body) as u32
68}
69
70/// Appends one framed record to `out` (the bytes handed to
71/// [`Storage::append_journal`](crate::storage::Storage::append_journal)).
72pub fn encode_entry(out: &mut Vec<u8>, op: u8, payload: &[u8]) {
73    let len = 1 + payload.len();
74    let len32 = u32::try_from(len).expect("journal payload fits u32 by construction");
75    out.reserve(HEADER + len);
76    out.extend_from_slice(&len32.to_le_bytes());
77    // The checksum needs the contiguous body; build it in place and hash
78    // the slice we just wrote.
79    let check_pos = out.len();
80    out.extend_from_slice(&[0u8; U32_BYTES]);
81    out.push(op);
82    out.extend_from_slice(payload);
83    let check = body_checksum(&out[check_pos + U32_BYTES..]);
84    out[check_pos..check_pos + U32_BYTES].copy_from_slice(&check.to_le_bytes());
85}
86
87/// One decoded engine operation (op table). `Revise` is
88/// `Remember` with `revises` set — the two share a payload, only the op
89/// byte differs.
90///
91/// Not `Eq`: the raw `f32` vector rides along so replay can re-quantize
92/// it deterministically, and `f32` is only `PartialEq`.
93#[derive(Clone, Debug, PartialEq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize))]
95pub enum Op<'a> {
96    /// Op 1/2: a new fact (op 2 additionally closes `revises`).
97    Remember {
98        /// Host timestamp of the operation.
99        now: u64,
100        /// Resolved validity start (the engine defaults it before
101        /// journaling — replay never re-derives).
102        valid_from: u64,
103        /// Subject entity name, if any.
104        entity: Option<&'a str>,
105        /// Fact text.
106        text: &'a str,
107        /// Tags, verbatim.
108        tags: Vec<&'a str>,
109        /// `(rel, target_entity)` link pairs.
110        links: Vec<(&'a str, &'a str)>,
111        /// The raw embedding as remembered (empty = none). Stored
112        /// pre-quantization so replay re-quantizes with the same pure
113        /// function and reproduces every slot byte for byte.
114        vector: Vec<f32>,
115        /// Metadata key→value pairs as remembered (empty = none). Replay
116        /// re-canonicalizes them (sorts, dedups) the same way `remember` did,
117        /// so the stored blob is reproduced byte for byte.
118        metadata: Vec<(&'a str, &'a str)>,
119        /// Predecessor being revised ([`crate::id::FactId::NONE`] for op 1).
120        revises: crate::id::FactId,
121        /// The fact id assigned at execution time — authoritative on
122        /// replay.
123        assigned: crate::id::FactId,
124    },
125    /// Op 3: tombstone a fact.
126    Forget {
127        /// Host timestamp of the operation.
128        now: u64,
129        /// The fact being forgotten.
130        fact: crate::id::FactId,
131    },
132    /// Op 4: upsert a typed edge between two entities.
133    Link {
134        /// Host timestamp of the operation.
135        now: u64,
136        /// Source entity name.
137        src: &'a str,
138        /// Relation term, verbatim.
139        rel: &'a str,
140        /// Destination entity name.
141        dst: &'a str,
142        /// Provenance fact, or [`crate::id::FactId::NONE`].
143        provenance: crate::id::FactId,
144    },
145    /// Op 5: marker that a maintenance pass ran at this point.
146    Maintain {
147        /// Host timestamp of the operation.
148        now: u64,
149        /// Maintenance mode encoded by the engine.
150        mode: u8,
151        /// HNSW insertion budget, or `u32::MAX` for unlimited.
152        max_hnsw_inserts: u32,
153    },
154    /// Op 6: close the current typed edge between two entities.
155    Unlink {
156        /// Host timestamp of the operation.
157        now: u64,
158        /// Source entity name.
159        src: &'a str,
160        /// Relation term, verbatim.
161        rel: &'a str,
162        /// Destination entity name.
163        dst: &'a str,
164    },
165}
166
167/// Appends a length-prefixed string (`u32 LE` + bytes).
168fn put_str(out: &mut Vec<u8>, s: &str) {
169    out.extend_from_slice(&(s.len() as u32).to_le_bytes());
170    out.extend_from_slice(s.as_bytes());
171}
172
173/// Reads a length-prefixed string; advances `at`.
174fn take_str<'a>(bytes: &'a [u8], at: &mut usize) -> Result<&'a str, Error> {
175    let len = take_u32(bytes, at)? as usize;
176    let end = at
177        .checked_add(len)
178        .filter(|&e| e <= bytes.len())
179        .ok_or(Error::Corrupt("journal string overruns its record"))?;
180    let s = core::str::from_utf8(&bytes[*at..end])
181        .map_err(|_| Error::Corrupt("journal string is not UTF-8"))?;
182    *at = end;
183    Ok(s)
184}
185
186/// Reads a `u32 LE`; advances `at`.
187fn take_u32(bytes: &[u8], at: &mut usize) -> Result<u32, Error> {
188    let end = *at + U32_BYTES;
189    if end > bytes.len() {
190        return Err(Error::Corrupt("journal record truncated inside a field"));
191    }
192    let v = u32::from_le_bytes(bytes[*at..end].try_into().unwrap());
193    *at = end;
194    Ok(v)
195}
196
197/// Reads a `u64 LE`; advances `at`.
198fn take_u64(bytes: &[u8], at: &mut usize) -> Result<u64, Error> {
199    let end = *at + U64_BYTES;
200    if end > bytes.len() {
201        return Err(Error::Corrupt("journal record truncated inside a field"));
202    }
203    let v = u64::from_le_bytes(bytes[*at..end].try_into().unwrap());
204    *at = end;
205    Ok(v)
206}
207
208/// Reads a `u32 LE` count followed by that many `f32 LE`; advances `at`.
209fn take_vec_f32(bytes: &[u8], at: &mut usize) -> Result<Vec<f32>, Error> {
210    let count = take_u32(bytes, at)? as usize;
211    // Bounds math in u64, like the snapshot container: on 32-bit targets
212    // `count * F32_BYTES` in usize can wrap and slip a hostile count past the
213    // check, and `with_capacity` on an unchecked count aborts a wasm32
214    // process (caught by the wasm32-wasip1 test run). Only after the
215    // check is the allocation known to be bounded by the input length.
216    let end = *at as u64 + count as u64 * F32_BYTES as u64;
217    if end > bytes.len() as u64 {
218        return Err(Error::Corrupt("journal vector overruns its record"));
219    }
220    let end = end as usize;
221    let mut v = Vec::with_capacity(count);
222    let mut p = *at;
223    while p < end {
224        v.push(f32::from_le_bytes(
225            bytes[p..p + F32_BYTES].try_into().unwrap(),
226        ));
227        p += F32_BYTES;
228    }
229    *at = end;
230    Ok(v)
231}
232
233impl<'a> Op<'a> {
234    /// Encodes the operation as one framed journal entry appended to
235    /// `out` (via [`encode_entry`]).
236    pub fn encode(&self, out: &mut Vec<u8>) {
237        let mut payload = Vec::new();
238        let op = match self {
239            Op::Remember {
240                now,
241                valid_from,
242                entity,
243                text,
244                tags,
245                links,
246                vector,
247                metadata,
248                revises,
249                assigned,
250            } => {
251                payload.extend_from_slice(&now.to_le_bytes());
252                payload.extend_from_slice(&valid_from.to_le_bytes());
253                payload.extend_from_slice(&revises.0.to_le_bytes());
254                payload.extend_from_slice(&assigned.0.to_le_bytes());
255                match entity {
256                    Some(name) => {
257                        payload.push(1);
258                        put_str(&mut payload, name);
259                    }
260                    None => payload.push(0),
261                }
262                put_str(&mut payload, text);
263                payload.push(tags.len() as u8);
264                for tag in tags {
265                    put_str(&mut payload, tag);
266                }
267                payload.push(links.len() as u8);
268                for (rel, dst) in links {
269                    put_str(&mut payload, rel);
270                    put_str(&mut payload, dst);
271                }
272                payload.extend_from_slice(&(vector.len() as u32).to_le_bytes());
273                for &x in vector {
274                    payload.extend_from_slice(&x.to_le_bytes());
275                }
276                payload.extend_from_slice(&(metadata.len() as u32).to_le_bytes());
277                for (k, v) in metadata {
278                    put_str(&mut payload, k);
279                    put_str(&mut payload, v);
280                }
281                if revises.is_none() { 1 } else { 2 }
282            }
283            Op::Forget { now, fact } => {
284                payload.extend_from_slice(&now.to_le_bytes());
285                payload.extend_from_slice(&fact.0.to_le_bytes());
286                3
287            }
288            Op::Link {
289                now,
290                src,
291                rel,
292                dst,
293                provenance,
294            } => {
295                payload.extend_from_slice(&now.to_le_bytes());
296                payload.extend_from_slice(&provenance.0.to_le_bytes());
297                put_str(&mut payload, src);
298                put_str(&mut payload, rel);
299                put_str(&mut payload, dst);
300                4
301            }
302            Op::Unlink { now, src, rel, dst } => {
303                payload.extend_from_slice(&now.to_le_bytes());
304                put_str(&mut payload, src);
305                put_str(&mut payload, rel);
306                put_str(&mut payload, dst);
307                6
308            }
309            Op::Maintain {
310                now,
311                mode,
312                max_hnsw_inserts,
313            } => {
314                payload.extend_from_slice(&now.to_le_bytes());
315                payload.push(*mode);
316                payload.extend_from_slice(&max_hnsw_inserts.to_le_bytes());
317                5
318            }
319        };
320        encode_entry(out, op, &payload);
321    }
322
323    /// Decodes one operation from a scanned entry. The payload is
324    /// untrusted (the checksum guards transport integrity, not origin):
325    /// every read is bounds-checked, malformed input is
326    /// [`Error::Corrupt`], never a panic.
327    pub fn decode(op: u8, payload: &'a [u8]) -> Result<Op<'a>, Error> {
328        use crate::id::FactId;
329        let at = &mut 0usize;
330        let decoded = match op {
331            1 | 2 => {
332                let now = take_u64(payload, at)?;
333                let valid_from = take_u64(payload, at)?;
334                let revises = FactId(take_u32(payload, at)?);
335                let assigned = FactId(take_u32(payload, at)?);
336                if (op == 2) == revises.is_none() {
337                    return Err(Error::Corrupt("journal revises field disagrees with op"));
338                }
339                let entity = match payload.get(*at) {
340                    Some(0) => {
341                        *at += 1;
342                        None
343                    }
344                    Some(1) => {
345                        *at += 1;
346                        Some(take_str(payload, at)?)
347                    }
348                    _ => return Err(Error::Corrupt("journal entity flag is invalid")),
349                };
350                let text = take_str(payload, at)?;
351                let tag_cnt = *payload
352                    .get(*at)
353                    .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
354                *at += 1;
355                let mut tags = Vec::with_capacity(tag_cnt as usize);
356                for _ in 0..tag_cnt {
357                    tags.push(take_str(payload, at)?);
358                }
359                let link_cnt = *payload
360                    .get(*at)
361                    .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
362                *at += 1;
363                let mut links = Vec::with_capacity(link_cnt as usize);
364                for _ in 0..link_cnt {
365                    let rel = take_str(payload, at)?;
366                    let dst = take_str(payload, at)?;
367                    links.push((rel, dst));
368                }
369                let vector = take_vec_f32(payload, at)?;
370                let meta_cnt = take_u32(payload, at)?;
371                let mut metadata = Vec::new();
372                for _ in 0..meta_cnt {
373                    let k = take_str(payload, at)?;
374                    let v = take_str(payload, at)?;
375                    metadata.push((k, v));
376                }
377                Op::Remember {
378                    now,
379                    valid_from,
380                    entity,
381                    text,
382                    tags,
383                    links,
384                    vector,
385                    metadata,
386                    revises,
387                    assigned,
388                }
389            }
390            3 => Op::Forget {
391                now: take_u64(payload, at)?,
392                fact: FactId(take_u32(payload, at)?),
393            },
394            4 => {
395                let now = take_u64(payload, at)?;
396                let provenance = FactId(take_u32(payload, at)?);
397                let src = take_str(payload, at)?;
398                let rel = take_str(payload, at)?;
399                let dst = take_str(payload, at)?;
400                Op::Link {
401                    now,
402                    src,
403                    rel,
404                    dst,
405                    provenance,
406                }
407            }
408            5 => {
409                let now = take_u64(payload, at)?;
410                let (mode, max_hnsw_inserts) = if *at == payload.len() {
411                    (0, u32::MAX)
412                } else {
413                    let mode = *payload
414                        .get(*at)
415                        .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
416                    *at += 1;
417                    let max_hnsw_inserts = take_u32(payload, at)?;
418                    (mode, max_hnsw_inserts)
419                };
420                Op::Maintain {
421                    now,
422                    mode,
423                    max_hnsw_inserts,
424                }
425            }
426            6 => {
427                let now = take_u64(payload, at)?;
428                let src = take_str(payload, at)?;
429                let rel = take_str(payload, at)?;
430                let dst = take_str(payload, at)?;
431                Op::Unlink { now, src, rel, dst }
432            }
433            _ => return Err(Error::Corrupt("unknown journal op")),
434        };
435        if *at != payload.len() {
436            return Err(Error::Corrupt("journal record has trailing bytes"));
437        }
438        Ok(decoded)
439    }
440}
441
442/// Scans a whole journal buffer into records (validation + tail-recovery
443/// rules in the module docs).
444pub fn scan(journal: &[u8]) -> Result<JournalScan<'_>, Error> {
445    let mut entries = Vec::new();
446    let mut pos = 0usize;
447    while pos < journal.len() {
448        let rest = &journal[pos..];
449        if rest.len() < HEADER {
450            return Ok(JournalScan {
451                entries,
452                truncated_tail: true,
453            });
454        }
455        let len = u32::from_le_bytes(rest[..U32_BYTES].try_into().unwrap()) as usize;
456        if len == 0 {
457            return Err(Error::Corrupt("journal record with zero length"));
458        }
459        // `HEADER + len` is computed with a checked add: on a 32-bit target
460        // `len` can reach u32::MAX and the bare `HEADER + len` overflows
461        // usize (a debug-build panic — the loader must never panic on any
462        // bytes). An overflow means the record claims more than any buffer
463        // can hold, so it is a torn tail like the `get` miss below.
464        let Some(body) = HEADER
465            .checked_add(len)
466            .and_then(|end| rest.get(HEADER..end))
467        else {
468            return Ok(JournalScan {
469                entries,
470                truncated_tail: true,
471            });
472        };
473        let want = u32::from_le_bytes(rest[U32_BYTES..HEADER].try_into().unwrap());
474        if body_checksum(body) != want {
475            if pos + HEADER + len == journal.len() {
476                return Ok(JournalScan {
477                    entries,
478                    truncated_tail: true,
479                });
480            }
481            return Err(Error::Corrupt("journal checksum mismatch mid-stream"));
482        }
483        entries.push(JournalEntry {
484            op: body[0],
485            payload: &body[1..],
486        });
487        pos += HEADER + len;
488    }
489    Ok(JournalScan {
490        entries,
491        truncated_tail: false,
492    })
493}