---
title: Multi-Select, Bulk Edit & Mass Reassignment
slug: bulk-edit-and-mass-reassignment
category: Tables
summary: Multi-select rows and apply one change to all of them — with an explicit field list, a real "leave this alone" state, mixed-value display, a blast-radius preview, a batch record you can undo, and a guard that refuses to blank real data.
tags: [tables, bulk-edit, multi-select, data-safety, stimulus, hotwire, audit]
status: stable
visibility: public
source_project: rsb.llamapress.ai
layers: [view, stimulus_js, controller, model, sql]
related:
  - title: High-Quality Inline-Editable Table
    url: /cookbook/inline-editable-table
    summary: The single-cell pattern this one extends. Read it first — bulk editing is a different operation with a different payload, not that pattern in a loop.
  - title: Filterable Index with Slide-Out Detail Drawer
    url: /cookbook/filterable-index-with-detail-drawer
    summary: How users get to a meaningful selection in the first place — filter down to the set, then bulk-edit what's left.
---

# Multi-Select, Bulk Edit & Mass Reassignment

> ⚠️ **Cookbook example — not live code.** Every code block below is an **example
> snippet**, **not part of the llamapress.ai codebase**, and **not running on this
> server**. This is a reference recipe for a **Leo instance (an AI coding agent) to
> implement in its own app** — read it to understand the pattern, then recreate it there.

**Multi-select** a set of rows, set one field, hit Apply. This guide covers the two
halves of that: the **selection model** (how the user picks rows and knows what they
picked) and the **bulk write** (the payload, the endpoint, and the safety mechanisms
that stop it destroying data nobody looked at).

> **When to use:** any table where a user needs to change the same thing on many rows —
> mass-reassigning an owner, marking a batch shipped/approved/archived, fixing an
> imported column on hundreds of rows.
> **When not to:** editing one cell on one record — that's the
> [inline-editable-table](/cookbook/inline-editable-table) pattern, and it is safe
> precisely because it does not do any of what's below.

> **Adding multi-select to an existing inline-editable table is the single most
> dangerous edit you can make to it.** It is not a checkbox column plus a loop. Read
> "Why this is its own guide" before you add the first `<input type="checkbox">`.

> The examples use a `Contact` model with `name/company/status/owner_id/notes`.
> Swap in your own model and columns — see **How to adapt** at the bottom.

---

## Why this is its own guide

Multi-select looks like a UI feature. It is not — it is a change to what a single
user action *means*. Bulk editing turns one instruction into many writes. Your error
rate stays the same. Your blast radius does not.

Worse, the user looks at **one** row while writing to **many**, so the feedback loop
that normally catches mistakes is switched off by the design itself. Assume errors
will be silent and found weeks later by a customer.

**A real incident.** A production app extended the single-cell pattern two ways: it
replaced per-cell saves with a whole-row form that submits all 26 columns, and it added
multi-select so an edit on one row applied to the rest. The server then worked out
*what to apply* by asking the database what had changed:

```ruby
# ❌ THE BUG. Never do this.
changed_fields = item.previous_changes.keys & SYNCABLE_FIELDS
```

The edited row held `NULL` in a delivery-number column. HTML forms submit **every**
field, so the browser sent `""`. Rails recorded `nil → ""` as a change. That empty
value was then copied onto the other 25 rows and erased their real delivery numbers.
The same mechanism on a different column wiped 35 assignee names, and a downstream
callback then removed 35 QC checkmarks because those stages no longer had an assignee.
Across six weeks and 9 projects, roughly 1,400 real values were replaced with blanks.
Nobody noticed until a customer did.

**The root cause, stated as a principle:**

> **The server inferred user intent from a state difference.** "What changed in the
> database" and "what did the user ask for" are different questions. A state difference
> also contains fields the browser sent empty, fields holding stale values because the
> page loaded ten minutes ago, and fields a callback wrote on its own.

Everything below exists to make that inference impossible.

---

## The 80/20 in one breath

1. **The request names the fields.** The payload carries an explicit list of columns to
   set and columns to clear. The server never asks `changed`, `previous_changes`, or
   diffs old against new.
2. **Three states, not two.** Every column is *set on all*, *cleared on all*, or
   **left alone per row**. "Left alone" = absent from both lists.
3. **Mixed values show as "multiple values", never blank.** A blank is a false statement
   about the selection.
4. **Preview before write.** Same endpoint, `dry_run: true`, so the preview can't drift
   from the write.
