A branch: the struct a branch process holds and storage persists, plus the pure functions over it.
id- a UUID stringname- free text for peopledomain- the Ash domain whose resources the branch coversstatus-:open,:proposed,:merged,:rejectedor:discarded
written, sobase- what the branch knows about the base rows it touched, by{resource, key}: `%{fingerprint: hashnil, snapshot: record nil}`. The fingerprint is taken when a row is first read or written, the snapshot when it is first diff/2never queries the databaseoverlay- seeRamal.Overlaylog- the writes the branch captured, newest first so that recording one is O(1); readers useEnum.reverse/1inserted_at- when the branch was createdmetadata- a map the host uses freely; Ramal only addsforked_fromto a forkproposal-niluntilRamal.propose/2; thenproposal/0
Nothing here touches a process or storage, so it is tested with plain data.
inspect/1 prints id, name, domain, status and inserted_at only. The log keeps the raw input and the actor of
every captured write, which can hold passwords and tokens, and a crash report prints the state of a branch process.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm, name: "move herd R7", metadata: %{ticket: 42})
iex> {branch.name, branch.status, branch.overlay, branch.metadata}
{"move herd R7", :open, %{}, %{ticket: 42}}
iex> Ramal.Branch.open?(branch)
true
Summary
Types
What the branch knows about one base row.
A captured write: {action_type, resource, action_name, input, actor, opts, at}. input is the params the action
was called with, opts carries :key (the primary key written), :tenant and :nested? (the write was made by
a hook of another captured action, so replaying that action reproduces it).
A branch's proposal: who proposed it and why, and once decided, how. proposer and by are whatever the host passed
(:proposed_by, :decided_by); stale and replayed are what the approving merge saw.
What put_write/6 replaced, so undo/2 can put it back.
Functions
The branch with the decision on its proposal recorded. status is :approved or :rejected; only a rejection
changes the branch's own status (to :rejected), because an approval is followed by the merge that marks it :merged.
details may carry :by, :reason, :replayed and :stale.
The branch's changes as Ramal.Change structs, in the order the log first touched each row.
The branch after discard: status :discarded and no overlay, log or base, so a discarded branch costs nothing to keep.
A new open branch that starts as a copy of parent: same domain, overlay, log and base.
A new open branch for domain with a fresh id.
Whether the branch still accepts reads, writes and merges.
The branch after Ramal.propose/2: status :proposed, frozen until someone decides, with the proposal recorded.
The branch after reading base rows with these fingerprints.
The branch after one captured write.
The branch with the writes described by undos taken back, newest write first.
Types
@type base_row() :: %{fingerprint: non_neg_integer() | nil, snapshot: map() | nil}
What the branch knows about one base row.
@type kind() :: :created | :updated | :deleted
@type log_entry() :: {Ash.Resource.Actions.action_type(), module(), atom(), map(), term(), keyword(), DateTime.t()}
A captured write: {action_type, resource, action_name, input, actor, opts, at}. input is the params the action
was called with, opts carries :key (the primary key written), :tenant and :nested? (the write was made by
a hook of another captured action, so replaying that action reproduces it).
@type proposal() :: %{ proposer: term(), reason: String.t() | nil, proposed_at: DateTime.t(), decision: nil | %{ status: :approved | :rejected, by: term(), reason: String.t() | nil, at: DateTime.t(), replayed: non_neg_integer() | nil, stale: [Ramal.Merge.Result.stale_row()] } }
A branch's proposal: who proposed it and why, and once decided, how. proposer and by are whatever the host passed
(:proposed_by, :decided_by); stale and replayed are what the approving merge saw.
@type ref() :: Ramal.Overlay.ref()
@type status() :: :open | :proposed | :merged | :rejected | :discarded
@type t() :: %Ramal.Branch{ base: map(), domain: module(), id: String.t(), inserted_at: DateTime.t(), log: list(), metadata: map(), name: String.t() | nil, overlay: Ramal.Overlay.t(), proposal: proposal() | nil, status: status() }
@type undo() :: %{ ref: ref(), overlay: Ramal.Overlay.entry() | :none, base: base_row() | :none, entry: log_entry() }
What put_write/6 replaced, so undo/2 can put it back.
Functions
@spec decide(t(), :approved | :rejected, DateTime.t(), keyword()) :: t()
The branch with the decision on its proposal recorded. status is :approved or :rejected; only a rejection
changes the branch's own status (to :rejected), because an approval is followed by the merge that marks it :merged.
details may carry :by, :reason, :replayed and :stale.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> branch = Ramal.Branch.propose(branch, nil, nil, ~U[2026-01-01 00:00:00Z])
iex> rejected = Ramal.Branch.decide(branch, :rejected, ~U[2026-01-02 00:00:00Z], by: %{id: 1}, reason: "not now")
iex> {rejected.status, rejected.proposal.decision.status, rejected.proposal.decision.reason}
{:rejected, :rejected, "not now"}
@spec diff(t(), keyword()) :: {:ok, [Ramal.Change.t()]} | {:error, Ramal.Error.t()}
The branch's changes as Ramal.Change structs, in the order the log first touched each row.
Never reads the database: updated and deleted rows are compared with the snapshot in base. See
Ramal.Overlay.diff/3 for the rules; the options are the same.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> entry = fn key -> {:create, MyApp.Herd, :create, %{}, nil, [key: key], ~U[2026-01-01 00:00:00Z]} end
iex> {branch, _} = Ramal.Branch.put_write(branch, :created, {MyApp.Herd, 2}, %{id: 2}, nil, entry.(2))
iex> {branch, _} = Ramal.Branch.put_write(branch, :created, {MyApp.Herd, 1}, %{id: 1}, nil, entry.(1))
iex> {:ok, changes} = Ramal.Branch.diff(branch)
iex> Enum.map(changes, & &1.key)
[2, 1]
The branch after discard: status :discarded and no overlay, log or base, so a discarded branch costs nothing to keep.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> branch = %{branch | overlay: %{{MyApp.Herd, 1} => :deleted}}
iex> discarded = Ramal.Branch.discard(branch)
iex> {discarded.status, discarded.overlay}
{:discarded, %{}}
A new open branch that starts as a copy of parent: same domain, overlay, log and base.
Overlays, logs and the base are immutable, so the copy shares structure with the parent and neither sees the
other's later changes. metadata gets forked_from: parent.id.
Options
:name- default: the parent's name:metadata- default: the parent's metadata
Example
iex> parent = Ramal.Branch.new(MyApp.Farm, name: "what if")
iex> parent = %{parent | overlay: %{{MyApp.Herd, 1} => :deleted}}
iex> fork = Ramal.Branch.fork(parent, name: "variant")
iex> {fork.name, fork.overlay == parent.overlay, fork.metadata.forked_from == parent.id, fork.id != parent.id}
{"variant", true, true, true}
A new open branch for domain with a fresh id.
Options
:name- a label for people, defaultnil:metadata- a map for the host, default%{}
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> {branch.domain, branch.name, branch.log}
{MyApp.Farm, nil, []}
Whether the branch still accepts reads, writes and merges.
Example
iex> Ramal.Branch.open?(%Ramal.Branch{id: "b", domain: MyApp.Farm, inserted_at: DateTime.utc_now(), status: :merged})
false
@spec propose(t(), term(), String.t() | nil, DateTime.t()) :: t()
The branch after Ramal.propose/2: status :proposed, frozen until someone decides, with the proposal recorded.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> proposed = Ramal.Branch.propose(branch, %{id: 7}, "move R7 before the rain", ~U[2026-01-01 00:00:00Z])
iex> {proposed.status, proposed.proposal.proposer, proposed.proposal.reason, proposed.proposal.decision}
{:proposed, %{id: 7}, "move R7 before the rain", nil}
@spec put_fingerprints(t(), %{optional(ref()) => non_neg_integer()}) :: t()
The branch after reading base rows with these fingerprints.
A row the branch already knows keeps its first fingerprint: staleness is measured from the first touch.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> branch = Ramal.Branch.put_fingerprints(branch, %{{MyApp.Herd, 1} => 7})
iex> branch = Ramal.Branch.put_fingerprints(branch, %{{MyApp.Herd, 1} => 8})
iex> branch.base
%{{MyApp.Herd, 1} => %{fingerprint: 7, snapshot: nil}}
The branch after one captured write.
kind says what the write did to the row ref, record is the row as the action left it, and base_row is the
row as the action found it (nil for a created row). The first time the branch writes an existing row, its snapshot
goes into base, keeping a fingerprint taken by an earlier read if there is one. entry is prepended to the log.
Returns the new branch and an undo that undo/2 uses to take the write back when the action that made it fails.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> entry = {:update, MyApp.Herd, :update, %{name: "b"}, nil, [key: 1], ~U[2026-01-01 00:00:00Z]}
iex> before = %{fingerprint: 7, snapshot: %{id: 1, name: "a"}}
iex> {branch, _undo} = Ramal.Branch.put_write(branch, :updated, {MyApp.Herd, 1}, %{id: 1, name: "b"}, before, entry)
iex> {branch.overlay, branch.base, length(branch.log)}
{%{{MyApp.Herd, 1} => {:updated, %{id: 1, name: "b"}}}, %{{MyApp.Herd, 1} => %{fingerprint: 7, snapshot: %{id: 1, name: "a"}}}, 1}
The branch with the writes described by undos taken back, newest write first.
Only the refs and log entries the undos name are touched, so a write another process made in the meantime stays.
Example
iex> branch = Ramal.Branch.new(MyApp.Farm)
iex> entry = {:destroy, MyApp.Herd, :destroy, %{}, nil, [key: 1], ~U[2026-01-01 00:00:00Z]}
iex> {written, undo} = Ramal.Branch.put_write(branch, :deleted, {MyApp.Herd, 1}, %{id: 1}, %{id: 1}, entry)
iex> Ramal.Branch.undo(written, [undo]) == branch
true