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 (a DynamicSupervisor plus a Registry keyed by branch id). The host adds it to its tree: {Ramal.Supervisor, storage: Ramal.Storage.Postgres, repo: MyApp.Repo}.
  • Ramal.Branch.Server is a GenServer per open branch. State is the %Ramal.Branch{}. It owns the overlay and the log; every write inside the branch is a GenServer.call to 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, a Ramal.Supervisor option) 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/2 and discard/1 all accept a branch id and rehydrate the process on demand. The host never needs to know whether the process is alive.
  • Limits: max_active_branches and branch_rate, Ramal.Supervisor options, both off by default. The internal module Ramal.Limits checks them in branch/2 and fork/2 before anything is created, per domain: the first caps the open and proposed branches (read from storage with the optional count/3 callback, 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_size on the server process, a Ramal.Supervisor option, 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 of fun, restoring the previous state afterwards even when fun raises. The first hook that runs also copies the id into the query's or changeset's Ash shared context, because Ash runs some loads in tasks that do not inherit the process dictionary. Tasks the host spawns inside fun do not inherit the branch; Ramal.run/2 inside 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:

  1. Before the data layer: in a before_action hook (it runs after every preparation and before the data-layer query is built, lib/ash/actions/read/read.ex), remember the query's limit and offset and 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.
  2. After the data layer: take the base results and the overlay slice for this resource, then:
    • drop every base row whose key is :deleted in the overlay;
    • replace every base row whose key is :updated with the overlay record;
    • add every :created and :updated overlay record that is not already present and that matches the query's filter, evaluated in Elixir with Ash.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 remembered offset and limit. Ramal.Overlay.patch/4 does the merge as a pure function and supplies the filter predicate's rows; the preparation calls it without :sort, :offset and :limit and 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.
  3. 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?: true paths of Ash.load, loads inside a read, and Ash.get/3 (the not-found check runs after the after_action hooks, 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's shared context, 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.
  4. 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.
  5. Pagination: Ash adds a page's limit and offset after the before_action hooks, 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, and count is the database's. 0.1 documents this. Patching the whole list and paging it in Elixir is possible but means rebuilding Ash.Page values, and is left to a later version.
  6. Policies: the query's filter, including one a policy added, is applied to overlay records. Checks that act on results (authorize_results) run before the after_action hooks and so do not see overlay records.
  7. 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_many join 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?/4 in lib/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_relationship decides what to create and remove from that same load. So type: :append_and_remove is refused with :bulk_unsupported when it has a link to remove (Ash unrelates with Ash.bulk_destroy/4, bulk_delete_unused_unrelate/8 in lib/ash/actions/managed_relationships.ex), type: :remove cannot find a link the branch created, and type: :append of 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.

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_action adds a last before_action that builds the record and calls Ash.Changeset.set_result/2, so the data layer is skipped. around_action itself 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_action and after_action hooks, 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_key otherwise.
  • Destroys mark the key :deleted. Updates of a :created row stay :created with the new record. The first write to an existing row keeps the row as the action found it in base, which diff/1 compares against.
  • Every write is appended to the log as {action_type, resource, action_name, params, actor, opts, at}; opts has key, tenant and nested?. A write made by a hook of another captured action (or by a managed relationship) is nested?: 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 of diff/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/2 runs the function inside Ash.transaction([], ..., return_notifications?: true) and sends only the notifications of resources with notify_in_branch? true.
  • Atomic updates: atomic/3 returns {:not_atomic, reason} while a branch is current, and the transformer sets require_atomic? false on the resource's update and destroy actions so that Ash falls back to the path above.
  • Bulk actions and upserts are refused: :bulk_unsupported for every Ash.bulk_*, :upsert_unsupported for an action with upsert? true. The call-time option upsert?: true cannot be seen by a hook and acts as a create.
  • Errors added by the hook are Ramal.Error structs (a Splode error), found in an Ash error with Ramal.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 registry

Ramal.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 by Ramal.Supervisor. Default in tests and development. Lost on restart.
  • Ramal.Storage.Postgres: one table ramal_branches (id uuid pk, domain text, name text, status text, state jsonb, inserted_at, updated_at). mix ramal.gen.migration writes 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:

  1. Refuse inside Ramal.run/2 (:nested_branch) and claim the branch: it must be :open and not already merging. While claimed, the branch process refuses every other call, so nothing is written between check and replay.
  2. Inside one Ash.transact/3, re-read every fingerprinted row from the base (one query per resource, key filter, FOR UPDATE where the data layer supports it). A row whose hash differs is :changed; one that no longer exists is :deleted.
  3. Apply on_stale: :error (default) returns {:error, %Ramal.Error{reason: :stale, details: %{rows: [...]}}}; :ignore continues; a function receives the stale rows and returns :continue or :abort.
  4. 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 the actor option 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_failed with the log entry's index. Validations run again, so a branch that was valid against a base that has since moved can legitimately fail here; that is the feature.
  5. Mark the branch :merged, persist, stop the process. Return a Ramal.Merge.Result with 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 :open branch to :proposed and stores proposal: %{proposer, reason, proposed_at, decision: nil}. The branch is persisted before the call returns. A proposed branch refuses run/2, fork/2, merge/2 and a second proposal (:not_open); diff/1, get/1 and list(domain, status: :proposed) work, which is what a reviewer needs. The proposer can still discard/1 it.
  • approve(branch, decided_by:, reason:, ...merge options) is merge/2 on a :proposed branch, 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 of merge/2 and 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_by is 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/1 of 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/2 copies overlay, log and base into a new open branch with its own process, a fresh id, the parent's name and metadata unless given, and metadata.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] with parent_id, then :load.
  • diff/1 walks 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/3 computes the changes (sorted by ref, because the overlay map has no order) and diff/1 reorders them by the log (Ramal.Branch.diff/2, with the Ash-aware Ramal.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).
  • Vet and the join resource HerdVet (assigned_on, primary key {herd_id, vet_id}): Herd and Vet are many_to_many through it. Herd has three update actions, add_vets, drop_vets and set_vets, that manage the relationship with type: :append, :remove and :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

  1. Several domains per branch, or one branch per domain? 0.1: one domain. Revisit when a real host needs more.
  2. Should run/2 be reentrant (a branch inside a branch)? 0.1: no, {:error, %Ramal.Error{reason: :nested_branch}}; merge/2 refuses the same way.
  3. 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 and manage_relationship do not see links the branch changed; that is the open part, for a later version.
  4. Does the read preparation need to handle Ash.Query.select? Answered: Ash always selects the primary key, and the preparation trims overlay records to the select (section 4.1).
  5. 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 in bench/README.md.
  6. Multitenancy Decided 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.
  7. Should Ramal.Storage.Postgres take a host callback for actors? No: actors are stored as the term, with the attributes marked 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 macro ramal_inspector path, domain: ..., pubsub: ..., actor: ... (options :actor, required, and :debounce). It expands to one live route, so the host puts it inside its own scope, pipeline and live_session and 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/)

#DecisionWhy
1Hooks (preparation + change) instead of a wrapping data layerA 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.
2One process per branchIsolation, lifecycle and limits come from the runtime for free.
3Overlay materialized, log authoritative for mergeReads need the overlay on every query; merge needs the intent, not the state.
4Merge replays actions rather than writing stateValidations and policies run against the real base at merge time. Slower and right.
5Limit and offset applied after patchingThe only way a branch-created row can appear in a limited query. Documented as the cost of correctness.
6No Phoenix in core; AshPostgres optionalKeeps the dependency surface small for the first users.
7Ramal.Change is the diff struct, Ramal.Capture the write hook; the overlay records net effectOne name for one thing, and diff can rely on :deleted meaning the base had the row. ADR 0001
8The current branch id lives in the process dictionary and in Ash's shared contextAsh has no ambient context, and it runs some loads in tasks that lose the process dictionary. ADR 0002
9Idle timeout and heap limit belong to the supervisor; discard keeps the record; storage takes config lastA per-branch value is lost on unload, and a kept record gives :not_open a meaning. ADR 0003
10Reads 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 itselfThe hooks are where Ash allows changing the data-layer query and seeing its rows; runtime_sort takes a list, not a comparator. ADR 0004
11Writes 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 erroraround_action cannot skip the data layer by itself, the atomic path never reaches a hook, and Ash flattens other exceptions to strings. ADR 0005
12base 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_fromStaleness needs the state at first read, diff needs the state at first write, and a partial row has no comparable hash. ADR 0006
13Merge 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 replayedWrites between check and replay would be lost, a check outside the transaction races, and later log entries name rows by key. ADR 0007
14Branches 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 failA round trip must be ==, the Ash cast_stored is the data layer's form, not JSON's, and a database can fail. ADR 0008
15A 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
16A stored actor that is an Ash record loses its sensitive? attributes; other actors are stored as they areThe branch table should not hold password hashes or tokens, and Ash already has the flag. A serializer callback has one use. ADR 0010
17Ramal.aggregate/4 reads the patched rows and computes the aggregate in Elixir; Ramal.Aggregate holds the pure part Superseded by 19 for the common kinds; the read-every-row path stays for limit, offset and distinctThe 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
18A proposal is a branch status (:proposed, :rejected) and a proposal field of the stored state; approving is mergingA 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
19Ramal.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 fingerprintedReading 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
20A 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
21The 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 branchA 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
22ramal_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 defaultA 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