0015. The inspector is a separate project mounted by a router macro in the host's own live_session

Copy Markdown View Source

Status: accepted, 2026-10-22. Issue: #32.

Context

The roadmap promises an inspector: open and proposed branches as columns, each branch's diff, buttons to fork, merge, discard, approve and reject, and live updates. Core takes no Phoenix dependency (decision 6), so it lives in its own package. Two questions shaped it: how a LiveView page gets into a host that already has a LiveView socket and JavaScript, and how it learns that a branch changed.

Phoenix.LiveDashboard mounts with a router macro that opens a live_session of its own, with its own root layout and its own compiled JavaScript. That makes it work anywhere, and costs an asset pipeline and a second socket setup.

Decision

  • ramal_live/ is a separate Mix project in this repository. It depends on ramal by path and nothing depends on it. It has its own CI job and its own mix.lock.
  • ramal_inspector "/ramal", domain: ..., pubsub: ... expands to one live route and nothing else. It opens no live_session, sets no root layout and ships no JavaScript or CSS file. The host places it inside its own scope, pipeline and live_session, so the page uses the host's root layout, socket and JS, and its authentication hooks apply. The CSS is inline in the page, scoped to a .ramal root.
  • The options travel as route metadata. A live route cannot take a per-route session, and the session belongs to the host's live_session. The page reads its options back with Phoenix.Router.route_info/4 for the URL it was reached at. A route mounted with plain live fails with a message that names ramal_inspector.
  • Live updates are telemetry to PubSub to a read of one branch. RamalLive.Broadcaster, a process the host starts, attaches handlers for the branch events, [:ramal, :write, :capture] and [:ramal, :merge, :stop], and broadcasts {:ramal_live, branch_id, event} on a topic per domain. The message carries no row data. The page re-reads only the branch it names (Ramal.get/1 and Ramal.diff/1, so the page sees what the public API shows), and coalesces a burst of writes into one read per :debounce window.
  • The broadcaster is supervised, not attached by the first page view. Telemetry is node-local and a branch emits its events whether or not anyone is looking, so a handler attached on first mount would miss every event before it. A page without a broadcaster says so and works on actions and Reload.
  • Authorization is the host's. The inspector can merge and discard. It checks nothing itself; an :actor option, {module, function} called with the socket, supplies the actor recorded as decided_by and used to replay.
  • Merge and approve run in start_async, since they replay a log in a transaction; the other actions are quick and run in the LiveView. Every destructive action has a server-side confirmation: the second event is accepted only after the first.

Consequences

  • ramal_live needs LiveView 1.1 or later.
  • Opening the page loads the process of every branch it shows (Ramal.get/1 does). They unload on idle_timeout. There is no pagination: a column shows its first 100 changes and counts the rest.
  • Several ramal_inspector mounts can share one PubSub and one broadcaster; two broadcasters for one PubSub are refused.
  • A host with a strict content security policy must allow the inline <style>.
  • Dropped from the first reading of the issue: a route per branch, a propose button, free-text reasons on approve and reject. Each would be its own decision.