5. **The batch is a record.** A `BulkEdit` row with an id, the user, the field list, the
   target ids, and the before-value of every row it touched — so you can audit and undo it.
6. **A guard refuses to blank a real value** unless the user explicitly asked to clear
   that field. This is the one rule that catches the failure nobody predicted.

---

## Layer 1 — The payload contract

This is `params[:column]` from the single-cell pattern, grown up. It is **not** a record.

```jsonc
// POST /contacts/bulk_update
{
  "selected_ids": [12, 15, 19, 23],
  "set":   { "status": "delivered", "owner_id": 4 },  // apply to every selected row
  "clear": ["notes"],                                 // blank on every selected row
  "dry_run": false
  // Any column in NEITHER list keeps each row's own value. That is the third state.
}
```

Three properties make this safe:

- **`set` is the instruction.** Nothing is inferred from record state.
- **`clear` is separate from `set`.** "Make this empty" is a deliberate, distinguishable
  act — not an empty string that looks like an untouched form field.
- **Absence means "leave alone".** A single-cell form has no way to express this, which
  is exactly why extending a row form into a bulk form destroys data.

> ⚠️ **If your bulk form cannot express "leave this field alone", it will destroy data.**
> That is not a risk, it's a certainty on a long enough timeline.

---

## Layer 2 — Model, batch record & SQL

`app/models/contact.rb` — reuse the **same** whitelist as single-cell editing. It is the
security boundary for both paths, applied to every column in the list.

```ruby
class Contact < ApplicationRecord
  belongs_to :account
  belongs_to :owner, class_name: "User", optional: true

  EDITABLE_COLUMNS = %w[name company status owner_id notes].freeze

  # Columns a bulk edit may touch. Usually a SUBSET of EDITABLE_COLUMNS —
  # per-row identity fields (name, email) rarely make sense to mass-assign.
  BULK_COLUMNS = %w[company status owner_id notes].freeze
end
```

`app/models/bulk_edit.rb` — the batch's identity. Without this you cannot undo a batch,
audit it as a unit, or alert on it.

```ruby
class BulkEdit < ApplicationRecord
  belongs_to :user
  belongs_to :account

  # target_type    : "Contact"
  # target_ids     : jsonb array of ids
  # set_values     : jsonb hash  { "status" => "delivered" }
  # cleared_columns: jsonb array [ "notes" ]
  # results        : jsonb array [ { "id" => 12, "status" => "ok", "before" => {...} } ]

  def columns_touched = set_values.keys + cleared_columns

  def undo!(actor:)
    transaction do
      results.select { _1["status"] == "ok" }.each do |row|
        account.contacts.where(id: row["id"]).update_all(row["before"].symbolize_keys)
      end
      update!(undone_at: Time.current, undone_by_id: actor.id)
    end
  end
end
```

```ruby
# db/migrate/XXXX_create_bulk_edits.rb
class CreateBulkEdits < ActiveRecord::Migration[7.1]
  def change
    create_table :bulk_edits do |t|
      t.references :user,    null: false, foreign_key: true
      t.references :account, null: false, foreign_key: true
      t.string  :target_type,    null: false
      t.jsonb   :target_ids,     null: false, default: []
      t.jsonb   :set_values,     null: false, default: {}
      t.jsonb   :cleared_columns, null: false, default: []
      t.jsonb   :results,        null: false, default: []
      t.integer :undone_by_id
      t.datetime :undone_at
      t.timestamps
    end
    add_index :bulk_edits, [:account_id, :created_at]
  end
end
```

If you keep an audit/version log, **stamp `bulk_edit_id` on every record it writes.** In
the incident above, investigators had to reconstruct the batch by finding audit rows
written within 0.2 seconds of each other, because nothing recorded that a bulk edit had
happened at all.

---

## Layer 3 — The apply service (and the guard)

`app/services/bulk_edits/apply.rb` — one class, used by both the preview and the write.

