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    /// Op 7: remove one tag from every current fact by creating revisions.
166    RemoveTag {
167        /// Host timestamp used as every successor's validity start.
168        now: u64,
169        /// Verbatim tag name.
170        tag: &'a str,
171    },
172    /// Op 8: assign the semantic identity of an empty vector pool.
173    SetVectorSpace {
174        /// Stable, human-readable embedding-space identity.
175        space: &'a str,
176    },
177}
178
179/// Appends a length-prefixed string (`u32 LE` + bytes).
180fn put_str(out: &mut Vec<u8>, s: &str) {
181    out.extend_from_slice(&(s.len() as u32).to_le_bytes());
182    out.extend_from_slice(s.as_bytes());
183}
184
185/// Reads a length-prefixed string; advances `at`.
186fn take_str<'a>(bytes: &'a [u8], at: &mut usize) -> Result<&'a str, Error> {
187    let len = take_u32(bytes, at)? as usize;
188    let end = at
189        .checked_add(len)
190        .filter(|&e| e <= bytes.len())
191        .ok_or(Error::Corrupt("journal string overruns its record"))?;
192    let s = core::str::from_utf8(&bytes[*at..end])
193        .map_err(|_| Error::Corrupt("journal string is not UTF-8"))?;
194    *at = end;
195    Ok(s)
196}
197
198/// Reads a `u32 LE`; advances `at`.
199fn take_u32(bytes: &[u8], at: &mut usize) -> Result<u32, Error> {
200    let end = *at + U32_BYTES;
201    if end > bytes.len() {
202        return Err(Error::Corrupt("journal record truncated inside a field"));
203    }
204    let v = u32::from_le_bytes(bytes[*at..end].try_into().unwrap());
205    *at = end;
206    Ok(v)
207}
208
209/// Reads a `u64 LE`; advances `at`.
210fn take_u64(bytes: &[u8], at: &mut usize) -> Result<u64, Error> {
211    let end = *at + U64_BYTES;
212    if end > bytes.len() {
213        return Err(Error::Corrupt("journal record truncated inside a field"));
214    }
215    let v = u64::from_le_bytes(bytes[*at..end].try_into().unwrap());
216    *at = end;
217    Ok(v)
218}
219
220/// Reads a `u32 LE` count followed by that many `f32 LE`; advances `at`.
221fn take_vec_f32(bytes: &[u8], at: &mut usize) -> Result<Vec<f32>, Error> {
222    let count = take_u32(bytes, at)? as usize;
223    // Bounds math in u64, like the snapshot container: on 32-bit targets
224    // `count * F32_BYTES` in usize can wrap and slip a hostile count past the
225    // check, and `with_capacity` on an unchecked count aborts a wasm32
226    // process (caught by the wasm32-wasip1 test run). Only after the
227    // check is the allocation known to be bounded by the input length.
228    let end = *at as u64 + count as u64 * F32_BYTES as u64;
229    if end > bytes.len() as u64 {
230        return Err(Error::Corrupt("journal vector overruns its record"));
231    }
232    let end = end as usize;
233    let mut v = Vec::with_capacity(count);
234    let mut p = *at;
235    while p < end {
236        v.push(f32::from_le_bytes(
237            bytes[p..p + F32_BYTES].try_into().unwrap(),
238        ));
239        p += F32_BYTES;
240    }
241    *at = end;
242    Ok(v)
243}
244
245impl<'a> Op<'a> {
246    /// Encodes the operation as one framed journal entry appended to
247    /// `out` (via [`encode_entry`]).
248    pub fn encode(&self, out: &mut Vec<u8>) {
249        let mut payload = Vec::new();
250        let op = match self {
251            Op::Remember {
252                now,
253                valid_from,
254                entity,
255                text,
256                tags,
257                links,
258                vector,
259                metadata,
260                revises,
261                assigned,
262            } => {
263                payload.extend_from_slice(&now.to_le_bytes());
264                payload.extend_from_slice(&valid_from.to_le_bytes());
265                payload.extend_from_slice(&revises.0.to_le_bytes());
266                payload.extend_from_slice(&assigned.0.to_le_bytes());
267                match entity {
268                    Some(name) => {
269                        payload.push(1);
270                        put_str(&mut payload, name);
271                    }
272                    None => payload.push(0),
273                }
274                put_str(&mut payload, text);
275                payload.push(tags.len() as u8);
276                for tag in tags {
277                    put_str(&mut payload, tag);
278                }
279                payload.push(links.len() as u8);
280                for (rel, dst) in links {
281                    put_str(&mut payload, rel);
282                    put_str(&mut payload, dst);
283                }
284                payload.extend_from_slice(&(vector.len() as u32).to_le_bytes());
285                for &x in vector {
286                    payload.extend_from_slice(&x.to_le_bytes());
287                }
288                payload.extend_from_slice(&(metadata.len() as u32).to_le_bytes());
289                for (k, v) in metadata {
290                    put_str(&mut payload, k);
291                    put_str(&mut payload, v);
292                }
293                if revises.is_none() { 1 } else { 2 }
294            }
295            Op::Forget { now, fact } => {
296                payload.extend_from_slice(&now.to_le_bytes());
297                payload.extend_from_slice(&fact.0.to_le_bytes());
298                3
299            }
300            Op::Link {
301                now,
302                src,
303                rel,
304                dst,
305                provenance,
306            } => {
307                payload.extend_from_slice(&now.to_le_bytes());
308                payload.extend_from_slice(&provenance.0.to_le_bytes());
309                put_str(&mut payload, src);
310                put_str(&mut payload, rel);
311                put_str(&mut payload, dst);
312                4
313            }
314            Op::Unlink { now, src, rel, dst } => {
315                payload.extend_from_slice(&now.to_le_bytes());
316                put_str(&mut payload, src);
317                put_str(&mut payload, rel);
318                put_str(&mut payload, dst);
319                6
320            }
321            Op::Maintain {
322                now,
323                mode,
324                max_hnsw_inserts,
325            } => {
326                payload.extend_from_slice(&now.to_le_bytes());
327                payload.push(*mode);
328                payload.extend_from_slice(&max_hnsw_inserts.to_le_bytes());
329                5
330            }
331            Op::RemoveTag { now, tag } => {
332                payload.extend_from_slice(&now.to_le_bytes());
333                put_str(&mut payload, tag);
334                7
335            }
336            Op::SetVectorSpace { space } => {
337                put_str(&mut payload, space);
338                8
339            }
340        };
341        encode_entry(out, op, &payload);
342    }
343
344    /// Decodes one operation from a scanned entry. The payload is
345    /// untrusted (the checksum guards transport integrity, not origin):
346    /// every read is bounds-checked, malformed input is
347    /// [`Error::Corrupt`], never a panic.
348    pub fn decode(op: u8, payload: &'a [u8]) -> Result<Op<'a>, Error> {
349        use crate::id::FactId;
350        let at = &mut 0usize;
351        let decoded = match op {
352            1 | 2 => {
353                let now = take_u64(payload, at)?;
354                let valid_from = take_u64(payload, at)?;
355                let revises = FactId(take_u32(payload, at)?);
356                let assigned = FactId(take_u32(payload, at)?);
357                if (op == 2) == revises.is_none() {
358                    return Err(Error::Corrupt("journal revises field disagrees with op"));
359                }
360                let entity = match payload.get(*at) {
361                    Some(0) => {
362                        *at += 1;
363                        None
364                    }
365                    Some(1) => {
366                        *at += 1;
367                        Some(take_str(payload, at)?)
368                    }
369                    _ => return Err(Error::Corrupt("journal entity flag is invalid")),
370                };
371                let text = take_str(payload, at)?;
372                let tag_cnt = *payload
373                    .get(*at)
374                    .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
375                *at += 1;
376                let mut tags = Vec::with_capacity(tag_cnt as usize);
377                for _ in 0..tag_cnt {
378                    tags.push(take_str(payload, at)?);
379                }
380                let link_cnt = *payload
381                    .get(*at)
382                    .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
383                *at += 1;
384                let mut links = Vec::with_capacity(link_cnt as usize);
385                for _ in 0..link_cnt {
386                    let rel = take_str(payload, at)?;
387                    let dst = take_str(payload, at)?;
388                    links.push((rel, dst));
389                }
390                let vector = take_vec_f32(payload, at)?;
391                let meta_cnt = take_u32(payload, at)?;
392                let mut metadata = Vec::new();
393                for _ in 0..meta_cnt {
394                    let k = take_str(payload, at)?;
395                    let v = take_str(payload, at)?;
396                    metadata.push((k, v));
397                }
398                Op::Remember {
399                    now,
400                    valid_from,
401                    entity,
402                    text,
403                    tags,
404                    links,
405                    vector,
406                    metadata,
407                    revises,
408                    assigned,
409                }
410            }
411            3 => Op::Forget {
412                now: take_u64(payload, at)?,
413                fact: FactId(take_u32(payload, at)?),
414            },
415            4 => {
416                let now = take_u64(payload, at)?;
417                let provenance = FactId(take_u32(payload, at)?);
418                let src = take_str(payload, at)?;
419                let rel = take_str(payload, at)?;
420                let dst = take_str(payload, at)?;
421                Op::Link {
422                    now,
423                    src,
424                    rel,
425                    dst,
426                    provenance,
427                }
428            }
429            5 => {
430                let now = take_u64(payload, at)?;
431                let (mode, max_hnsw_inserts) = if *at == payload.len() {
432                    (0, u32::MAX)
433                } else {
434                    let mode = *payload
435                        .get(*at)
436                        .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
437                    *at += 1;
438                    let max_hnsw_inserts = take_u32(payload, at)?;
439                    (mode, max_hnsw_inserts)
440                };
441                Op::Maintain {
442                    now,
443                    mode,
444                    max_hnsw_inserts,
445                }
446            }
447            6 => {
448                let now = take_u64(payload, at)?;
449                let src = take_str(payload, at)?;
450                let rel = take_str(payload, at)?;
451                let dst = take_str(payload, at)?;
452                Op::Unlink { now, src, rel, dst }
453            }
454            7 => {
455                let now = take_u64(payload, at)?;
456                let tag = take_str(payload, at)?;
457                Op::RemoveTag { now, tag }
458            }
459            8 => Op::SetVectorSpace {
460                space: take_str(payload, at)?,
461            },
462            _ => return Err(Error::Corrupt("unknown journal op")),
463        };
464        if *at != payload.len() {
465            return Err(Error::Corrupt("journal record has trailing bytes"));
466        }
467        Ok(decoded)
468    }
469}
470
471/// Scans a whole journal buffer into records (validation + tail-recovery
472/// rules in the module docs).
473pub fn scan(journal: &[u8]) -> Result<JournalScan<'_>, Error> {
474    let mut entries = Vec::new();
475    let mut pos = 0usize;
476    while pos < journal.len() {
477        let rest = &journal[pos..];
478        if rest.len() < HEADER {
479            return Ok(JournalScan {
480                entries,
481                truncated_tail: true,
482            });
483        }
484        let len = u32::from_le_bytes(rest[..U32_BYTES].try_into().unwrap()) as usize;
485        if len == 0 {
486            return Err(Error::Corrupt("journal record with zero length"));
487        }
488        // `HEADER + len` is computed with a checked add: on a 32-bit target
489        // `len` can reach u32::MAX and the bare `HEADER + len` overflows
490        // usize (a debug-build panic — the loader must never panic on any
491        // bytes). An overflow means the record claims more than any buffer
492        // can hold, so it is a torn tail like the `get` miss below.
493        let Some(body) = HEADER
494            .checked_add(len)
495            .and_then(|end| rest.get(HEADER..end))
496        else {
497            return Ok(JournalScan {
498                entries,
499                truncated_tail: true,
500            });
501        };
502        let want = u32::from_le_bytes(rest[U32_BYTES..HEADER].try_into().unwrap());
503        if body_checksum(body) != want {
504            if pos + HEADER + len == journal.len() {
505                return Ok(JournalScan {
506                    entries,
507                    truncated_tail: true,
508                });
509            }
510            return Err(Error::Corrupt("journal checksum mismatch mid-stream"));
511        }
512        entries.push(JournalEntry {
513            op: body[0],
514            payload: &body[1..],
515        });
516        pos += HEADER + len;
517    }
518    Ok(JournalScan {
519        entries,
520        truncated_tail: false,
521    })
522}