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