```ruby
module BulkEdits
  class Apply
    class Unsafe < StandardError; end

    Preview = Struct.new(:target_count, :per_column, :destructive_count, keyword_init: true)

    def initialize(scope:, ids:, set: {}, clear: [], user:, dry_run: false)
      @scope   = scope                                   # already tenant-scoped
      @ids     = Array(ids).map(&:to_i).uniq
      @set     = set.to_h.stringify_keys.slice(*Contact::BULK_COLUMNS)
      @clear   = Array(clear).map(&:to_s) & Contact::BULK_COLUMNS
      @user    = user
      @dry_run = dry_run
    end

    def call
      raise Unsafe, "No rows selected"  if @ids.empty?
      raise Unsafe, "No fields to apply" if @set.empty? && @clear.empty?
      raise Unsafe, "A field cannot be both set and cleared" if (@set.keys & @clear).any?

      # ── RULE 6: the guard. ────────────────────────────────────────────────
      # A bulk apply never replaces a real value with an empty one unless the
      # user explicitly asked to clear that field. This fires no matter which
      # column, user, or form is involved — including the ones nobody predicted.
      blank_sets = @set.select { |_, v| blank_value?(v) }.keys
      raise Unsafe, "Refusing to blank #{blank_sets.join(', ')} — use `clear` to do that on purpose" if blank_sets.any?

      records = @scope.where(id: @ids).to_a
      return preview(records) if @dry_run

      write!(records)
    end

    private

    def blank_value?(v) = v.nil? || (v.is_a?(String) && v.strip.empty?)

    def assignments = @set.merge(@clear.index_with(nil))

    def preview(records)
      per_column = assignments.keys.index_with do |col|
        target = assignments[col]
        {
          changing: records.count { |r| normalize(r[col]) != normalize(target) },
          losing:   records.count { |r| !blank_value?(r[col]) && blank_value?(target) }
        }
      end
      Preview.new(
        target_count:     records.size,
        per_column:       per_column,
        destructive_count: per_column.values.sum { _1[:losing] }
      )
    end

    def write!(records)
      bulk = nil
      ActiveRecord::Base.transaction do
        bulk = BulkEdit.create!(
          user: @user, account: @user.account, target_type: @scope.klass.name,
          target_ids: records.map(&:id), set_values: @set, cleared_columns: @clear
        )
        results = records.map do |record|
          before = assignments.keys.index_with { |col| record[col] }
          record.assign_attributes(assignments)
          if record.save
            { id: record.id, status: "ok", before: before }
          else
            { id: record.id, status: "failed", errors: record.errors.full_messages }
          end
        end
        bulk.update!(results: results)
      end
      bulk
    end

    # One canonical representation of "no value" — see Gotchas.
    def normalize(v) = blank_value?(v) ? nil : v
  end
end
```

**Read the guard twice.** Rules 1–5 require the author to have correctly predicted the
failure mode. The guard does not. It is the backstop that catches the class, not the
instance — and it is what would have stopped the incident in one line.

---

## Layer 4 — Controller

`app/controllers/contacts_controller.rb` — one action, two modes.

```ruby
# POST /contacts/bulk_update
def bulk_update
  service = BulkEdits::Apply.new(
    scope:   current_user.account.contacts,          # tenant scope, always
    ids:     params[:selected_ids],
    set:     params.fetch(:set, {}).permit(*Contact::BULK_COLUMNS).to_h,
    clear:   params[:clear],
    user:    current_user,
    dry_run: ActiveModel::Type::Boolean.new.cast(params[:dry_run])
  ).call

  if service.is_a?(BulkEdits::Apply::Preview)
    render json: { ok: true, preview: service.to_h }
  else
    render json: { ok: true, bulk_edit_id: service.id,
                   applied: service.results.count { _1["status"] == "ok" },
                   failed:  service.results.count { _1["status"] == "failed" } }
  end
rescue BulkEdits::Apply::Unsafe => e
  render json: { ok: false, error: e.message }, status: :unprocessable_entity
end

# POST /contacts/bulk_edits/:id/undo
def undo_bulk_edit
  bulk = current_user.account.bulk_edits.find(params[:id])
  bulk.undo!(actor: current_user)
  render json: { ok: true, reverted: bulk.results.count { _1["status"] == "ok" } }
end
```

```ruby
# config/routes.rb
resources :contacts, only: [:index, :update] do
  collection { post :bulk_update }
end
post "bulk_edits/:id/undo", to: "contacts#undo_bulk_edit", as: :undo_bulk_edit
```

> **Preview and write are the same code path.** `dry_run` is the only difference. A
> preview computed by separate code will eventually disagree with the write, and the
> user trusted the preview.

---

## Layer 5 — Multi-select in the view

Two pieces on top of the inline-editable table: a **checkbox column** (the selection),
and a **bulk panel** that only appears when something is selected (the instruction).

### The selection model, before any markup

Four rules that decide whether multi-select is safe, independent of how it looks:

