Raxol. AgentClientProtocol. Ext. Journal behaviour
(Raxol Agent Client Protocol v0.1.0)
Copy Markdown
View Source
The durable session journal — behaviour + live-bus registration facade.
This is the foundation cell of the reattach extension. Sessions hold only ephemeral turn state; the DURABLE state lives here, and a reattach replays it from an exact offset.
THE OFFSET LAW (storage-independent)
- Offsets are per-session positive integers starting at 1, contiguous,
strictly increasing in append order. Assigned atomically inside
append(offset = currenthigh_watermark+ 1), under the Writer's single-mailbox serialization. Never reused, never reordered, never reassigned. high_watermark(j)returns the last DURABLE offset (0= empty session), read from the store — a conforming impl MUST NOT serve a cached counter that can lag or lead the durable state.read(j, from, to)returns exactly the records withfrom <= offset <= to, in offset order, value-identical to whatappendstored.- A record, once
appendreturns{:ok, record}, is read-visible: ahigh_watermarkorreadissued after that return observes it. This is what makes the Writer's "append THEN publish" order meaningful. - Genesis: offset 1 of a journal-managed session is ALWAYS a
"session_created"record (appended by the Writer on first use). - Retention: grow-only, no truncation.
The handle is PER-SESSION
The opaque handle j binds session_id, so the callbacks are
append(j, entry) / read(j, from, to) / high_watermark(j).
session_id is carried inside j and stamped
into every record; a per-session :ordered_set keyed by offset is the ETS
layout (Raxol.AgentClientProtocol.Ext.Journal.Mem).
Storage callbacks
open(session_id, opts)— construct a per-session handle. The store (ETS table / on-disk log) is owned by a process that OUTLIVES any single Writer (the app supervisor in production), so the journal survives Writer death — the precondition for the Writer-restart tip-fold orphan repair.append(j, entry)— assign the next offset atomically, stampsession_idts_hook, persist, and return the COMPLETE stamped%Record{}(the Writer needs the whole record to publish it — the offset isrecord.offset).entryis%{kind:, payload:, taint:}.
read(j, from, to)— offset-ordered records in[from, to].high_watermark(j)— last durable offset, read from the store.close(j)— release the handle (grow-only convenience; not load-bearing).
Live-bus registration (facade → Writer)
subscribe/2 and unsubscribe/2 are the "live bus registration" surface. They
are DELIBERATELY NOT storage callbacks: registration must be serialized
against appends in ONE mailbox — the Writer's — so the ordering between a
registration and any append is a mailbox-order guarantee, not a memory-model
one. These facade functions therefore route to
Raxol.AgentClientProtocol.Ext.Journal.Writer (the single publisher), which
owns the subscriber set in its own state. A bare journal-level registry insert
would break that seam.
Summary
Types
An append request: the Writer supplies kind/payload/taint; the store stamps the rest.
Opaque, per-session storage handle.
Functions
Start-or-lookup the single-publisher Writer for session_id, lazily, under
the package Application's WriterSupervisor (a concurrent
:already_started is folded into {:ok, pid}). Idempotent: a live
Writer for the session is returned as-is ({:ok, pid}) without a second start.
Register subscriber for the live tail of session_id. Routed through the
session's Writer so it is serialized against appends in ONE mailbox.
Returns {:error, :no_writer} if no live Writer serves the session
(writerless / history-only sessions have no live tail).
Idempotently detach subscriber from the live tail of session_id. A missing
Writer is a no-op (:ok) — there is nothing to detach from.
Types
Callbacks
@callback append(j(), entry()) :: {:ok, Raxol.AgentClientProtocol.Ext.Journal.Record.t()} | {:error, :read_only | term()}
@callback close(j()) :: :ok
@callback high_watermark(j()) :: non_neg_integer()
@callback read(j(), from :: pos_integer(), to :: non_neg_integer()) :: {:ok, [Raxol.AgentClientProtocol.Ext.Journal.Record.t()]} | {:error, term()}
Functions
@spec ensure_writer(String.t(), {module(), term()}, keyword()) :: {:ok, pid()} | {:error, :no_supervisor | term()}
Start-or-lookup the single-publisher Writer for session_id, lazily, under
the package Application's WriterSupervisor (a concurrent
:already_started is folded into {:ok, pid}). Idempotent: a live
Writer for the session is returned as-is ({:ok, pid}) without a second start.
journal is the {module, handle} durable store for the session; the handle's
owner MUST outlive the Writer (so a restarted Writer can tip-fold). opts
are extra Writer.start_link/1 opts (e.g. :session_meta). Children start
:transient — a Writer that exits :normal is not resurrected, but a crash is
restarted so the live tail survives (the journal, not the Writer, is durable).
Returns {:error, :no_supervisor} when the WriterSupervisor is not running
(the Application did not start / an embedder omitted it from children/0) —
fail soft, never raise into a turn.
Register subscriber for the live tail of session_id. Routed through the
session's Writer so it is serialized against appends in ONE mailbox.
Returns {:error, :no_writer} if no live Writer serves the session
(writerless / history-only sessions have no live tail).
Idempotently detach subscriber from the live tail of session_id. A missing
Writer is a no-op (:ok) — there is nothing to detach from.