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:

  1. Authorize through the ONE funnel. attach/1 calls the injected :authorize fun — whose default is Raxol.AgentClientProtocol.Ext.AttachPolicy.Runner.authorize/2, the SOLE fail-closed funnel. There is NO second FailClosed/try-catch wrapper. Any {:denied, _} — or any non-{:ok, %Grant{}} — DENIES: attach/1 returns the deny envelope (-32000 "attach denied", NO data) and NOTHING is registered, no history is read. The request error IS the denial — no separate _raxol/session.closed frame.
  2. 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
     `Writer.subscribe`); live records queue to the subscriber from now
     on.
    b. snapshot h SECOND — journal.high_watermark, decision-time,
     read 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.
    c. reject from_offset > h + 1 (-32602, minting a gap is illegal);
     else emit `history(max(from,1)..h)` as wire frames, under the
     stock projection (see Taint below).
    d. optional _raxol/session.caught_up{h+1} (offset-aware only). e. reply the LoadSessionResponse (_meta.raxol.io.highWatermark = h)
     — replay-before-respond (history frames and the reply leave the SAME
     process, so the response serializes after the last history frame).
    f. steady state — the PERMANENT, MONOTONE live gate: 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/1 takes the funnel as :authorize; its default is Ext.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-time h, permanent monotone gate) provable in isolation and leaves the turn state machine untouched.
  • ctx.transport is passed in, not read from Connection. The Connection exposes no transport/1 accessor; the reattach handler MUST source transport from Connection-side knowledge (never the peer) and pass it as a field. With no transport, the default LocalNode policy denies.
  • Keyring is validated per-attach, not at boot. The Token policy's Ed25519 keyring is a pure per-verify/4 parameter (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

opts()

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

rider()

@type rider() :: %{
  from_offset: non_neg_integer(),
  history_policy: :from_offset | :tip | :none,
  capability: binary() | nil
}

The parsed _meta["raxol.io"] attach rider.

Functions

attach(opts)

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

deny_envelope()

@spec deny_envelope() :: Raxol.AgentClientProtocol.Error.t()

The deny envelope: -32000 "attach denied", NO data (anti-oracle).

parse_rider(ours)

@spec parse_rider(map() | nil) :: rider()

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.