# `Raxol.Agent.Journal.FileStore`
[🔗](https://github.com/DROOdotFOO/raxol/blob/v2.8.0/lib/raxol/agent/journal/file_store.ex#L1)

File-backed `Raxol.Agent.Journal` — one directory per session, the directory
*is* the session (portable, tar-able, rsync-able, grep-able).

    <base>/<session_id>/
    ├── meta.json          # created_at, cwd, git branch, title, schema_version
    ├── HEAD               # sidecar: current durable offset + config
    ├── writer.lock        # the live Writer's OS pid + BEAM start time
    ├── journal/
    │   ├── 000001.jsonl   # framed JSONL, size-capped ascending segments
    │   └── 000002.jsonl
    └── snapshots/         # checkpoint payloads, named by SHA-256

The base directory defaults to `~/.raxol/sessions/`, overridable via the
`:base_dir` option or the `RAXOL_SESSIONS_DIR` env var.

Storage splits into a single-writer GenServer
(`Raxol.Agent.Journal.FileStore.Writer`) and a tolerant replay reader
(`Raxol.Agent.Journal.FileStore.Reader`). See those modules and
`Raxol.Agent.Journal` for the durability and torn-tail-recovery contract.

## Chained journals

`open(session_id, chain: true)` creates a hash-chained journal (see
`Raxol.Agent.Journal.Chain`): every record carries `prev_hash` and `hash`,
`meta.json` records `"chain": true`, and `HEAD` anchors the tip hash. The
mode is fixed when the journal is created; a chained journal stays chained
whatever later openers pass, and `chain: true` on an existing unchained
journal is refused with `{:error, :unchained_journal}`. `read/2`, `status/1`
and `verify/1` all walk the chain, so a broken one is never surfaced.

Removing the flag from `meta.json` does not unchain a journal: one that
still carries a chain marker (`HEAD`'s `tip_hash`, the first record's
`prev_hash`/`hash`) reads as damaged from offset 1 and its Writer refuses
every append (see `Raxol.Agent.Journal.FileStore.Reader`).

## Private journals

`open(session_id, private: true)` keeps the journal to the account that
writes it: the session directory, `journal/` and `snapshots/` are 0700 and
every file the Writer creates is 0600 before a byte is written to it. The
base and session directories must be real directories (not symlinks), owned
by this account and writable by no one else; otherwise the open is refused
with `{:error, {:untrusted_dir, {path, reason}}}` and nothing is created.
`reason` is `:symlink`, `{:not_a_directory, type}`, `{:stat_failed, posix}`,
`{:not_owned, uid}` (the directory's owner) or
`{:group_or_other_writable, mode}`. A Windows host reports neither a uid nor
POSIX mode bits, so the owner and mode rules pass there.

# `t`

```elixir
@type t() :: %Raxol.Agent.Journal.FileStore{
  dir: Path.t(),
  owner?: boolean(),
  session_id: String.t(),
  writer: pid()
}
```

# `append_checked`

```elixir
@spec append_checked(t(), map(), ([map()] -&gt; :ok | {:error, term()})) ::
  {:ok, non_neg_integer()} | {:error, term()}
```

Atomic check-and-append: `check` runs against the freshest on-disk records
INSIDE the single Writer, and `event` is appended only if it returns `:ok` —
one atomic step, so no concurrent append (e.g. through a shared joiner
handle) can interleave between validation and commit. See
`Raxol.Agent.Journal.FileStore.Writer.append_checked/3`.

# `append_many`

```elixir
@spec append_many(t(), [map()], timeout()) ::
  {:ok, [non_neg_integer()]} | {:error, term()}
```

Append `events` as consecutive records in one Writer call: no other append
can land between them. Returns their offsets in order; nothing is appended
when any event fails to encode or seal.

`timeout` bounds the Writer call (default 5_000 ms, `:infinity` to wait as
long as the Writer takes). A call that times out returns `{:error,
{:writer_down, :timeout}}`, but the request stays queued in the Writer and
the events land later: a caller that must not treat a slow fsync as a
failure should pass `:infinity`.

# `high_watermark`

```elixir
@spec high_watermark(term(), keyword()) ::
  non_neg_integer() | {:error, :invalid_session_id}
```

The current durable high-watermark (highest offset) for `session_id`, read
read-side via the tolerant Reader — `0` for an empty/absent/damaged journal,
`{:error, :invalid_session_id}` for an id that names no session directory.

`Raxol.Agent.McpTools` reads it as an agent's event count.

# `open`

Open (creating if needed) the journal for `session_id`.

Options:

  * `:base_dir` — base directory (defaults to `$RAXOL_SESSIONS_DIR` or `~/.raxol/sessions`).
  * `:segment_cap` — segment size cap in bytes before rotation (default 8 MiB).
  * `:immediate_sync_types` — event `type`s that force an immediate datasync
    (default `["tool_result", "approval"]`).
  * `:schema_version`, `:cwd`, `:git_branch`, `:title` — recorded in `meta.json` on first open.
  * `:chain` — `true` to create a hash-chained journal (see "Chained journals").
  * `:private` — `true` for a journal only this account can read (see
    "Private journals"): directories 0700, files 0600, and an untrusted base
    or session directory refused with `{:error, {:untrusted_dir, {path, reason}}}`.
  * `:require_lock` — `true` to refuse a journal whose cross-process writer
    lock cannot be taken (no `kill`, a non-Unix host, the lock file cannot be
    created) with `{:error, {:journal_locked, :lock_unavailable}}`, instead
    of writing without one. A live holder is refused either way, as
    `{:error, {:journal_locked, os_pid}}`;
    `Raxol.Agent.Journal.FileStore.Writer.lock_path/1` names the lock file.

# `read_meta`

```elixir
@spec read_meta(t()) :: {:ok, map()} | {:error, term()}
```

The decoded `meta.json` of an open journal: what its first opener recorded
(`"cwd"`, `"title"`, `"created_at"`, ...). `{:error, reason}` when the file
is missing or is not a JSON object.

# `read_records`

```elixir
@spec read_records(term(), keyword()) ::
  {:ok, [map()]} | {:error, :damaged | :invalid_session_id}
```

Read `session_id`'s durable records read-side via the tolerant Reader
(writerless-safe — no Writer needed). `{:error, :damaged}` on interior
corruption (the damaged content is never surfaced),
`{:error, :invalid_session_id}` for an id that names no session directory.
Read by `Raxol.Agent.McpTools` and `Raxol.Broker.Journal.Replay`.

# `session_dir`

```elixir
@spec session_dir(String.t(), keyword()) :: Path.t()
```

Resolve `session_id` to its on-disk session directory under the configured
base (`:base_dir` option, `$RAXOL_SESSIONS_DIR`, or `~/.raxol/sessions`).

This is the single resolution point `open/2` and the read-side functions
share, so a session opened for writing and one read without a Writer always
name the same directory.

# `verify`

Walk the hash chain of an open journal: `:ok`, `{:broken, offset}` for the
first record that does not verify, or `{:error, :unchained}` for a journal
created without `chain: true` and carrying no chain marker. A chained
journal whose `meta.json` flag was removed is `{:broken, 1}`.

# `verify_session`

```elixir
@spec verify_session(String.t(), keyword()) ::
  :ok | {:broken, non_neg_integer()} | {:error, :unchained}
```

`verify/1` read-side, without a Writer: safe against a journal another OS
process is appending to, and on read-only media. Used by replay tooling.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
