Status: describes Ramal 0.2. First written 2026-10-22. This document is the current state of the design; the history of
decisions lives in docs/adr/. Where this document and the code disagree, open an issue: one of them is wrong.
Marks: [confirm] flagged an Ash behaviour the implementer had to verify against the locked version before relying on it. Every one in this document has been resolved for 0.1, into a module doc or an ADR (the decision log below links them); a new mark means a new unverified assumption and should be resolved the same way. Locked versions: Ash 3.37, AshPostgres 2.15, Spark 2.7.
1. Goal and non-goals
Goal. Give an Ash application many cheap, isolated, persistent alternate states of its data ("branches") that ordinary Ash code can read and write without knowing it is inside one, plus the operations to fork, diff, merge and discard them. A branch must cost roughly what a process and a map cost, so thousands can be alive at once.
Non-goals for 0.1.
- Schema changes inside a branch.
- Branch-local indexes, or any promise about query plans.
- Correct data-layer aggregates and calculations over a branch (they are computed on the base and documented as such).
- Multi-node branches. A branch lives on one node; persistence makes it movable, not distributed.
- A UI. The inspector is a separate package (
ramal_live/, section 12).
2. Concepts
Branch. %Ramal.Branch{}: id (UUID string), name, domain (the Ash domain whose resources it covers; several
domains is a 0.2 question), status (:open | :proposed | :merged | :rejected | :discarded), base (fingerprints and snapshots, section 7), overlay,
log, inserted_at, metadata (a map the host uses freely), proposal (nil until proposed, section 7.1).
Overlay. A map from {resource_module, primary_key} to one of {:created, record}, {:updated, record} or
:deleted. The record is the full Ash struct as the action left it. The primary key is the value of the resource's
primary key attribute, or a tuple for composite keys. The overlay is the branch's only state that matters for reads.
Entries record the net effect on the base, so a row created and then deleted in the same branch leaves no entry, and
a row deleted and then created again becomes :updated (the base row is replaced). Ramal.Overlay documents the full
table of how an entry combines with the next write. The overlay is a plain map and does not remember write order; the
log does.
Log. A list of entries {action_type, resource, action_name, input, actor, opts, at} recording every
write the branch captured, newest first so recording one is O(1); readers reverse it. The log is what merge/2 replays. The overlay is derived from the log in
principle, but kept materialized because reads need it on every query.
Fingerprints and snapshots. base maps {resource, key} to %{fingerprint, snapshot} for every base row the branch
touched. The fingerprint is :erlang.phash2 of the row's attributes (Ramal.Resource.fingerprint/1) at the first read
or write; it is nil when the row was read with select, since a hash of part of a row cannot be compared with a
hash of the whole. The snapshot is the row as the first write found it, nil for rows only read. Section 7 uses
fingerprints to detect that the base moved under the branch; diff/1 uses snapshots.
Change. %Ramal.Change{resource, key, kind, changes} where kind is :created | :updated | :deleted and
changes maps attribute names to {before, after}. diff/1 returns a list of them in log order. (The Ash change
module that captures writes, section 4.2, is Ramal.Capture; the name Ramal.Change belongs to this public struct.)
3. Process model
Ramal.Supervisor(aDynamicSupervisorplus aRegistrykeyed by branch id). The host adds it to its tree:{Ramal.Supervisor, storage: Ramal.Storage.Postgres, repo: MyApp.Repo}.- Ramal.Branch.Server is a
GenServerper open branch. State is the%Ramal.Branch{}. It owns the overlay and the log; every write inside the branch is aGenServer.callto it, every read asks it for the overlay slice of one resource. Reads of the overlay are frequent and small; if they become a bottleneck, the server publishes the overlay to an ETS table it owns. Not before the benchmark says so. - Idle branches unload: after
idle_timeout(default 5 minutes, aRamal.Supervisoroption) the server persists its state through the storage backend and stops. A branch is also persisted when it is created, discarded, and when the supervisor shuts down; writes in between live only in the process (ADR 0003).Ramal.branch/2,run/2,fork/1,diff/1,merge/2anddiscard/1all accept a branch id and rehydrate the process on demand. The host never needs to know whether the process is alive. - Limits:
max_active_branchesandbranch_rate,Ramal.Supervisoroptions, both off by default. The internal module Ramal.Limits checks them inbranch/2andfork/2before anything is created, per domain: the first caps the open and proposed branches (read from storage with the optionalcount/3callback, so one query with Postgres), the second caps creations per fixed window with an atomic counter in the supervisor's ETS table. A refusal is{:error, :limit_reached}plus a[:ramal, :limit, :reached]event (ADR 0016). - Memory:
max_heap_sizeon the server process, aRamal.Supervisoroption, default 25 million words. A branch that exceeds it is killed and its last persisted state remains; a watcher process notices the untrappable kill, emits[:ramal, :branch, :too_large]and marks the branch, and the next use returns{:error, :branch_too_large}until the branch is discarded (ADR 0003). - "Current branch" is process-local.
Ramal.run(branch, fun)stores the branch id in the process dictionary (Ramal.Current) for the duration offun, restoring the previous state afterwards even whenfunraises. The first hook that runs also copies the id into the query's or changeset's Ashsharedcontext, because Ash runs some loads in tasks that do not inherit the process dictionary. Tasks the host spawns insidefundo not inherit the branch;Ramal.run/2inside the task propagates it explicitly. Resolved in ADR 0002.
4. Interception: the Spark extension
Resources opt in with extensions: [Ramal.Resource]. The extension adds, through a transformer, one global
preparation to every read action and one global change to every create, update and destroy action. It also adds a
ramal do ... end section with branchable? true (default) so a resource can opt out (a ramal_branches table must
never be branchable, for example). Ramal.branch/2 refuses a domain with no branchable resource (:no_branchable_resources). That check runs when the
branch is created, not as a compile-time domain verifier: a verifier would need the domain to opt into an extension
of its own, which 0.1 does not ask of the host. The transformer is Ramal.Resource.Transformer (@moduledoc false);
Spark needs a module for it.
Both hooks are no-ops when no branch is current. A test per hook proves that: the same action, with and without the extension, produces byte-identical results when no branch is active.
4.1 How reads are patched
This is the section that decides correctness. In a read action's preparation, when a branch is current:
- Before the data layer: in a
before_actionhook (it runs after every preparation and before the data-layer query is built,lib/ash/actions/read/read.ex), remember the query'slimitandoffsetand remove them from the query that goes to the data layer. The base query keeps its filter, so Postgres still uses its indexes and returns only candidate rows. For OLTP-sized working sets this is cheap; for a query that would return a large result the host should not be in a branch. When the overlay has no entry for the resource, nothing is stripped and the database pages as usual. - After the data layer: take the base results and the overlay slice for this resource, then:
- drop every base row whose key is
:deletedin the overlay; - replace every base row whose key is
:updatedwith the overlay record; - add every
:createdand:updatedoverlay record that is not already present and that matches the query's filter, evaluated in Elixir withAsh.Filter.Runtime.filter_matches/4(domain, records, filter, opts), once for the whole slice. An updated row that no longer matches the filter is dropped; - re-apply the sort in Elixir with Ash.Actions.Sort.runtime_sort/3 (it lives in Ash.Actions.Sort, not
Ash.Sort, and sorts a list, not pairs), then the rememberedoffsetandlimit.Ramal.Overlay.patch/4does the merge as a pure function and supplies the filter predicate's rows; the preparation calls it without:sort,:offsetand:limitand applies those itself; - trim overlay records to the query's
select, since an overlay record is a whole struct and a base row carries only the selected attributes (the primary key is always kept); - record a fingerprint (section 7) for every base row the data layer returned, before patching (ADR 0006); a read that selects some attributes records none.
- drop every base row whose key is
- Loads and relationships: Ash loads relationships through further read actions on the related resource, so they
go through the same preparation. Confirmed in Ash 3.37 and covered by tests: the default and
lazy?: truepaths ofAsh.load, loads inside a read, andAsh.get/3(the not-found check runs after theafter_actionhooks, so a row created in the branch is found). Ash builds the first level of a load in the calling process, where the process dictionary is set, and the levels below it inside the task that runs the first level, where it is not. The preparation therefore copies the branch id into the query'ssharedcontext, which Ash passes to related queries (ADR 0002); a test outside the SQL sandbox, where Ash really does run loads in tasks, fails without it. - Aggregates and calculations: data-layer aggregates (
count,sum,exists) and calculations with expressions are computed on the base and do not see the overlay. 0.1 documents this and emits a[:ramal, :read, :unpatched_aggregate]telemetry event when a query inside a branch selects one, so a host can detect it in tests.Ramal.aggregate/4(ADR 0011, ADR 0013) gives the exact answer for the common kinds: the database aggregates the rows the overlay does not touch and the branch reads only the ones it does; aggregates and calculations declared on resources stay unpatched. - Pagination: Ash adds a page's limit and offset after the
before_actionhooks, so a paginated read is paged by the database and patched afterwards. Deleted rows shorten a page, rows created in the branch can be missing from or misplaced across pages, andcountis the database's. 0.1 documents this. Patching the whole list and paging it in Elixir is possible but means rebuildingAsh.Pagevalues, and is left to a later version. - Policies: the query's filter, including one a policy added, is applied to overlay records. Checks that act on
results (
authorize_results) run before theafter_actionhooks and so do not see overlay records. - Many-to-many: the join resource is a resource like any other and gets its own overlay entries, keyed by its
primary key (a tuple when the key is the pair of foreign keys). Covered by tests with a join resource that has such a
key (ADR 0014). A read of the join resource and a load of its
has_manyjoin relationship see the branch's links, and a many_to_many load sees destination rows the branch created, updated or destroyed. Two limits follow from how Ash and AshPostgres work, and 0.2 documents them:- AshPostgres loads a many_to_many with one SQL query that joins the join table in the database
(
lateral_join?/4inlib/ash/actions/read/relationships.ex), so a many_to_many load does not see links the branch created or destroyed. Read the join resource, or load the join relationship, to see them. manage_relationshipdecides what to create and remove from that same load. Sotype: :append_and_removeis refused with:bulk_unsupportedwhen it has a link to remove (Ash unrelates withAsh.bulk_destroy/4,bulk_delete_unused_unrelate/8inlib/ash/actions/managed_relationships.ex),type: :removecannot find a link the branch created, andtype: :appendof a vet the branch already linked creates the link twice, which fails the merge. Use it for links the branch has not changed, or write the join resource directly.
- AshPostgres loads a many_to_many with one SQL query that joins the join table in the database
(
4.2 How writes are captured
Ramal.Capture is a global change for create, update and destroy actions. When a branch is current
(ADR 0005):
around_actionadds a lastbefore_actionthat builds the record and callsAsh.Changeset.set_result/2, so the data layer is skipped.around_actionitself cannot return a result early, because the before-action hooks run inside its callback.- All of Ash's own work still happens: argument casting, validations, policies, other
before_actionandafter_actionhooks, defaults, timestamps. Ramal only replaces "write this to Postgres" with "put this in the overlay", so a branch is a faithful rehearsal of the real action. - The record is
Ash.Changeset.apply_attributes/2, which also applies defaults. Values the database would generate (identity integer keys, database defaults) do not exist in a branch. 0.1 requires branch-created records to have application-generated primary keys (UUIDs) and fails the action with:database_generated_keyotherwise. - Destroys mark the key
:deleted. Updates of a:createdrow stay:createdwith the new record. The first write to an existing row keeps the row as the action found it inbase, whichdiff/1compares against. - Every write is appended to the log as
{action_type, resource, action_name, params, actor, opts, at};optshaskey,tenantandnested?. A write made by a hook of another captured action (or by a managed relationship) isnested?: true, and merge skips it because replaying the outer action runs the hook again. An update that changes no attribute is logged too, with the row left as it was and out ofdiff/1, because its hooks and managed relationships can write other rows and only the outer action can replay them (ADR 0014). - A failing action takes back its own writes and those of the actions its hooks ran.
- Notifications are dropped by default.
Ramal.run/2runs the function insideAsh.transaction([], ..., return_notifications?: true)and sends only the notifications of resources withnotify_in_branch? true. - Atomic updates:
atomic/3returns{:not_atomic, reason}while a branch is current, and the transformer setsrequire_atomic? falseon the resource's update and destroy actions so that Ash falls back to the path above. - Bulk actions and upserts are refused:
:bulk_unsupportedfor everyAsh.bulk_*,:upsert_unsupportedfor an action withupsert? true. The call-time optionupsert?: truecannot be seen by a hook and acts as a create. - Errors added by the hook are
Ramal.Errorstructs (a Splode error), found in an Ash error withRamal.Error.find/1. - Transactions: an action that opens a data-layer transaction inside a branch still opens one (cheap, empty). Nothing is written, so nothing needs rolling back. 0.2 may skip it.
5. Public API (0.1)
@spec branch(domain :: module, keyword) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
# opts: name, metadata
@spec run(branch | id, (-> result)) :: {:ok, result} | {:error, Ramal.Error.t()}
@spec run!(branch | id, (-> result)) :: result
@spec current() :: Ramal.Branch.t() | nil
@spec fork(branch | id, keyword) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
# the fork is a new branch with a copy of overlay, log and fingerprints; metadata.forked_from = parent id
@spec diff(branch | id) :: {:ok, [Ramal.Change.t()]} | {:error, Ramal.Error.t()}
@spec merge(branch | id, keyword) :: {:ok, Ramal.Merge.Result.t()} | {:error, Ramal.Error.t()}
# opts: actor, authorize? (default true), on_stale (:error | :ignore | (stale_rows -> :continue | :abort)), mode (:replay)
@spec propose(branch | id, keyword) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
# opts: proposed_by, reason
@spec approve(branch | id, keyword) :: {:ok, Ramal.Merge.Result.t()} | {:error, Ramal.Error.t()}
# opts: decided_by, reason, and the options of merge/2
@spec reject(branch | id, keyword) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
# opts: decided_by, reason
@spec aggregate(query | resource, kind, field | nil, keyword) :: {:ok, term} | {:error, Ash.Error.t()}
# kind: :count | :exists | :sum | :min | :max | :avg, computed in Elixir over the rows a read returns in the branch
@spec discard(branch | id) :: :ok | {:error, Ramal.Error.t()}
@spec get(id) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
@spec list(domain, keyword) :: {:ok, [Ramal.Branch.t()]} | {:error, Ramal.Error.t()}
# status filter; from storage, not from the registryRamal.Error has reason (an atom from a documented list), message, and details (a map). Reasons in 0.1:
:not_found, :not_open, :stale, :database_generated_key, :bulk_unsupported, :upsert_unsupported, :branch_too_large,
:no_branchable_resources, :nested_branch, :missing_snapshot, :not_started, :storage, :merge_failed,
:multitenancy_unsupported.
6. Persistence
Ramal.Storage is a behaviour: put(branch, config), fetch(id, config), delete(id, config),
list(domain, filters, config) (which returns {:ok, branches} or {:error, error}) and the optional
count(domain, statuses, config) (how many branches have one of the statuses; without it the limits count with list), where config is the Ramal.Supervisor options that are not its own (the Postgres
backend reads :repo from it). Two implementations:
Ramal.Storage.ETS: a named table owned byRamal.Supervisor. Default in tests and development. Lost on restart.Ramal.Storage.Postgres: one tableramal_branches(id uuid pk, domain text, name text, status text, state jsonb, inserted_at, updated_at).mix ramal.gen.migrationwrites the migration into the host's repo. Requires AshPostgres (or at least Ecto SQL); the module compiles only when it is present.
Serialization is Ramal.Storage.Codec, a pure module any text-based backend can use. The branch columns hold id,
domain, name and status; state holds the overlay (resource, key, op, record), the base (resource,
key, fingerprint, snapshot), the log, the metadata and inserted_at. A record is attrs (each attribute dumped
with Ash.Type.dump_to_embedded/3 and loaded with Ash.Type.cast_from_embedded/3, both confirmed in Ash 3.37), the
names of unloaded attributes, and the other struct fields when they differ from a fresh struct. Action input,
primary keys, options and actors are stored as tagged terms; actors are stored as the term itself, not through a
serializer (pass actor: to merge/2 to replace a stored one). Atoms are never created from stored data. See
ADR 0008.
7. Staleness and merge
Every base row a branch reads or writes gets a fingerprint: :erlang.phash2 of its attribute map at first touch,
keyed by {resource, key}. On merge/2:
- Refuse inside
Ramal.run/2(:nested_branch) and claim the branch: it must be:openand not already merging. While claimed, the branch process refuses every other call, so nothing is written between check and replay. - Inside one
Ash.transact/3, re-read every fingerprinted row from the base (one query per resource, key filter,FOR UPDATEwhere the data layer supports it). A row whose hash differs is:changed; one that no longer exists is:deleted. - Apply
on_stale::error(default) returns{:error, %Ramal.Error{reason: :stale, details: %{rows: [...]}}};:ignorecontinues; a function receives the stale rows and returns:continueor:abort. - In the same transaction, replay the log oldest first through the real actions with the real data layer (no branch
current), skipping entries logged by hooks of other captured actions (
nested?: true). The actor is theactoroption if given, else the one recorded with each entry. A replayed create keeps the primary key it had in the branch. Any failure rolls the transaction back and returns:merge_failedwith the log entry'sindex. Validations run again, so a branch that was valid against a base that has since moved can legitimately fail here; that is the feature. - Mark the branch
:merged, persist, stop the process. Return aRamal.Merge.Resultwith the records the replay produced, keyed like the overlay. On failure the claim is released and the branch stays open.
Limits and the reasons for these choices are in ADR 0007.
7.1 Proposals (0.2)
A branch can be frozen as a proposal that someone decides on, with the decision recorded on the branch itself.
propose(branch, proposed_by:, reason:)moves an:openbranch to:proposedand storesproposal: %{proposer, reason, proposed_at, decision: nil}. The branch is persisted before the call returns. A proposed branch refusesrun/2,fork/2,merge/2and a second proposal (:not_open);diff/1,get/1andlist(domain, status: :proposed)work, which is what a reviewer needs. The proposer can stilldiscard/1it.approve(branch, decided_by:, reason:, ...merge options)ismerge/2on a:proposedbranch, plus the decision:%{status: :approved, by, reason, at, replayed, stale}, written in the same step that marks the branch:merged. A stale base or a failing replay returns the errors ofmerge/2and leaves the branch:proposed.reject(branch, decided_by:, reason:)records%{status: :rejected, ...}, sets the status to:rejected, persists, and stops the process. Overlay and log are kept, so the rejected diff stays readable.- Ramal does not decide who may approve. The host authorizes before calling;
:decided_byis recorded, not checked. - No new table: the proposal is a field of the stored branch state, and the status column gains two values. The diff at
decision time is
diff/1of the stored branch, which is frozen from the proposal on (ADR 0012).
mode: :replay is the only merge mode in 0.1. A future :apply mode would write the overlay's final state directly,
skipping action logic; it is faster and less safe, and needs its own ADR.
8. Fork and diff
fork/2copies overlay, log andbaseinto a new open branch with its own process, a fresh id, the parent's name and metadata unless given, andmetadata.forked_from. Both branches share the database; neither sees the other's later changes. Persistent data structures make the copy cheap; the log is a list and gets shared structurally. A fork of a branch that is not open is:not_open. It emits[:ramal, :branch, :fork]withparent_id, then:load.diff/1walks the overlay: for:updated, compare the overlay record with the base row as the branch first saw it (kept alongside the fingerprint, so diff never queries the base) and keep only the attributes that differ; for:created, every attribute is{nil, value}; for:deleted,{value, nil}. An updated row whose values ended equal to the base is left out. Output order is the log order of first touch:Ramal.Overlay.diff/3computes the changes (sorted by ref, because the overlay map has no order) anddiff/1reorders them by the log (Ramal.Branch.diff/2, with the Ash-awareRamal.Resource.attributes/1). An updated or deleted entry with no base snapshot is{:error, %Ramal.Error{reason: :missing_snapshot}}: the write layer forgot to keep one.
9. Telemetry
Events, all with %{branch_id, domain} metadata:
[:ramal, :branch, :create], [:ramal, :branch, :fork], [:ramal, :branch, :unload], [:ramal, :branch, :load],
[:ramal, :branch, :discard], [:ramal, :branch, :propose], [:ramal, :branch, :approve], [:ramal, :branch, :reject], [:ramal, :branch, :too_large], [:ramal, :merge, :start | :stop | :exception] (span; stop adds result: :ok | :error and replayed or reason),
[:ramal, :read, :patch] with measurements %{base_rows, overlay_rows, result_rows, duration},
[:ramal, :read, :unpatched_aggregate], [:ramal, :write, :capture] with %{action_type, resource, action, nested?}.
[:ramal, :branch, :load] is emitted every time a branch process starts, including right after creation, so
create is followed by load. unload carries reason: :idle | :shutdown. discard is emitted once per discard
and is not followed by unload.
10. Test domain
test/support/farm/: domain Ramal.Test.Farm with five resources on Ramal.TestRepo (AshPostgres), all with
timestamps and, except the join resource, UUID primary keys:
Paddock:name,hectares,capacity_head(integer).Herd:name,head(integer),belongs_to :paddock.Move:belongs_to :herd,from_paddock_id,to_paddock_id,moved_on(date).Vetand the join resourceHerdVet(assigned_on, primary key{herd_id, vet_id}):HerdandVetaremany_to_manythrough it.Herdhas three update actions,add_vets,drop_vetsandset_vets, that manage the relationship withtype: :append,:removeand:append_and_remove.
Actions: Herd.move(to_paddock_id) with a validation "destination capacity not exceeded" that reads the
destination's herds (so a branch must see its own earlier moves for the validation to be right), creating a Move
in an after_action. Paddock has an aggregate head_count (sum of herds' head) to document the unpatched-aggregate
behaviour with a test. One policy on Herd (only %{role: :admin} may move) to prove policies run inside a branch.
The domain mirrors the first real user of Ramal (an agricultural operations app) and stays in English.
11. Open questions
- Several domains per branch, or one branch per domain? 0.1: one domain. Revisit when a real host needs more.
- Should
run/2be reentrant (a branch inside a branch)? 0.1: no,{:error, %Ramal.Error{reason: :nested_branch}};merge/2refuses the same way. Where do many-to-many join resources sit in the overlay?Answered: they are resources, so they get overlay entries like any other, keyed by the join resource's primary key (section 4.1 point 7, ADR 0014). A many_to_many load andmanage_relationshipdo not see links the branch changed; that is the open part, for a later version.Does the read preparation need to handleAnswered: Ash always selects the primary key, and the preparation trims overlay records to the select (section 4.1).Ash.Query.select?- Should fingerprints cover rows read through relationship loads? 0.1: yes, because they are reads like any other
(ADR 0006). Measured by
bench/(20,000 rows, one 4-core machine): hashing costs about 1.1 microseconds per row, and a read of 2,000 rows inside an empty branch takes about 67% longer than the same Ash read outside one, the hash and the call that stores the fingerprints together. Kept for now; the numbers and their limits are inbench/README.md. MultitenancyDecided for 0.1: refused inside a branch with:multitenancy_unsupported(ADR 0009). Supporting it puts the tenant in the overlay key and the base ref; revisit when a host needs it.ShouldNo: actors are stored as the term, with the attributes markedRamal.Storage.Postgrestake a host callback for actors?sensitive?dropped from resource records (ADR 0008, ADR 0010).
12. Inspector (ramal_live, 0.3)
A separate Mix project, ramal_live/, that depends on ramal by path. Core never depends on it. Public modules:
RamalLive.Router: the macroramal_inspector path, domain: ..., pubsub: ..., actor: ...(options:actor, required, and:debounce). It expands to oneliveroute, so the host puts it inside its own scope, pipeline andlive_sessionand the page uses the host's root layout, LiveView socket and JavaScript (ADR 0015).RamalLive.Broadcaster: a process the host starts. It attaches telemetry handlers (the branch lifecycle events,[:ramal, :write, :capture],[:ramal, :merge, :stop]) and broadcasts{:ramal_live, branch_id, event}on the host's PubSub, topic"ramal_live:" <> domain. No row data travels in the message.RamalLive: the setup guide.
Internal (@moduledoc false): RamalLive.InspectorLive (the page), RamalLive.BranchColumn (a LiveComponent per branch),
RamalLive.Column (the pure view model: facts and before/after rows as strings), RamalLive.Events (event names, topic,
the telemetry handler) and RamalLive.Style (the inline CSS).
The page. Open branches, then proposals, each oldest first, as columns. A column shows the name, status, short id, age,
overlay size (rows and writes), forked_from, the proposal's proposer and reason, and Ramal.diff/1 as before and after
rows (the first 100 changes; the rest are counted). Buttons: fork, merge and discard on an open branch; approve and reject
on a proposal. Merge, discard, approve and reject show an inline "are you sure?" first, and the server accepts the second
event only after the first. Merge and approve run in start_async.
What it reads. Ramal.list/2 for the ids, then Ramal.get/1 and Ramal.diff/1 per branch, so the page shows what
the public API shows, and loads the processes of the branches on screen. A telemetry event re-reads only the branch it
names, after a :debounce window (default 50 ms) that coalesces a burst of writes. Each read emits
[:ramal_live, :column, :read] with %{branch_id, domain} and a duration.
Identity. Slate #243044 for the base line, copper #D98324 only for branches; the dark theme uses the dark logo's
cream and light amber. Barlow Condensed for headings and IBM Plex Mono for data, with system fallbacks, because the page
does not load fonts. No Tailwind and no asset pipeline: the CSS is inline, scoped to .ramal, and follows
prefers-color-scheme unless the buttons in the header pick a theme.
Authorization is the host's: put the route behind its pipeline or on_mount. The :actor option is required and supplies
the actor recorded as decided_by and used to replay a merge; a function that returns nil makes the page refuse every
action (ADR 0016).
13. Decision log (summary; details in docs/adr/)
| # | Decision | Why |
|---|---|---|
| 1 | Hooks (preparation + change) instead of a wrapping data layer | A wrapping data layer breaks every tool that checks data_layer(resource) == AshPostgres.DataLayer (migrations, codegen) and must mirror AshPostgres' DSL. Hooks leave the resource's data layer untouched. |
| 2 | One process per branch | Isolation, lifecycle and limits come from the runtime for free. |
| 3 | Overlay materialized, log authoritative for merge | Reads need the overlay on every query; merge needs the intent, not the state. |
| 4 | Merge replays actions rather than writing state | Validations and policies run against the real base at merge time. Slower and right. |
| 5 | Limit and offset applied after patching | The only way a branch-created row can appear in a limited query. Documented as the cost of correctness. |
| 6 | No Phoenix in core; AshPostgres optional | Keeps the dependency surface small for the first users. |
| 7 | Ramal.Change is the diff struct, Ramal.Capture the write hook; the overlay records net effect | One name for one thing, and diff can rely on :deleted meaning the base had the row. ADR 0001 |
| 8 | The current branch id lives in the process dictionary and in Ash's shared context | Ash has no ambient context, and it runs some loads in tasks that lose the process dictionary. ADR 0002 |
| 9 | Idle timeout and heap limit belong to the supervisor; discard keeps the record; storage takes config last | A per-branch value is lost on unload, and a kept record gives :not_open a meaning. ADR 0003 |
| 10 | Reads are patched in before_action/after_action hooks; nothing is stripped when the overlay has no entry for the resource; the preparation sorts and pages the list itself | The hooks are where Ash allows changing the data-layer query and seeing its rows; runtime_sort takes a list, not a comparator. ADR 0004 |
| 11 | Writes are captured by a last before_action that sets the result; atomic updates fall back through atomic/3 and require_atomic? false; every bulk action and upsert action is refused; notifications are dropped unless the resource opts in; a failed action undoes its writes; Ramal.Error is a Splode error | around_action cannot skip the data layer by itself, the atomic path never reaches a hook, and Ash flattens other exceptions to strings. ADR 0005 |
| 12 | base holds %{fingerprint, snapshot} per touched row; every read fingerprints the base rows it returns, except when it selects some attributes; a fork copies everything and records forked_from | Staleness needs the state at first read, diff needs the state at first write, and a partial row has no comparable hash. ADR 0006 |
| 13 | Merge claims the branch first, checks staleness inside the replay transaction with row locks where possible, and forces created rows to keep their branch primary keys; entries logged by hooks are not replayed | Writes between check and replay would be lost, a check outside the transaction races, and later log entries name rows by key. ADR 0007 |
| 14 | Branches are stored through Ramal.Storage.Codec: attributes through the Ash embedded dump and cast, everything else as tagged terms, atoms never created, actors stored as the term; list/3 can fail | A round trip must be ==, the Ash cast_stored is the data layer's form, not JSON's, and a database can fail. ADR 0008 |
| 15 | A multitenant resource used with a tenant inside a branch is refused (:multitenancy_unsupported) | The overlay is keyed by {resource, key} and merge's staleness read has no tenant; half support would mix tenants silently. ADR 0009 |
| 16 | A stored actor that is an Ash record loses its sensitive? attributes; other actors are stored as they are | The branch table should not hold password hashes or tokens, and Ash already has the flag. A serializer callback has one use. ADR 0010 |
| 17 | Ramal.aggregate/4 reads the patched rows and computes the aggregate in Elixir; Ramal.Aggregate holds the pure partlimit, offset and distinct | The data layer's aggregates cannot see the overlay, and Ash offers no hook to make them. Exact for the branch, as expensive as reading the matching rows. ADR 0011 |
| 18 | A proposal is a branch status (:proposed, :rejected) and a proposal field of the stored state; approving is merging | A second table and storage API for one record per branch would duplicate what the branch row already persists, and the decision must land in the same step as the merge. ADR 0012 |
| 19 | Ramal.aggregate/4 aggregates in the database the rows outside the overlay, reads the overlaid ones through the patched path and joins the two; limit, offset and distinct keep the read-every-row path; the database's part is not fingerprinted | Reading every row made scoring a state cost a table scan (measured in bench/). The delta is exact and its cost follows the overlay. ADR 0013 |
| 20 | A many_to_many join resource gets overlay entries like any resource; an update that changes no attribute is logged; a many_to_many load and manage_relationship do not see links the branch changed (documented limit) | Hooks and managed relationships write nested rows that only the outer action can replay; AshPostgres loads the relationship in SQL over the base join table. ADR 0014 |
| 21 | The inspector is a separate project whose router macro expands to one live route in the host's own live_session; options travel as route metadata; a supervised broadcaster turns telemetry into PubSub messages naming a branch, and the page re-reads only that branch | A live_session of its own would need its own root layout, socket and JavaScript, which is an asset pipeline; telemetry is node-local, so a handler attached on first page view would miss events. ADR 0015 |
| 22 | ramal_inspector requires :actor; Ramal.branch/2 and Ramal.fork/2 can be limited per domain by :max_active_branches and :branch_rate of Ramal.Supervisor, both off by default | A page that acts for nobody lets anyone who reaches the route merge as no one, and an unlimited fork is a process and a row per call. The limits stay off so no host changes behaviour by upgrading. ADR 0016 |