Raxol. AgentClientProtocol. Ext. Reattach
(Raxol Agent Client Protocol v0.1.0)
Copy Markdown
View Source
The register-before-h attach seam — the correctness core of the reattach
extension.
The seam (both wire entrances converge here)
session/load + rider and _raxol/session.load both parse the same
_meta["raxol.io"] rider and call attach/1. The algorithm, per attach:
- Authorize through the ONE funnel.
attach/1calls the injected:authorizefun — whose default isRaxol.AgentClientProtocol.Ext.AttachPolicy.Runner.authorize/2, the SOLE fail-closed funnel. There is NO secondFailClosed/try-catch wrapper. Any{:denied, _}— or any non-{:ok, %Grant{}}— DENIES:attach/1returns the deny envelope (-32000 "attach denied", NOdata) and NOTHING is registered, no history is read. The request error IS the denial — no separate_raxol/session.closedframe. - On grant, start a fresh subscriber process (this module's GenServer),
transfer the load-request reply obligation to it via
Connection.delegate_reply/3, then run the attach algorithm INSIDE that process: a. REGISTER FIRST —Journal.subscribe(routed to
b. snapshot`Writer.subscribe`); live records queue to the subscriber from now on.hSECOND —journal.high_watermark, decision-time,
c. rejectread from the STORE (never a cached counter). The gate is armed (`h` bound) BEFORE any `{:reattach_live, ...}` is processed (gate-arm invariant): steps a–e run with no intervening live receive.from_offset > h + 1(-32602, minting a gap is illegal);
d. optionalelse emit `history(max(from,1)..h)` as wire frames, under the stock projection (see Taint below)._raxol/session.caught_up{h+1}(offset-aware only). e. reply theLoadSessionResponse(_meta.raxol.io.highWatermark = h)
f. steady state — the PERMANENT, MONOTONE live gate:— replay-before-respond (history frames and the reply leave the SAME process, so the response serializes after the last history frame).offset <= h⇒drop (already in history), forever; `offset > h` ⇒ forward.
Taint — annotate, NEVER filter
Every delivered frame carries the record's taint (in _meta["raxol.io"] on
session/update frames, first-class taint on _raxol/session.record). NO
code path here drops/withholds/reroutes a record by taint — a taint filter
anywhere breaks history ++ live == durable stream. The only
projection is by KIND (a stock, non-offset-aware attacher receives only
session_update frames), applied identically to history and live, and it
is orthogonal to taint. grant.lens is threaded opaquely and never
interpreted (reserved).
Mid-attach expiry — force-close at exp
When grant.expires_at != nil, the subscriber arms a timer that at
expires_at emits _raxol/session.closed{reason: "revoked"} and detaches —
bounding how long a held live tail may read. A reconnect just before exp
re-authorizes fresh: each attach is its own subscriber process with its own timer
targeting only itself, so the timer-vs-reconnect race is closed by process
identity (the old subscriber's timer never touches the new one).
Implementation notes
- The authorization funnel is injectable.
attach/1takes the funnel as:authorize; its default isExt.AttachPolicy.Runner.authorize/2— the SOLE fail-closed funnel. Any non-{:ok, %Grant{}}result (incl. every{:denied, _}) denies, and a grant returned by an injected funnel is re-checked against the Grant contract. - The attach subscriber is a dedicated process, not the turn
Session. Realizing the read path as its own small process keeps the attach algorithm (register-first, decision-timeh, permanent monotone gate) provable in isolation and leaves the turn state machine untouched. ctx.transportis passed in, not read fromConnection. The Connection exposes notransport/1accessor; the reattach handler MUST sourcetransportfrom Connection-side knowledge (never the peer) and pass it as a field. With no transport, the defaultLocalNodepolicy denies.- Keyring is validated per-attach, not at boot. The
Tokenpolicy's Ed25519 keyring is a pure per-verify/4parameter (no process state, no boot check), so a malformed/empty keyring surfaces as a per-attach deny, not a startup failure — an endpoint that requires a configured keyring should validate it at its OWN boot (this seam never holds the keyring).
Summary
Types
Attach inputs. :journal is {module, handle} (the durable store, per-session
handle). :authorize is the authorization funnel; when absent it binds to
AttachPolicy.Runner.authorize(policy, ctx), where policy is :policy or,
when that is absent, AttachPolicy.default_policy/0 (the configured
:attach_policy, else LocalNode).
The parsed _meta["raxol.io"] attach rider.
Functions
Run the attach seam. Returns :deferred (the reply obligation was delegated
to the fresh subscriber, which replies) on a granted attach, or
{:error, %Error{}} (the deny envelope) on a denied one — the caller
(the session/load / _raxol/session.load handler) returns this value.
Returns a specification to start this module under a supervisor.
The deny envelope: -32000 "attach denied", NO data (anti-oracle).
Parse the _meta["raxol.io"] rider map. Total: a missing/garbled rider
yields stock defaults (from_offset: 0, history_policy: :from_offset,
capability: nil). A present-but-non-string capability is passed through
verbatim so the policy's is_binary check denies it — garbling must never widen
access, only narrow it.
Types
@type opts() :: %{ :conn => pid(), :session_id => String.t(), :reply_ref => reference(), :journal => {module(), term()}, optional(:conn_mod) => module(), optional(:ctx) => map(), optional(:actor) => map() | nil, optional(:surface) => atom(), optional(:capability) => binary() | nil, optional(:transport) => %{kind: atom(), peer: term()} | nil, optional(:from_offset) => non_neg_integer(), optional(:history_policy) => :from_offset | :tip | :none, optional(:offset_aware?) => boolean(), optional(:authorize) => (map() -> {:ok, map()} | {:denied, atom()}), optional(:policy) => module(), optional(:start_subscriber) => (keyword() -> {:ok, pid()}), optional(:session_sup) => pid() | nil, optional(:now) => integer() }
Attach inputs. :journal is {module, handle} (the durable store, per-session
handle). :authorize is the authorization funnel; when absent it binds to
AttachPolicy.Runner.authorize(policy, ctx), where policy is :policy or,
when that is absent, AttachPolicy.default_policy/0 (the configured
:attach_policy, else LocalNode).
@type rider() :: %{ from_offset: non_neg_integer(), history_policy: :from_offset | :tip | :none, capability: binary() | nil }
The parsed _meta["raxol.io"] attach rider.
Functions
@spec attach(opts()) :: :deferred | {:error, Raxol.AgentClientProtocol.Error.t()}
Run the attach seam. Returns :deferred (the reply obligation was delegated
to the fresh subscriber, which replies) on a granted attach, or
{:error, %Error{}} (the deny envelope) on a denied one — the caller
(the session/load / _raxol/session.load handler) returns this value.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec deny_envelope() :: Raxol.AgentClientProtocol.Error.t()
The deny envelope: -32000 "attach denied", NO data (anti-oracle).
Parse the _meta["raxol.io"] rider map. Total: a missing/garbled rider
yields stock defaults (from_offset: 0, history_policy: :from_offset,
capability: nil). A present-but-non-string capability is passed through
verbatim so the policy's is_binary check denies it — garbling must never widen
access, only narrow it.