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

Copy Markdown View Source

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-hex sha256 of 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 :ok and nothing is deleted.
  • snapshot_ref: nil is a legal tip-only pointer; restore then falls back to a full fold(0..tip_offset).
  • tip_offset is frozen at write time and validated at write time: the record it names MUST satisfy conversational?/1 on the checkpoint's own branch, else {:error, :invalid_tip} and nothing is appended.
  • A checkpoint MUST NOT be appended mid-turn (between turn_started and 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" in Checkpoint.FileBackend's moduledoc.

Summary

Types

A folded harness model: snapshot content, opaque to this module.

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

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

model()

@type model() :: term()

A folded harness model: snapshot content, opaque to this module.

t()

@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

protected_floor(journal, records)

@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(journal, opts)

@callback restore(journal :: term(), opts :: keyword()) ::
  {:ok, model()} | {:error, term()}

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).

restore_checkpoint(journal, records, checkpoint)

@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.

write(journal, model, opts)

@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

kind()

@spec kind() :: String.t()

The record kind string every checkpoint carries.

protected_floor(records)

@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.

protected_floor(journal, records)

@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.

put_backend(backend)

@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.

reasons()

@spec reasons() :: [String.t()]

The v1 grow-only reason enum accepted by the producer seam.

record_keys()

@spec record_keys() :: [String.t()]

The complete frozen key set (plus the defaulted branch_id) of a checkpoint record.

restore(journal, opts \\ [])

@spec restore(term(), keyword()) :: {:ok, model()} | {:error, term()}

See restore/2. Dispatches to the active backend (default FileBackend).

restore_checkpoint(journal, records, checkpoint)

@spec restore_checkpoint(term(), [map()], map()) :: {:ok, model()} | {:error, term()}

See restore_checkpoint/3. Dispatches to the active backend (default FileBackend).

turn_closers()

@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.

write(journal, model, opts \\ [])

@spec write(term(), model(), keyword()) ::
  {:ok, Raxol.Agent.Journal.FileStore.offset()} | {:error, term()}

See write/3. Dispatches to the active backend (default FileBackend).