# Ramal design

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](adr/0014-many-to-many-join-resources.md)). 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](adr/0005-write-capture.md)):

- `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)

```elixir
@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](adr/0008-postgres-storage.md).

## 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](adr/0007-merge.md).

### 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](adr/0012-proposals.md)).

`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/`)

| # | 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](adr/0001-change-struct-and-capture-module.md) |
| 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](adr/0002-current-branch-carrier.md) |
| 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](adr/0003-branch-process-lifecycle.md) |
| 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](adr/0004-read-patching-hooks.md) |
| 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](adr/0005-write-capture.md) |
| 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](adr/0006-fingerprints-and-fork.md) |
| 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](adr/0007-merge.md) |
| 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](adr/0008-postgres-storage.md) |
| 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](adr/0009-multitenancy-refused.md) |
| 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](adr/0010-stored-actors.md) |
| 17 | ~~`Ramal.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 `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](adr/0011-branch-aggregates.md) |
| 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](adr/0012-proposals.md) |
| 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](adr/0013-aggregate-delta.md) |
| 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](adr/0014-many-to-many-join-resources.md) |
| 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](adr/0015-inspector-mounting.md) |
| 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](adr/0016-security-defaults.md) |
