Checkpoint pointer records.
A checkpoint is an in-log pointer record, not a second store. The snapshot
payload lives content-addressed and out-of-line at
<session>/snapshots/<sha256-hex>.json; the checkpoint record carries only
the pointer + hash + the conversational tip it was taken at. It is appended
through the same single Writer as every other record, consuming exactly one
offset from the one id space.
The record shape (t/0) and the write/3 / restore/2 seam are a behaviour.
The default backend (no registration) is the file-store FileBackend, and
write/restore dispatch to it. {:error, :not_implemented} is only the
explicit nil-backend opt-out.
Write discipline (frozen)
- The snapshot file is written before the record is appended (a record cannot be named by its own offset before it exists), via an atomic temp+fsync+rename. A crash between file-write and record-append leaves a harmless orphan file, which is never deleted implicitly.
snapshot_hash= lowercase-hexsha256of the snapshot file bytes; it is verified at restore. A missing file →{:error, :snapshot_missing}; a hash mismatch →{:error, :snapshot_corrupt}— and in both cases the journal stays:okand nothing is deleted.snapshot_ref: nilis a legal tip-only pointer; restore then falls back to a fullfold(0..tip_offset).tip_offsetis frozen at write time and validated at write time: the record it names MUST satisfyconversational?/1on the checkpoint's own branch, else{:error, :invalid_tip}and nothing is appended.- A checkpoint MUST NOT be appended mid-turn (between
turn_startedand its close) nor mid-reserve (between a spend-gate reserve and its terminal), else{:error, :mid_turn}/{:error, :mid_reserve}. - The record body carries no model content — pointer + hash only.
The default backend's surrogate codec typed-rejects any non-JSON-native
model (
{:error, :surrogate_backend_unbound}) and writes a JSON-safe model verbatim, with no redaction — see "No redaction" inCheckpoint.FileBackend's moduledoc.
Summary
Types
A folded harness model: snapshot content, opaque to this module.
A checkpoint record — the frozen on-disk shape (string keys on disk; this is
the Elixir-side view). Every field below is frozen: none is
ever renamed, repurposed, type-narrowed, or flipped optional→required.
Callbacks
The GC-protected floor for journal's already-read records: the tip_offset
of the newest healthy checkpoint (its snapshot present + hash-verified,
restore succeeds). Records at or above this offset MUST NEVER be truncated.
:none when no healthy checkpoint exists.
Restore the model captured by the latest healthy checkpoint of journal.
Restore the model captured by ONE specific already-read checkpoint record of
journal (the hardened single-checkpoint restore).
Write a checkpoint for journal capturing model.
Functions
The record kind string every checkpoint carries.
Record-only variant of protected_floor/2 for callers WITHOUT a journal
handle: it can validate record shape but cannot probe snapshot health, so it
returns the newest well-formed checkpoint's tip. On its own it is NOT
sufficient for a truncation admission decision under the fall-back resume
model — a shape-valid checkpoint whose snapshot file is corrupt keeps its
higher tip here, while protected_floor/2 lands the floor on the older
healthy tip resume actually falls back to. A GC writer with a journal handle
MUST use protected_floor/2.
The GC-protected floor for journal's already-read records: records at or
above this offset MUST NEVER be truncated, so the checkpoint restore path
stays intact (frozen law: GC never orphans checkpoints).
Register a module implementing this behaviour as the active backend, or nil
to force the inert {:error, :not_implemented} seam. Absence ⇒ the default
FileBackend.
The v1 grow-only reason enum accepted by the producer seam.
The complete frozen key set (plus the defaulted branch_id) of a checkpoint record.
See restore/2. Dispatches to the active backend (default FileBackend).
See restore_checkpoint/3. Dispatches to the active backend (default FileBackend).
The record types that close a turn_started. A checkpoint is refused as
:mid_turn while more turns were started than closed.
See write/3. Dispatches to the active backend (default FileBackend).
Types
@type model() :: term()
A folded harness model: snapshot content, opaque to this module.
@type t() :: %{ id: non_neg_integer(), schema_version: String.t(), kind: String.t(), branch_id: String.t(), session_id: String.t(), ts: integer(), tip_offset: pos_integer(), snapshot_ref: String.t() | nil, snapshot_hash: String.t() | nil, reason: String.t() }
A checkpoint record — the frozen on-disk shape (string keys on disk; this is
the Elixir-side view). Every field below is frozen: none is
ever renamed, repurposed, type-narrowed, or flipped optional→required.
Callbacks
@callback protected_floor(journal :: term(), records :: [map()]) :: {:offset, pos_integer()} | :none
The GC-protected floor for journal's already-read records: the tip_offset
of the newest healthy checkpoint (its snapshot present + hash-verified,
restore succeeds). Records at or above this offset MUST NEVER be truncated.
:none when no healthy checkpoint exists.
Restore the model captured by the latest healthy checkpoint of journal.
Locates the latest healthy checkpoint, verifies sha256(bytes) == snapshot_hash, loads the snapshot, and folds the journal forward
from tip_offset — equal to a full fold over the persistent slice. A
missing/corrupt snapshot surfaces
{:error, :snapshot_missing | :snapshot_corrupt} with the journal left :ok
and nothing deleted. A tip-only checkpoint (snapshot_ref: nil) falls
back to a full fold(0..tip_offset).
@callback restore_checkpoint(journal :: term(), records :: [map()], checkpoint :: map()) :: {:ok, model()} | {:error, term()}
Restore the model captured by ONE specific already-read checkpoint record of
journal (the hardened single-checkpoint restore).
This is the seam a compaction resume delegates to for its newest-first walk:
it applies the SAME restore-path hardening as restore/2
(tip_offset/snapshot_ref presence guard ⇒ :malformed_checkpoint;
@ref_re path-traversal reject ⇒ :malformed_pointer; snapshot size ceiling,
depth-bounded decode, $s deref-gadget guard ⇒ :snapshot_corrupt; hash
verify ⇒ :snapshot_corrupt; absent file ⇒ :snapshot_missing) — the journal
stays :ok and nothing is deleted on any failure. restore/2 is
exactly restore_checkpoint/3 applied to the newest checkpoint.
@callback write(journal :: term(), model :: model(), opts :: keyword()) :: {:ok, Raxol.Agent.Journal.FileStore.offset()} | {:error, term()}
Write a checkpoint for journal capturing model.
Snapshot-file-before-record, single-Writer append, tip validated at write,
turn-boundary enforced. Returns {:ok, offset} (the checkpoint's own dense
journal offset) or a typed {:error, reason}
(:invalid_tip | :mid_turn | :mid_reserve | ...).
Options: :reason (one of reasons/0, default "manual"), :tip_offset
(explicit tip to freeze; derived from the journal's conversational tip when
absent), :base_dir.
Functions
@spec kind() :: String.t()
The record kind string every checkpoint carries.
@spec protected_floor([map()]) :: {:offset, pos_integer()} | :none
Record-only variant of protected_floor/2 for callers WITHOUT a journal
handle: it can validate record shape but cannot probe snapshot health, so it
returns the newest well-formed checkpoint's tip. On its own it is NOT
sufficient for a truncation admission decision under the fall-back resume
model — a shape-valid checkpoint whose snapshot file is corrupt keeps its
higher tip here, while protected_floor/2 lands the floor on the older
healthy tip resume actually falls back to. A GC writer with a journal handle
MUST use protected_floor/2.
Fail-closed on malformed checkpoints: the
tolerant Reader accepts a truncated or hostile checkpoint record missing
id/tip_offset (or carrying non-integer junk). Restore typed-rejects
those (:malformed_checkpoint); this floor — the "never orphan a
checkpoint" safety check — must not raise a KeyError on the same records.
When ANY checkpoint record is malformed the floor is unknowable, so it
degrades to {:offset, 1}: the entire journal is protected and every
truncation proposal is rejected. Protecting too much is safe; guessing is
not.
@spec protected_floor(term(), [map()]) :: {:offset, pos_integer()} | :none
The GC-protected floor for journal's already-read records: records at or
above this offset MUST NEVER be truncated, so the checkpoint restore path
stays intact (frozen law: GC never orphans checkpoints).
It is the tip_offset of the newest healthy checkpoint — the one resume
actually restores from. "Healthy" means its snapshot is present and
hash-verified (restore succeeds); a corrupt newest is skipped so the floor
lands on the older healthy checkpoint a fall-back restore uses, never on
a corrupt newest's higher tip (which would let GC truncate records the
fall-back fold still needs — an orphaned fall-back tip). :none when no
healthy checkpoint exists (nothing to protect).
A future gc/truncation writer MUST consult this and reject any proposal
whose truncation range reaches this offset — rejected at the same synchronous
admission seam as :invalid_tip, never accepted-then-marked.
Dispatches to the active backend (default FileBackend) because health is a
snapshot-file property, not a record-only one. This arity is the
authoritative floor for a truncation admission decision;
protected_floor/1 is the record-only conservative variant for callers
without a journal handle.
@spec put_backend(module() | nil) :: :ok
Register a module implementing this behaviour as the active backend, or nil
to force the inert {:error, :not_implemented} seam. Absence ⇒ the default
FileBackend.
@spec reasons() :: [String.t()]
The v1 grow-only reason enum accepted by the producer seam.
@spec record_keys() :: [String.t()]
The complete frozen key set (plus the defaulted branch_id) of a checkpoint record.
See restore/2. Dispatches to the active backend (default FileBackend).
See restore_checkpoint/3. Dispatches to the active backend (default FileBackend).
@spec turn_closers() :: [String.t()]
The record types that close a turn_started. A checkpoint is refused as
:mid_turn while more turns were started than closed.
@spec write(term(), model(), keyword()) :: {:ok, Raxol.Agent.Journal.FileStore.offset()} | {:error, term()}
See write/3. Dispatches to the active backend (default FileBackend).