Raxol.Agent.Journal.Records.Checkpoint.FileBackend (Raxol Journal v2.8.0)

Copy Markdown View Source

The file-store backend for Raxol.Agent.Journal.Records.Checkpoint. Implements the checkpoint pointer-record discipline over a Raxol.Agent.Journal.FileStore session.

A checkpoint is an in-log pointer, not a second store: the snapshot payload is content-addressed and written out-of-line to <session>/snapshots/<sha256>.json (atomic temp+fsync+rename) before the pointer record is appended through the one single Writer, consuming exactly one offset from the one id space. The record body carries only the pointer + hash + tip — never model content.

Write path (write/3)

  1. read the journal (tolerant Reader; STRING-keyed maps);
  2. resolve tip_offset (explicit opt, else the derived conversational tip);
  3. reject an invalid tip (:invalid_tip) — the named record must exist and be CONVERSATIONAL on the branch; nothing is appended;
  4. reject a mid-turn / mid-reserve write (:mid_turn / :mid_reserve);
  5. stage the snapshot file (content-addressed, atomic) — skipped for a nil model, which is a legal tip-only pointer;
  6. append the pointer record through the single Writer ⇒ {:ok, offset}. The turn-boundary check is re-run atomically at the commit point (FileStore.append_checked/3 — the check executes inside the single Writer against the freshest on-disk records), so a concurrent turn_started landing between step 1's read and the append can never slip a checkpoint mid-turn. Step 4 is only the cheap pre-flight that avoids staging a snapshot file destined for rejection.

Restore path (restore/2)

Locate the newest checkpoint (highest offset), then:

  • tip-only pointer (snapshot_ref == nil) ⇒ full fold(0..tip_offset);
  • else verify sha256(bytes) == snapshot_hash (mismatch ⇒ :snapshot_corrupt; absent file ⇒ :snapshot_missing; both leave the journal :ok and delete nothing), load the snapshot, then fold the CONVERSATIONAL tail (records with id > tip_offset) forward onto it.

A restore equals a full fold over the persistent slice.

Restore-path hardening

  • Bounded nesting BEFORE decode — the raw snapshot bytes are pre-scanned (nesting_within_bound?/1, an O(n) byte walk that never builds a term) and rejected :snapshot_corrupt when JSON nesting exceeds @max_decode_depth — before Jason.decode/1 ever runs, so a hostile small-but-deep snapshot can never force the parser to materialize an unbounded term. The depth-bounded structural walk (safe_term?/2) still runs after decode as defense in depth.
  • $s deref-gadget guard — a decoded map carrying a "$s" struct-module tag is rejected (:snapshot_corrupt); this backend NEVER resolves a caller-controlled atom to a module or calls struct/2 on untrusted disk data (type-confusion / atom-table-DoS class).
  • Malformed-pointer reject — snapshot_ref must match snapshots/<64-hex>.json exactly; anything else (path traversal, wrong shape) is refused before any file read, so a hostile pointer can never escape the session's snapshots/ directory.
  • Snapshot-size ceiling — a snapshot file over @max_snapshot_bytes is refused (:snapshot_corrupt) rather than loaded whole.

Model fold (surrogate)

This backend owns the pointer discipline, not the model. It has no codec for a harness model and no reducer for its events, so both are surrogates: dump/1 writes a JSON-native model as it is, and the fold (applying conversational events onto a model) appends each CONVERSATIONAL event's id to model["applied"]. The round-trip equation (a restore equals a full fold) does not depend on the codec.

Surrogate safety (this backend is the DEFAULT — it must fail loud)

Because this backend is the active default, the surrogate is gated rather than trusted:

  • Write — dump/1 typed-rejects any model the surrogate JSON codec cannot faithfully round-trip (structs, tuples, pids, refs, functions, atom keys/values, non-UTF-8 binaries, non-map top-level): {:error, :surrogate_backend_unbound}, nothing staged, nothing appended — never an uncaught Jason.EncodeError crashing the caller.
  • Restore — every successful restore through the surrogate is LOUD: it emits [:raxol, :agent, :journal, :checkpoint, :surrogate] telemetry (metadata %{op: :restore, session_id:, tip_offset:} — offsets only, never content) and logs a warning, so a caller can never mistake the surrogate %{"applied" => [ids]} fold for real harness state. Writes that stage a surrogate snapshot emit the same event with op: :write.

No redaction (sanitize / secret exclusion)

The surrogate performs NO redaction: a JSON-safe model is written verbatim (plaintext) to snapshots/ — the same trust domain and permissions as the journal segments, which already persist event payloads in plaintext. The record body itself never carries model content. A codec that replaces dump/1 must bring redaction with it.

Summary

Functions

Restore ONE specific already-read checkpoint record — the hardened single-checkpoint restore a compaction resume delegates to for its newest-first walk. restore/2 is exactly this applied to the newest checkpoint; both share the same validation + snapshot hardening so compaction-restore IS checkpoint-restore (structural resume-equivalence).

Functions

restore_checkpoint(journal, records, checkpoint)

Restore ONE specific already-read checkpoint record — the hardened single-checkpoint restore a compaction resume delegates to for its newest-first walk. restore/2 is exactly this applied to the newest checkpoint; both share the same validation + snapshot hardening so compaction-restore IS checkpoint-restore (structural resume-equivalence).