Ramal
Git-style branches for your Ash application's data, as BEAM processes.
Fork it, change it, diff it, merge it back, or throw it away. Nothing touches your database until you say so.
Status: 0.9, on Hex. Branches, diff, merge, proposals, reads that agree with the branch (pages, aggregates, calculations), bulk actions and upserts, one tenant per branch, nested, durable and clustered branches, the LiveView inspector and the tools for AI agents work and are tested on Postgres. The branch lifecycle is model-checked and its data rules are proved. What is stable before 1.0 is on the stability page.
What it does
Ramal lets an Ash application try something on its real data without doing it.
You open a branch. Inside the branch your own code runs unchanged: the same Ash actions, the same validations and
policies, the same reads. But every write lands in a small overlay held by a process instead of in Postgres, and every
read sees your database plus what the branch changed. When you are done you can look at exactly what changed
(diff), copy the branch to explore a variation (fork), apply it for real (merge), or drop it (discard).
┌─ branch A: move herd R7 to paddock L18 ─┐
│ │
Postgres ────┼─ branch B: move herd R7 to paddock L03 ──┼──▶ diff ──▶ merge one (through your real actions)
(untouched) │ │ the others are now stale and refuse
└─ branch C: split herd R7 in two ─────────┘
A branch is one Erlang process and one map. There is no second database, no copied tables, no container and no branch quota, so a program can open as many alternatives as it needs.
{:ok, branch} = Ramal.branch(MyApp.Farm, name: "move herd R7 to paddock L18")
Ramal.run!(branch, fn ->
# Your real action, validations and policies included. The row lands in the branch, never in Postgres.
Ash.update!(herd, %{paddock_id: paddock.id})
# A read inside the branch sees the base rows plus what the branch changed.
Ash.read!(MyApp.Farm.Herd)
end)
{:ok, copy} = Ramal.fork(branch) # a second branch that starts where this one is
{:ok, changes} = Ramal.diff(branch) # a list of %Ramal.Change{}, computed without querying the database
{:ok, merged} = Ramal.merge(branch, actor: admin) # replays the branch through the real actions, in one transaction
:ok = Ramal.discard(copy)
Why it exists
Agents, planners and people who ask "what if?" need many cheap, alternate states of the same data. The usual tools each give you only part of that:
- A transaction is one alternate state, tied to one connection, gone when the request ends.
- A copy of the database (or a branching database) is a real second state, but expensive to create and to keep many of. In a 2026 study from Columbia's DAPLab, Neon ran into its cap on active branches, and Dolt created branches cheaply but read and joined slowly inside them. The authors conclude that agents "need databases that can support thousands of cheap alternate realities without giving up normal query performance", and add: "That system does not exist yet."
- Your own draft tables mean building and maintaining a second copy of your domain logic.
On the BEAM a branch can be a process with a map, and Ash already gives you actions, validations and policies to replay. That combination is the whole library.
What you can build with it
- AI agents that explore alternatives. Give an agent a branch instead of your database. It can try five plans, read the results of each, score them, keep the best and throw the rest away, and it cannot damage real data while it does. A tree search that forks promising states is exactly what a branch is cheap at (see the benchmark). How to wire an agent to Ramal.
- What-if planning. "What happens to pasture capacity if we move this herd?" Run the real move in a branch, read
the numbers (
Ramal.aggregate/4gives exact counts and sums for the branch), and discard it if you do not like the answer. - Preview and staging of edits. Show a user the result of a change before it exists: the form edits a branch, the page renders the branch's reads and the diff, and "Save" merges.
- Approvals and review. An agent or a junior user proposes a change with
Ramal.propose/2; a reviewer reads the diff and approves (which merges) or rejects. The proposer, the decider and the reasons are recorded on the branch. - Safe dry runs. Run a series of record-level actions in a branch and read the diff to see what they would do, then merge or discard.
- Several drafts at once. A user keeps three versions of a plan open and compares them; each is its own branch and survives restarts when you use the Postgres storage.
How it works, in four lines
- A branch is an overlay. It holds the rows it created, updated or deleted, keyed by resource and primary key, plus a log of the writes.
- Reads are patched. A Spark extension adds a preparation to your read actions: Postgres answers as usual, and the branch's overlay is applied on top (deleted rows dropped, changed rows replaced, new rows added if they match the query's filter, then sorted and paged).
- Writes are captured. The extension adds a change to your create, update and destroy actions that, inside a branch, puts the result in the overlay instead of calling the data layer. Ash does all its normal work first.
- Merge replays through your actions. The log is replayed through the real actions, in one transaction, after checking that no row the branch saw has changed in the database since. A branch that is no longer valid fails as a whole and leaves the database untouched.
Both hooks do nothing when no branch is active, and a test per hook proves it. The full design is in docs/DESIGN.md.
Formally verified
Merging is the step that must never be wrong, so the rules behind it are checked by machine, not only by tests.
verification/ holds three models:
- A TLA+ model of a branch in a cluster, checked over about a million states: three nodes sharing a database, network splits and heals, a node crash. It proves that no write a caller was told succeeded is dropped without a trace, and that only the process on the storage's epoch ever changes what the storage holds.
- A TLA+ model of the branch lifecycle, checked with TLC over about 5.5 million states (and 2.6 million more for durable branches): writes, reads, fork, propose, approve, discard, merge, idle unload, killed processes and node restarts, interleaved the way the BEAM interleaves them. It proves that a merge commits at most once, that a merge never takes the write of an action that failed, and that what reads see is what the merge writes. A second model covers a nested fork merging into its parent: no write reaches the parent twice, and none is lost to the merge.
- Proofs in Bend 2 (rechecked by a Lean-proved kernel) that a read in a branch is the base plus the overlay, that a merge loses no edit made outside the branch, and that an overlay entry never lies about the base.
The model found three real bugs that tests had missed, now fixed and covered by tests that failed before:
writes of a still-running action taken by a merge or fork (ADR 0033),
and a branch merged twice when the merging process died after the commit (ADR 0035).
A conformance test ties the Bend model to Ramal.Overlay, and CI runs both checks. The
verification README says what is proved, what is assumed and what is not covered (filters and sorts of a patched read, for one, are covered by property tests instead).
Quickstart
Ramal is on Hex, with docs on HexDocs. Add it to mix.exs:
def deps do
[{:ramal, "~> 0.9.0"}]
end
Requires Elixir 1.18+ and Ash 3.36 or later. CI tests Ash 3.36, 3.37, the latest 3.x and Ash's main branch, and Ramal
warns at boot when the host's Ash is outside the range it is tested with (Ramal.Ash). Run mix ramal.doctor after upgrading Ash: it checks every Ash behaviour Ramal relies on against the version you run. AshPostgres is optional and only needed for the Postgres storage backend.
1. Add the extension to each resource a branch should see.
defmodule MyApp.Farm.Herd do
use Ash.Resource,
domain: MyApp.Farm,
data_layer: AshPostgres.DataLayer,
extensions: [Ramal.Resource]
# ...
end
2. Add the supervisor to your tree.
children = [
MyApp.Repo,
{Ramal.Supervisor, storage: Ramal.Storage.Postgres, repo: MyApp.Repo}
]
Run mix ramal.gen.migration for the ramal_branches table (run it again after upgrading for the columns and the table that are missing, then migrate: the owner column, the epoch column before cluster: true, the table of the write log before durable: true). Without storage: branches live in ETS and are lost when
the supervisor stops, which is fine for development and tests.
3. Branch.
{:ok, branch} = Ramal.branch(MyApp.Farm, name: "what if")
Ramal.run!(branch, fn ->
Ash.create!(MyApp.Farm.Herd, %{name: "R10", head: 5})
end)
{:ok, [change]} = Ramal.diff(branch)
change.kind #=> :created
change.changes #=> %{name: {nil, "R10"}, head: {nil, 5}, ...}
Ash.read!(MyApp.Farm.Herd) # outside the branch: no R10
Ramal.merge(branch) # now R10 is in Postgres, created by your real create action
Every function that takes a branch also takes its id, and starts the branch process from storage if it is not running, so a request can keep only the id.
Try it
A guided tour opens branches of a small farm in Postgres, diffs and forks them, merges one, gets a stale one refused, and
shows a merge that fails halfway and leaves the database untouched. It needs a Postgres (docker compose -f demo/docker-compose.yml up -d
starts one) and comes in two forms:
- In Livebook: click the badge above, or open
demo/ramal_tour.livemd. One section per chapter, with the explanation next to the cell you run. - In a terminal:
elixir demo/walkthrough.exs, which needs Elixir 1.18+.
demo/ has what each chapter shows, and the browser view.
A closer look
Merging safely
merge/2 records a fingerprint of every database row the branch reads or writes. At merge time it checks them again
inside the transaction. By default (on_stale: :error) it refuses when one has changed or disappeared, and it fails as a
whole, leaving the database untouched, if any replayed action is no longer valid:
{:ok, a} = Ramal.branch(MyApp.Farm)
{:ok, b} = Ramal.fork(a) # both started from the same farm
# ... each moves herd R7 to a different paddock inside its own branch ...
{:ok, _} = Ramal.merge(a)
{:error, %Ramal.Error{reason: :stale}} = Ramal.merge(b) # R7 is no longer where b read it
Pass on_stale: :ignore, or a function that decides per stale row, when you know better.
Proposals: an agent proposes, a person decides
{:ok, branch} = Ramal.branch(MyApp.Farm)
Ramal.run!(branch, fn -> Ash.update!(herd, %{paddock_id: paddock.id}, authorize?: false) end)
{:ok, _proposed} = Ramal.propose(branch, proposed_by: agent, reason: "R7 is crowding L07")
{:ok, pending} = Ramal.list(MyApp.Farm, status: :proposed) # what a reviewer sees
{:ok, changes} = Ramal.diff(branch) # what the reviewer reads
{:ok, result} = Ramal.approve(branch, decided_by: manager, actor: manager) # merges; or Ramal.reject(branch, ...)
A proposed branch is frozen: it refuses further writes, so the diff the reviewer reads is the diff that gets merged. Ramal records who proposed and who decided, and leaves the question of who may decide to your own policies.
Exact numbers for a branch
Ramal.aggregate/4 takes the database's answer for the rows the branch did not touch and adds the rows it did, so it
costs what the overlay holds, not what the table holds.
Ramal.run!(branch, fn ->
Ash.create!(MyApp.Farm.Herd, %{name: "R10", head: 5})
Ramal.aggregate!(MyApp.Farm.Herd, :sum, :head) # the base total plus 5
end)
It supports :count, :exists, :sum, :min, :max, :avg, :first and :list. The last two follow the query's sort and accept uniq?: and include_nil?:.
Per-tenant branches
A branch of a multitenant application works in one tenant: Ramal.branch(MyApp.Farm, tenant: "acme"). Reads and writes
inside it must carry that tenant (:tenant_mismatch otherwise), other tenants' rows never enter its overlay, and the
merge checks and replays in the tenant. Both the attribute and the schema-per-tenant strategies work.
Per-user branches
Pass owner: to Ramal.branch/2 and Ramal.list(domain, owner: user_id) shows each user their own. It is a filter, not
access control; the security notes say where the line is.
Branches in a cluster
With Ramal.Storage.Postgres (after mix ramal.gen.migration) and cluster: true on Ramal.Supervisor, every node
serves the same branches: :global gives each branch one process, and the storage refuses the saves of a process that
no longer holds the branch. A netsplit can still lose writes. The process on the losing side is fenced at its next save:
it emits [:ramal, :branch, :fenced] and stops, and unless the branch is durable (durable: true) what it held since its
last save is lost. A durable branch stores each action before the action returns, so no acknowledged write is lost, and
an action on the losing side fails with :fenced. Ramal.Supervisor says what stays per node.
For AI agents
Ramal is written with an agent in the loop in mind: the agent works in a branch, a person approves. Three pieces:
Ramal.Toolsgives an agent the branch calls (open, list, fork, diff, propose, discard) as tool definitions with JSON Schemas and onecall/3, for any tool loop or MCP server. There is deliberately no merge or approve tool: an agent proposes, a person decides. The host fixes the domain and owner, so the agent cannot reach another user's branch.Ramal.context/1is thecontext:option that runs an Ash call in a branch, for tool servers (such as AshAi) that cannot wrap the call inRamal.run/2.usage-rules.mdand a skill teach coding agents (Claude Code, Cursor, Copilot) to use Ramal correctly. They are in the format ofusage_rules:mix usage_rules.syncputs them in yourAGENTS.mdand.claude/skills.
opts = [domain: MyApp.Farm, owner: "session-42", proposed_by: "planner agent"]
tools = Ramal.Tools.definitions(opts) # name, description, input_schema: hand these to your model
{:ok, %{"id" => id}} = Ramal.Tools.call("ramal_open_branch", %{"name" => "move R7"}, opts)
{:ok, %{"changes" => _}} = Ramal.Tools.call("ramal_branch_diff", %{"branch_id" => id}, opts)
The agent guide has the tool loop, the AshAi MCP plug
and what is and is not tested.
Key features
- Real actions inside a branch. Validations, policies, hooks, defaults and managed relationships run as they do outside.
- Fork, diff, merge, discard as ordinary function calls, plus proposals with a recorded proposer and decider.
- Persistent. With
Ramal.Storage.Postgresa branch is saved when it is created, proposed, discarded, unloaded or when the supervisor shuts down, so it survives restarts; idle branches unload from memory and come back on demand. Withdurable: truethe writes of every action that ended are stored before the action returns, so a crash of the node loses none of them. A durable branch does not store the fingerprint of a row it only read since its last save, so after a crash the merge cannot tell that such a row changed in the database; every row an action wrote keeps its fingerprint. - Staleness detection at merge, with a choice of what to do about it.
- Exact aggregates for a branch without scanning the table, and loaded, filtered and sorted aggregates that agree with the branch.
- Pages, prepared queries and module calculations that see the branch.
- One tenant per branch, on both multitenancy strategies.
- Formally verified lifecycle and data rules (above).
- Many-to-many links changed in a branch are seen by
many_to_manyloads and bymanage_relationship. - Telemetry for every branch lifecycle step, every patched read and every captured write.
- Safety valves. Optional per-domain limits on open branches and on the rate of new ones, a heap limit on each branch
process, and the
sensitive?attributes of a stored actor are dropped from the saved branch. - Agent tooling. Branch tools for an LLM agent without merge or approve, a context for MCP servers, and usage rules and a skill for coding agents (above).
- A LiveView inspector in a separate package,
ramal_live. - No Phoenix in core, AshPostgres optional. The library adds one supervisor to your tree and nothing else.
Limits
Ramal is comprehensive, and docs/limits.md lists every limit it has. Reads fetch candidate rows and patch them in memory, so Ramal is meant for OLTP-sized working sets. Merge replays each change through your actions, so it is slower than a SQL statement. Authorization is yours.
Stability
From 0.8.0, the calls branch/2, run/2, fork/2, diff/2, merge/2, propose/2, approve/2, reject/2 and
discard/1 keep their names, required arguments and return shapes through 0.x and into 1.0. A change to one of them
comes with a deprecation warning for at least one minor release first. Everything else may change in a minor release
before 1.0, and the changelog says when it does. The stability page
lists what is stable, what is extended, and when releases come.
Inspector
ramal_live is a separate package with a LiveView page that shows the open and proposed branches as
columns, each with its diff as before and after rows, and buttons to fork, merge, discard, approve and reject. It is
mounted by your router inside your own live_session, so it uses your LiveView socket and JavaScript, and has no asset
pipeline. Ramal itself never depends on it.
ramal_inspector "/ramal", domain: MyApp.Farm, pubsub: MyApp.PubSub, actor: {MyAppWeb.Inspector, :actor}
To see it move before wiring it into an app, run the demo from a clone of the repository: docker compose up -d db, then
elixir ramal_live/examples/farm_demo.exs. It starts a seeded farm where one button opens four branches that each move
a herd to a different paddock, and merging one makes the others stale. The
ramal_live README has the steps and the setup, and
ADR 0015 explains why it is built this way.
Benchmark
bench/ is a separate project that runs the same work against Ramal and two plain-Postgres baselines (one table copied per fork, one shared delta table) on 20,000 rows. In the run of 2026-10-23 on Ramal 0.6.1 published in bench/README.md, on one shared sandbox with the database on localhost (which moves by tens of percent between runs):
- Fork takes 0.05 ms against 31 ms for a table copy with an empty overlay, and 2.7 ms against 29 ms with 1,000 changes, because a fork copies a map.
- A tree search (20 forks, 100 updates, 20 scorings) takes 280 ms against 873 ms for table copies and 279 ms for the delta table.
- Where it loses: reading 2,000 rows inside a branch takes 9 to 18 ms (about 10 ms through Ash with no branch) against about 2 ms for raw SQL,
Ramal.aggregate/4costs 8 ms with 100 changes and 32 ms with 1,000, and merging replays every change through your action at about 0.4 ms each, against one SQL statement for the baseline.
The figures are indicative. Run mix bench on your own hardware before drawing a conclusion; the file also says how it differs from the DAPLab study quoted above.
Learn more
- HexDocs: the API, with an example on every function.
- Agent guide and usage rules: Ramal for AI agents.
- Design and roadmap: how it works and what comes next.
- Architecture decision records: why each choice was made.
- Security notes: what Ramal enforces and what it leaves to you.
- Changelog and contributing.
Name
Ramal is Spanish for a railway branch line: it leaves the main line, serves its own stations, and often rejoins it. Pronounced rah-MAHL.
License
MIT. See LICENSE.