1. **The selection is one list of ids, held in one place.** Not "whatever is checked in
   the DOM right now" — the DOM is re-rendered by sort, search, and Turbo, and a
   checkbox that scrolled out of the filtered set is still selected. Keep a `Set` of ids
   and re-derive the checkboxes from it, never the other way round. **A real incident
   (2026-08-05) hit 1,565 rows because deselected rows were still in the submitted set.**
2. **The count is the primary safety signal, so show it constantly.** "26 rows selected"
   belongs next to the Apply button, in the panel header, at all times — not in a
   confirm dialog that appears after the user has decided.
3. **"Select all" must say which "all" it means.** On a paginated table, the 50 rows on
   screen and the 2,431 rows matching the filter are wildly different blast radii. Offer
   both explicitly ("Select 50 on this page" / "Select all 2,431 matching") and never let
   a header checkbox silently mean the larger one.
4. **A selection must survive a re-render, or be cleared by it.** Pick one and be
   obvious. Silently keeping a stale selection across a filter change is how a user
   applies an edit to rows they can no longer see.

### The markup

```erb
<%# app/views/contacts/index.html.erb — inside the existing table %>
<td class="px-3 py-2 border-t">
  <input type="checkbox" value="<%= c.id %>"
         data-bulk-edit-target="rowCheckbox"
         data-action="change->bulk-edit#selectionChanged"
         data-values="<%= Contact::BULK_COLUMNS.index_with { |k| c[k] }.to_json %>">
</td>
```

```erb
<%# app/views/contacts/_bulk_panel.html.erb %>
<div data-controller="bulk-edit" data-bulk-edit-url-value="<%= bulk_update_contacts_path %>">
  <div data-bulk-edit-target="panel" hidden
       class="fixed bottom-0 inset-x-0 border-t bg-white shadow-lg p-4">

    <p class="text-sm font-medium mb-3">
      <span data-bulk-edit-target="count">0</span> rows selected
    </p>

    <% %w[company status owner_id notes].each do |col| %>
      <label class="flex items-center gap-2 mb-2 text-sm">
        <%# The "apply" checkbox IS the third state. Unticked = leave each row alone. %>
        <input type="checkbox" data-bulk-edit-target="applyToggle" data-column="<%= col %>"
               data-action="change->bulk-edit#toggleField">
        <span class="w-28 text-gray-600"><%= col.humanize %></span>
        <input type="text" data-bulk-edit-target="fieldInput" data-column="<%= col %>" disabled
               class="border rounded px-2 py-1 flex-1 disabled:bg-gray-50 disabled:text-gray-400">
        <button type="button" data-action="bulk-edit#clearField" data-column="<%= col %>"
                class="text-xs text-red-600 hover:underline">Clear on all</button>
      </label>
    <% end %>

    <p data-bulk-edit-target="blastRadius" class="text-sm my-3 text-amber-700"></p>

    <button data-action="bulk-edit#preview" class="px-3 py-1.5 border rounded">Preview</button>
    <button data-action="bulk-edit#apply" data-bulk-edit-target="applyButton" disabled
            class="px-3 py-1.5 bg-blue-600 text-white rounded disabled:opacity-40">Apply</button>
  </div>
</div>
```

Two rules the markup encodes:

- **The "apply" checkbox is the third state made visible.** Unticked means "leave each
  row's own value alone", and the input is disabled so it cannot be typed into by accident.
- **"Clear on all" is a separate, red, deliberate control.** It is the only way to write
  an empty value, and it never happens by leaving a box empty.

---

## Layer 6 — The Stimulus controller

`app/javascript/controllers/bulk_edit_controller.js`

