All notable changes to this project are documented here. The format follows Keep a Changelog, and the project follows Semantic Versioning once it reaches 0.1.
Unreleased
0.2.0 - 2026-10-23
Proposals, fast aggregates, many-to-many join resources, the ramal_live inspector, a benchmark, and a security pass. No breaking change to the public API of 0.1.0: a branch stored by 0.1.0 loads unchanged, and the public functions of 0.1.0 behave as before.
Added
Ramal.propose/2,Ramal.approve/2andRamal.reject/2: a branch can be frozen as a proposal (status:proposed) that a decider approves, which merges it, or rejects. The proposer, the decider, the reasons, the replay count and the stale rows are recorded on the branch and stored with it;list(domain, status: :proposed)finds the pending ones. Telemetry[:ramal, :branch, :propose | :approve | :reject]. Branches stored by 0.1.0 load unchanged.Ramal.aggregate/4andRamal.aggregate!/4: count, exists, sum, min, max and avg over the rows a read returns inside a branch, so a branch's creates, updates and destroys are included. Outside a branch they call the matchingAshfunction.Ramal.Aggregateholds the pure computation. The database aggregates the rows the overlay does not touch and the branch reads only the overlaid ones, so the cost follows the overlay and not the table; a query with a limit, an offset or a distinct reads every matching row. The rows the database aggregates are not fingerprinted for the merge staleness check (ADR 0013).- Many-to-many join resources inside a branch are tested and documented (DESIGN section 4.1 point 7, ADR 0014): the join resource gets overlay entries keyed by its primary key, and a branch diffs, merges and stores them. A many_to_many load and
manage_relationshipdo not see links the branch changed; both are listed as known limits. ramal_live/: a separate Mix project with a LiveView inspector for branches.ramal_inspector "/ramal", domain: MyApp.Farm, pubsub: MyApp.PubSubmounts it inside the host's ownlive_session, so it uses the host's LiveView socket and JavaScript; there is no asset pipeline, the CSS is inline and has light and dark themes. Open and proposed branches are columns with theirRamal.diff/1as before and after rows, and buttons to fork, merge, discard, approve and reject, each destructive one with an inline confirmation.RamalLive.Broadcasterturns Ramal's telemetry into PubSub messages so the columns update live. Core is unchanged. Decision in ADR 0015.ramal_live/examples/farm_demo.exs: a runnable demo of the inspector for a screen recording. One command starts a Phoenix Playground app on a seeded farm in theramal_devdatabase; "Explore 4 alternatives" opens four branches that each move a herd to a different paddock while the columns fill in live, and merging one makes the others stale. How to run it is inramal_live/README.md.bench/: a separate Mix project that measures fork, read, write, merge, fingerprinting and a tree search against two plain-Postgres baselines (a table copied per fork, and a shared delta table).mix benchruns it,mix bench --quickis a smoke run that CI compiles and executes. Results and their limits are inbench/README.md.bench/results rerun after fast aggregates: the tree search takes 183 ms instead of 5,705 ms (1.07 times the delta baseline instead of 23 times), and every table inbench/README.mdnow comes from that one run. The earlier raw samples stay inbench/results/published/next to the new ones.ramal_liveis versioned with Ramal (0.2.0) and builds as a Hex package:RAMAL_LIVE_HEX=1makes itsmix.exsdepend on the publishedramal ~> 0.2.0instead of the checkout next to it, and the package ships itsLICENSEand builds its docs with ExDoc.- Community files for the public launch:
CODE_OF_CONDUCT.md(Contributor Covenant 2.1),SECURITY.md, bug and feature issue templates, a pull request template that mirrors the rules inCLAUDE.md, and Dependabot for the root Mix project,bench/and GitHub Actions. - A "Security model and defaults" section in
SECURITY.md, and ADR 0016: what Ramal enforces and what it leaves to the host, how stored state is treated, and how to mount the inspector in production. CI runsmix hex.auditfor the library,bench/andramal_live/. Ramal.Supervisoroptions:max_active_branchesand:branch_rate: optional per-domain limits on the open and proposed branches, and on the branches and forks created per window.Ramal.branch/2andRamal.fork/2return{:error, %Ramal.Error{reason: :limit_reached}}past either, with the limit indetails, and emit[:ramal, :limit, :reached]. Both are off by default, so nothing changes until a host sets them (ADR 0016,SECURITY.md).Ramal.Storage.count/3, an optional callback that counts a domain's branches by status without loading them.Ramal.Storage.ETSandRamal.Storage.Postgresdefine it; a backend without it is counted withlist/3.
Security
ramal_live: the:actoroption oframal_inspector/2is required. A router without it fails to compile with a message that says what to pass, and a route whose actor function does not exist raises when the page opens. A page with no actor would merge every branch as the actors the writes were captured with and record no decider; a tool with no users passes a function that returns a constant (ADR 0016).ramal_live: attributes markedsensitive?: trueshow as[redacted]in a branch's diff. The page printed every value, so a password hash or token written in a branch was visible to everyone who could open the inspector.ramal_live: when the:actorfunction returnsnilthe page refuses fork, merge, discard, approve and reject. Before, a session without a user merged as the recorded actors with no decider.inspect/1of aRamal.Branchprints only its id, name, domain, status and creation time. The log holds the raw input of every captured write, which Ash does not redact, and a crash report prints the state of a branch process.Ramal.Storage.Codec.decode_state/2returns{:error, %Ramal.Error{reason: :storage}}for a malformed stored state instead of raising, never echoes stored values in the error, rejects a module name that is not a name (Elixir.../../x) before it touches the code path, and rejects a stored struct whose module is not a struct.
Fixed
Ramal.merge/2andRamal.approve/2on resources backed byAsh.DataLayer.Etsreturned aCaseClauseErrorinstead of{:error, %Ramal.Error{}}when the staleness check or the replay failed. The error is now returned as with Postgres. ETS cannot roll back, so rows replayed before a failing entry stay written; the error message anddetails.not_rolled_backsay so (ADR 0007).docs/ROADMAP.mdandbench/README.mdno longer say the DAPLab article was unreachable; they link it and state how the benchmark differs from theirs.- An update that changes no attribute is now logged, so a merge replays it. Before, the rows its hooks or managed relationships wrote (the links of a
manage_relationship, a record created in anafter_action) were logged as nested under an update that was not logged, and the merge dropped them. - A load of a many_to_many (or a limited has_many) inside a branch no longer loses a destination row the branch updated: the patched row keeps the source record Ash groups the load by.
- README: the DAPLab quotation now matches the article word for word, and the Neon and Dolt summary matches its findings.
0.1.0 - 2026-10-22
Added
- Project scaffold: design document, roadmap, conventions, CI, test database setup, logo.
Ramal.Overlay: the pure overlay of a branch (put, slice, patch, diff, merge for forks),Ramal.ChangeandRamal.Error.- Branch processes:
Ramal.Supervisor,Ramal.branch/2,run/2,run!/2,current/0,get/1,list/2anddiscard/1, with idle unload, transparent rehydration, a heap limit, and telemetry for the branch lifecycle. Ramal.Storagebehaviour and theRamal.Storage.ETSbackend.Ramal.Resourceextension andRamal.Preparation: reads inside a branch see rows created, updated and deleted in it, with limit, offset, sort, select and relationship loads applied to the patched rows. Telemetry for[:ramal, :read, :patch]and[:ramal, :read, :unpatched_aggregate].Ramal.Capture: creates, updates and destroys insideRamal.run/2land in the branch overlay and log instead of the data layer, with validations, policies and hooks still running. Notifications are dropped unless a resource setsnotify_in_branch? true; bulk actions and upsert actions are refused; a failed action undoes its writes.Ramal.Erroris now a Splode error withRamal.Error.find/1. Telemetry for[:ramal, :write, :capture].Ramal.fork/2andRamal.diff/1: a fork starts with its parent's overlay, log and base and shares nothing afterwards;diff/1returnsRamal.Changestructs in log order without querying the database. Reads record a fingerprint of every base row they return, for the merge staleness check.Ramal.branch/2returns:no_branchable_resourcesfor a domain none of whose resources opted in.Ramal.merge/2: checks that the base rows the branch saw are unchanged (on_stale: :error | :ignore | fun), then replays the branch log through the real actions in one transaction, so validations and policies run again. Failures return:staleor:merge_failedand leave the base untouched; a merged branch cannot be merged again. Telemetry span[:ramal, :merge].Ramal.Storage.Postgresandmix ramal.gen.migration: branches persist in aramal_branchestable and load back equal in a fresh VM.Ramal.Storage.Codecserializes a branch through the Ash embedded types without creating atoms.Ramal.Storage.list/3now returns{:ok, branches} | {:error, error}.- Documentation pass for 0.1: the README example runs as a test (
test/readme_test.exs), a test checks that every public module has an example and every public function a@docand@spec,mix docsbuilds without warnings and includes the ADRs, and every [confirm] mark in the design is resolved. - A multitenant resource used with a tenant inside a branch is refused with
:multitenancy_unsupported(Ramal.Resource.ensure_untenanted/2), instead of mixing tenants in one overlay or failing at merge. - Stored branches no longer hold the
sensitive?attributes of an actor that is an Ash record (they are stored asnil); other actors are stored as before.