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

The single-writer GenServer for one session's journal (see the package
README, "The journal").

Exactly one Writer per session enforces the single-writer invariant. It:

  * appends framed JSONL (one complete self-delimited JSON object per line),
    assigning each event a monotonic `offset` = the event's id;
  * batches `:file.datasync` on mailbox drain with a `≤200ms` ceiling, but syncs
    **immediately** for side-effect events (`tool_result`, `approval`, ...);
  * never uses `delayed_write`;
  * rotates to the next `NNNNNN.jsonl` segment when one passes the size cap;
  * writes `meta.json` and `HEAD` **atomically only** (temp + fsync + rename).

`HEAD` records the last *durable* offset (+ config) and is persisted together
with each datasync, never in place.

## Single-writer across OS processes

The process name is `{:global, {Writer, key}}`, keyed on the session
directory's device and inode (its symlink-resolved path where a directory
has no inode), so every spelling of one directory names one Writer. But
`:global` only spans a CONNECTED cluster — two unclustered OS processes on
one session dir would each start a Writer and mint duplicate offsets,
permanently damaging the journal. A lock file (`writer.lock`) closes the
same-host case. It holds the writer's OS pid and a token for when that BEAM
started, and is removed on terminate if it is still this Writer's. The
token is `ticks:<n>` on Linux (`starttime` in `/proc/<pid>/stat`, clock
ticks since boot) and `lstart:<time>` elsewhere (`ps -o lstart=`, read with
`LC_ALL=C TZ=UTC`). Linux does not use `ps`: procps derives `lstart` from
the current time and the uptime on every run, so one process can print two
start times.

The lock appears already filled: its content is written to
`writer.lock.tmp.<our pid>` and that file is linked to `writer.lock`, which
fails if a lock exists. No reader sees an empty or partial lock, so an
empty one is a leftover and stale.

A holder counts as alive only when `kill -0 <pid>` succeeds and the pid is
a `beam` whose start equals the recorded token, so a pid the OS reused for
another program, or for a later BEAM, is stale. So is a pid `kill -0` may
not signal (another account's process): a private journal's directory is
0700, and no other account's BEAM can hold its lock. A lock with no token,
or a token of another kind than this host reads, is judged by the pid and
the `beam` name alone. A holder whose state cannot be read (`ps` fails, or
this BEAM cannot read its own start) counts as alive: only "no such
process" makes it gone. `kill` and `ps` are run from `/bin` or `/usr/bin`
when they are there, and looked up on `PATH` only otherwise.

A lock naming this BEAM's own pid is stale: a second Writer for the same
directory cannot be running here.

A stale lock is removed inside a gate, `writer.lock.reclaim`, which is
created the same way as the lock, and only if it still reads as it did when
it was judged; the reclaimer then creates `writer.lock` as above. So one
reclaimer acts at a time and none removes a lock created after it looked. A
claimer that finds a live BEAM in the gate is refused: that BEAM, or a
claimer that beats it to the lock, is taking the journal. A gate whose
holder is gone was left by a reclaimer that died inside it, and the next one
removes it. Residual: two claimers that judge such a leftover gate at once
can both enter.

Init refuses when a confirmed-live foreign BEAM holds the lock. Where no
lock can be taken (non-Unix, no `kill`, the lock file cannot be created)
the Writer proceeds without one, so a lock-subsystem hiccup never blocks
journaling, unless it was started with `require_lock: true`: then it
refuses too, and `FileStore.open/2` answers
`{:error, {:journal_locked, :lock_unavailable}}`.

A refusal is `:ignore`, never `{:stop, reason}`: an abnormal init exit also
reaches the linked opener as an exit signal, which kills a non-trapping
caller before it can read the `{:error, _}` return. `FileStore.open/2`
turns the `:ignore` into `{:error, {:journal_locked, _}}`; what the opener
does then is its own choice (a surface carries on unjournaled, a session
that requires its journal does not start).

## Write-failure tolerance

A failing disk (e.g. ENOSPC) surfaces as a logged
`[:raxol, :agent, :journal, :write_failed]` event, never a Writer crash: the
append's own `:file.write` already returns `{:error, _}`, and the follow-on
`datasync`, `HEAD`/`meta` atomic writes, and segment rotation all degrade
(log + continue, retry on the next append) rather than MatchError-exiting the
process out from under every open handle on the session.

## Chained journals

A journal created with `chain: true` records `"chain": true` in `meta.json`
and is chained for life, whatever later openers pass. Each record is sealed
by `Raxol.Agent.Journal.Chain` (`prev_hash` + `hash`), written as its
canonical JSON, and `HEAD` carries the tip hash beside the durable offset.
If the chain does not verify when the Writer starts, it refuses every
append with `{:error, :damaged}` and leaves `HEAD` untouched: a broken
chain cannot be extended, and its anchor is evidence.

## Private journals

With `private: true` every file this Writer creates -- segments,
`meta.json`, `HEAD`, `writer.lock` and the temp files behind atomic writes
-- is chmod 0600 before a byte is written to it, and a segment it reopens
is tightened the same way. `Raxol.Agent.Journal.FileStore.open/2` has
already made the directories 0700, so no other account can open a file in
the moment between its creation and the chmod.

# `append`

```elixir
@spec append(pid(), map()) :: {:ok, non_neg_integer()} | {:error, term()}
```

# `append_checked`

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

Atomic check-and-append: run `check` against the freshest on-disk records
and append `event` only if it returns `:ok` — as ONE step inside the single
Writer, so no other append can interleave between the check and the append.

Closes the read-then-append race of a caller that validates against its own
(possibly stale) `read` snapshot: e.g. the checkpoint turn-boundary rule,
where a concurrent `turn_started` through a shared joiner handle must not
slip in between validation and commit.

`check` receives the current record list and returns `:ok` or `{:error,
reason}` (relayed verbatim; nothing appended). A raising `check` replies
`{:error, {:check_raised, ...}}` — fail-closed — rather than crashing the
Writer out from under every other handle on the session.

# `append_many`

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

Append `events` as consecutive records in ONE Writer call, so no other
append can land between them. Returns their offsets in order. Nothing is
appended when any event fails to encode (or, on a chained journal, to seal).

`timeout` bounds the call (default 5_000 ms). A caller that times out does
not withdraw the request: it stays queued and the events land later.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `default_schema_version`

```elixir
@spec default_schema_version() :: String.t()
```

The `schema_version` stamped on records written without an explicit override.

Bumping this is a freeze event: a bump must arrive with a frozen golden
corpus under `priv/golden/` for the version being left behind, so every
historical shape stays replayable by a future upcast-on-read. See the
package README, "Frozen golden corpora".

# `flush`

```elixir
@spec flush(pid()) :: :ok
```

# `lock_holder`

```elixir
@spec lock_holder(Path.t()) :: String.t() | nil
```

The OS pid holding `dir`'s writer lock, or reclaiming a stale one, when it
is a CONFIRMED-LIVE foreign BEAM, else `nil` (no lock, a stale one, our own,
or liveness unprovable).

A read-only pre-flight for `FileStore.open/2`, so a refusal is reported as
a plain `{:error, {:journal_locked, holder}}` naming the holder. It does not
replace the claim in `init/1` — that is the authority, this is the message.

# `lock_path`

```elixir
@spec lock_path(Path.t()) :: Path.t()
```

The path of `dir`'s writer lock file.

# `start_link`

# `whereis`

```elixir
@spec whereis(Path.t()) :: pid() | nil
```

The Writer that has the journal in `dir` open, or `nil`.

---

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