```javascript
import { Controller } from "@hotwired/stimulus"

const MIXED = "— multiple values —"

export default class extends Controller {
  static targets = ["rowCheckbox", "panel", "count", "applyToggle", "fieldInput",
                    "blastRadius", "applyButton"]
  static values  = { url: String }

  connect() { this._cleared = new Set(); this._csrf = document.querySelector('meta[name="csrf-token"]')?.content }

  get selected() { return this.rowCheckboxTargets.filter(c => c.checked) }
  get ids()      { return this.selected.map(c => c.value) }

  selectionChanged() {
    const n = this.selected.length
    this.panelTarget.hidden = n === 0
    this.countTarget.textContent = n
    this.showMixedState()
    this.invalidatePreview()
  }

  // RULE 3 — show the SELECTION's state, not one row's state.
  // A blank here would be a false statement about the selection.
  showMixedState() {
    const rows = this.selected.map(c => JSON.parse(c.dataset.values))
    this.fieldInputTargets.forEach(input => {
      const col    = input.dataset.column
      const values = [...new Set(rows.map(r => (r[col] ?? "") + ""))]
      input.placeholder = values.length > 1 ? MIXED : (values[0] || "(empty on all)")
    })
  }

  toggleField(e) {
    const col   = e.target.dataset.column
    const input = this.fieldInputTargets.find(i => i.dataset.column === col)
    input.disabled = !e.target.checked
    if (!e.target.checked) input.value = ""
    this._cleared.delete(col)
    this.invalidatePreview()
  }

  clearField(e) {
    const col = e.target.dataset.column
    if (!confirm(`Clear ${col} on all ${this.ids.length} selected rows?`)) return
    this._cleared.add(col)
    this.invalidatePreview()
    this.preview()
  }

  payload(dryRun) {
    const set = {}
    this.applyToggleTargets.filter(t => t.checked).forEach(t => {
      const col = t.dataset.column
      if (this._cleared.has(col)) return
      set[col] = this.fieldInputTargets.find(i => i.dataset.column === col).value
    })
    // Columns in NEITHER `set` nor `clear` are left alone, per row. That is the point.
    return { selected_ids: this.ids, set, clear: [...this._cleared], dry_run: dryRun }
  }

  // RULE 4 — the user's cost is one click; the consequence can be hundreds of rows.
  async preview() {
    const data = await this.post(this.payload(true))
    if (!data.ok) return this.fail(data.error)
    const p = data.preview
    const parts = Object.entries(p.per_column)
      .map(([col, s]) => `${s.changing} rows change ${col}`)
    let msg = `This updates ${p.target_count} rows. ${parts.join(", ")}.`
    if (p.destructive_count > 0) msg += ` ⚠ ${p.destructive_count} rows will LOSE an existing value.`
    this.blastRadiusTarget.textContent = msg
    this.applyButtonTarget.disabled = false      // Apply unlocks only after a preview
  }

  async apply() {
    const data = await this.post(this.payload(false))
    if (!data.ok) return this.fail(data.error)
    this.blastRadiusTarget.innerHTML =
      `✓ Updated ${data.applied} rows. <a href="/bulk_edits/${data.bulk_edit_id}/undo"
        data-turbo-method="post" class="underline">Undo this batch</a>`
    setTimeout(() => location.reload(), 2500)
  }

  invalidatePreview() {
    this.applyButtonTarget.disabled = true
    this.blastRadiusTarget.textContent = ""
  }

  async post(body) {
    const res = await fetch(this.urlValue, {
      method: "POST",
      headers: { "Content-Type": "application/json", "Accept": "application/json",
                 "X-CSRF-Token": this._csrf },
      body: JSON.stringify(body)
    })
    return res.json()
  }

  fail(msg) { this.blastRadiusTarget.textContent = `⚠ ${msg}`; this.applyButtonTarget.disabled = true }
}
```

Note `invalidatePreview()`: **Apply is disabled until a preview has run for the current
payload.** Change anything and the preview goes stale, so the button locks again. The
preview is a gate, not a decoration.

---

## Detection: an audit log is not detection

The incident above was **fully recorded in the audit log as it happened**, and nobody
noticed for six weeks. Logging tells you what happened after someone asks. Alerting tells
you it happened.

Run this hourly and alert on a spike (PaperTrail-style `versions.object_changes` jsonb —
adapt to your audit table):

```sql
-- Real values replaced with blanks, by column, in the last hour.
SELECT v.item_type,
       c.key            AS column_name,
       count(*)         AS blanked_rows,
       count(DISTINCT v.whodunnit) AS users
FROM versions v
CROSS JOIN LATERAL jsonb_each(v.object_changes) AS c(key, val)
WHERE v.event = 'update'
  AND v.created_at > now() - interval '1 hour'
  AND coalesce(val->>0, '') <> ''      -- had a real value
  AND coalesce(val->>1, '') =  ''      -- now blank
GROUP BY 1, 2
HAVING count(*) >= 10
ORDER BY blanked_rows DESC;
```

The audit log **cannot** distinguish a deliberate clear from an accidental blanking —
which is itself part of the lesson. Once `clear` is an explicit payload field, it can:
join to `bulk_edits.cleared_columns` and everything left over is suspect.

---

## Gotchas (the hard-won stuff)

- **Never derive the field list from `previous_changes`, `changed`, `saved_changes`, or
  any old-vs-new comparison.** This is the entire bug. A state difference is not a user
  instruction. If you catch yourself writing `& SYNCABLE_FIELDS`, stop.
