0005. Write capture: hook, atomic fallback, bulk, notifications and errors

Copy Markdown View Source

Status: accepted, 2026-10-22. Issue: #5. Resolves the [confirm] items in design section 4.2.

Context

Design section 4.2 planned to capture writes in around_action, to fall back from atomic updates through atomic/3, to refuse some bulk actions and to suppress notifications. It was checked against Ash 3.37.

Decision

  • Hook: Ramal.Capture is a global change for create, update and destroy. around_action cannot return a result without running the callback, and the before-action hooks run inside that callback. So the around hook appends one last before_action that builds the record with Ash.Changeset.apply_attributes/2 (which applies defaults, so the undocumented set_defaults/3 is not needed) and calls Ash.Changeset.set_result(changeset, {:ok, record}). Create, update and destroy then skip the data layer but still run relationship management and the after-action hooks.
  • Atomic updates: Ash tries fully_atomic_changeset first and updates in the database without calling any hook. atomic/3 returns {:not_atomic, reason} while a branch is current and {:ok, changeset} otherwise, so outside a branch the action stays atomic. Ash only falls back for require_atomic? false, so the transformer sets it on the update and destroy actions of a branchable resource. A resource loses require_atomic? true as a guard, also outside a branch.
  • Bulk and upsert: bulk paths call the data layer without reading the result, so every Ash.bulk_* is refused with :bulk_unsupported (the design only refused return_records?: false). An action with upsert? true is refused with :upsert_unsupported. The call-time option upsert?: true is invisible to hooks (Ash sets it in the context of every create when the action runs) and acts as a plain create.
  • Notifications: Ramal.run/2 runs fun inside Ash.transaction([], fun, return_notifications?: true), which opens no database transaction and hands back the queue instead of sending it. Only resources with notify_in_branch? true get theirs sent afterwards. Default off.
  • Failures: a captured action that fails or raises takes back the writes it and its hooks' actions made, using the undo Ramal.Branch.put_write/6 returns. A database transaction would have rolled them back.
  • Nested writes: an action called from a hook of a captured action is logged with nested?: true; replaying the outer action runs the hook again, so merge skips nested entries.
  • Log entry: {action_type, resource, action_name, params, actor, [key:, tenant:, nested?:], at} with changeset.params; changes made with change_attribute/3 outside the action's inputs are not replayed.
  • Errors: Ramal.Error implements Splode.Error (class :invalid), because Ash turns any other exception added to a changeset into a string and the reason is lost. Ramal.Error.find/1 extracts it from an Ash error. :splode is declared as a dependency; it was already in the tree through Ash.

Consequences

  • An update that changes no attribute writes nothing to the overlay. It is logged since ADR 0014, which explains why.
  • A create with an explicit primary key that exists in the base is not detected, and updating a row the branch already deleted resurrects it; a database would raise.
  • An undo can overwrite another process's interleaved write to the same row; the branch process serialises writes but does not isolate actions.