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)

  1. Offsets are per-session positive integers starting at 1, contiguous, strictly increasing in append order. Assigned atomically inside append (offset = current high_watermark + 1), under the Writer's single-mailbox serialization. Never reused, never reordered, never reassigned.
  2. 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.
  3. read(j, from, to) returns exactly the records with from <= offset <= to, in offset order, value-identical to what append stored.
  4. A record, once append returns {:ok, record}, is read-visible: a high_watermark or read issued after that return observes it. This is what makes the Writer's "append THEN publish" order meaningful.
  5. Genesis: offset 1 of a journal-managed session is ALWAYS a "session_created" record (appended by the Writer on first use).
  6. 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, stamp session_id
    • ts_hook, persist, and return the COMPLETE stamped %Record{} (the Writer needs the whole record to publish it — the offset is record.offset). entry is %{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.

j()

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

entry()

@type entry() :: %{kind: String.t(), payload: map(), taint: String.t()}

An append request: the Writer supplies kind/payload/taint; the store stamps the rest.

j()

@type j() :: term()

Opaque, per-session storage handle.

Callbacks

append(j, entry)

@callback append(j(), entry()) ::
  {:ok, Raxol.AgentClientProtocol.Ext.Journal.Record.t()}
  | {:error, :read_only | term()}

close(j)

(optional)
@callback close(j()) :: :ok

high_watermark(j)

@callback high_watermark(j()) :: non_neg_integer()

open(session_id, opts)

@callback open(session_id :: String.t(), opts :: keyword()) ::
  {:ok, j()} | {:error, term()}

read(j, from, to)

@callback read(j(), from :: pos_integer(), to :: non_neg_integer()) ::
  {:ok, [Raxol.AgentClientProtocol.Ext.Journal.Record.t()]} | {:error, term()}

Functions

ensure_writer(session_id, journal, opts \\ [])

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

subscribe(session_id, subscriber)

@spec subscribe(String.t(), pid()) :: :ok | {:error, :no_writer}

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

unsubscribe(session_id, subscriber)

@spec unsubscribe(String.t(), pid()) :: :ok

Idempotently detach subscriber from the live tail of session_id. A missing Writer is a no-op (:ok) — there is nothing to detach from.