- **HTML forms submit every field, including untouched ones.** A whole-row form has no
  way to tell "the user left this empty" from "the user emptied this". Do not build a
  bulk editor on top of one.
- **`nil` and `""` are two spellings of nothing, and they will eventually be compared.**
  Normalize at the boundary (`normalize` above, or a `before_validation` that maps `""`
  to `nil`) and pick one canonical form. Comparing across the two returns the wrong answer.
- **Import paths and form paths produce different data shapes.** In the incident, the
  `NULL` precondition only arose from a bulk import using `insert_all!`, which skips
  callbacks, validations, and column defaults. Rows created through the form held `""`
  and were immune — which made the bug nearly impossible to reproduce by hand, because
  test data made through the UI never has the precondition. **Seed your test data through
  the import path too**, or normalize inside the import.
- **Watch for callbacks that cascade.** Blanking an assignee removed 35 QC checkmarks via
  an `after_save`. Multiply every callback by the batch size before you ship.
- **Apply the whitelist to every column in the list**, not just the first. `slice(*BULK_COLUMNS)`
  on `set` and `& BULK_COLUMNS` on `clear`, as above. A dynamic column name is the
  mass-assignment boundary here, exactly as in the single-cell pattern.
- **Wrap the write in one transaction** so a mid-batch failure doesn't leave half a
  reassignment. If partial success is genuinely wanted, record it per row in `results`
  and say so in the UI — don't leave it ambiguous.
- **"Edit the primary row and apply to the rest" is a weak model.** It cannot show mixed
  state, and it invites the user to treat one row's values as the selection's values.
  Prefer a dedicated panel that describes the *selection*.
- **Select-all means the filtered set, not the page.** If your table paginates, "select
  all" must be explicit about which it means — `2,431 rows match this filter` is a very
  different blast radius from the 50 on screen.
- **Never read the selection out of the DOM at submit time.** Sorting, searching, paging
  and any Turbo Frame swap rebuild the rows; a checkbox that is no longer rendered is
  not unchecked, it is *gone*. Hold the ids in a `Set` and render checkboxes from it.
  The 2026-08-05 incident was exactly this: the user deselected rows, the UI still
  treated the original set as selected, and one edit hit 1,565 rows.
- **Deselect must be as easy as select.** If clearing a selection takes more clicks than
  making one, users will apply an edit rather than start over. A visible "Clear
  selection" and an Escape binding cost nothing.
- **Shift-click range select is worth the 10 lines**, and it is also a blast-radius
  multiplier — a mis-aimed shift-click grabs 200 rows as easily as 5. It is another
  reason the count and the preview must be impossible to miss.
- **Cap the batch size** (a few hundred) and run bigger ones as a background job with a
  progress indicator. A 30-second synchronous request that times out mid-write is the
  worst possible outcome.

---

## Files this pattern touches

```
app/models/<model>.rb                                # BULK_COLUMNS whitelist
app/models/bulk_edit.rb                              # batch identity + undo!
app/services/bulk_edits/apply.rb                     # one path for preview and write + the guard
app/controllers/<plural>_controller.rb               # bulk_update (dry_run) + undo_bulk_edit
app/views/<plural>/index.html.erb                    # multi-select checkbox column + data-values
app/views/<plural>/_bulk_panel.html.erb              # selection panel (count, fields, preview)
app/javascript/controllers/bulk_edit_controller.js   # selection Set, mixed state, preview, apply
config/routes.rb                                     # collection post :bulk_update + undo
db/migrate/XXXX_create_bulk_edits.rb                 # the batch table
```

## How to adapt to your schema

1. Replace `Contact`/`contacts` with your model and set `BULK_COLUMNS` — start with the
   smallest useful set. Identity fields (name, email) almost never belong in it.
2. Replace `current_user.account.contacts` with your tenant scope. The service takes the
   scope, never a class, so it cannot reach another tenant's rows.
3. Swap the text inputs in the panel for the right control per column (a `<select>` for
   `status`, a searchable picker for `owner_id`). The `applyToggle` + `MIXED` placeholder
   contract stays the same whatever the control is.
4. If you have no audit log yet, `bulk_edits.results` already holds every before-value —
   that alone gives you undo and a post-hoc investigation trail.
5. **Do not skip the guard** to save time. It is nine lines, and it is the only mechanism
   here that catches a failure mode you did not think of.
