{"slug":"bulk-edit-and-mass-reassignment","meta":{"title":"Multi-Select, Bulk Edit \u0026 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."}]},"body":"# Multi-Select, Bulk Edit \u0026 Mass Reassignment\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it there.\n\n**Multi-select** a set of rows, set one field, hit Apply. This guide covers the two\nhalves of that: the **selection model** (how the user picks rows and knows what they\npicked) and the **bulk write** (the payload, the endpoint, and the safety mechanisms\nthat stop it destroying data nobody looked at).\n\n\u003e **When to use:** any table where a user needs to change the same thing on many rows —\n\u003e mass-reassigning an owner, marking a batch shipped/approved/archived, fixing an\n\u003e imported column on hundreds of rows.\n\u003e **When not to:** editing one cell on one record — that's the\n\u003e [inline-editable-table](/cookbook/inline-editable-table) pattern, and it is safe\n\u003e precisely because it does not do any of what's below.\n\n\u003e **Adding multi-select to an existing inline-editable table is the single most\n\u003e dangerous edit you can make to it.** It is not a checkbox column plus a loop. Read\n\u003e \"Why this is its own guide\" before you add the first `\u003cinput type=\"checkbox\"\u003e`.\n\n\u003e The examples use a `Contact` model with `name/company/status/owner_id/notes`.\n\u003e Swap in your own model and columns — see **How to adapt** at the bottom.\n\n---\n\n## Why this is its own guide\n\nMulti-select looks like a UI feature. It is not — it is a change to what a single\nuser action *means*. Bulk editing turns one instruction into many writes. Your error\nrate stays the same. Your blast radius does not.\n\nWorse, the user looks at **one** row while writing to **many**, so the feedback loop\nthat normally catches mistakes is switched off by the design itself. Assume errors\nwill be silent and found weeks later by a customer.\n\n**A real incident.** A production app extended the single-cell pattern two ways: it\nreplaced per-cell saves with a whole-row form that submits all 26 columns, and it added\nmulti-select so an edit on one row applied to the rest. The server then worked out\n*what to apply* by asking the database what had changed:\n\n```ruby\n# ❌ THE BUG. Never do this.\nchanged_fields = item.previous_changes.keys \u0026 SYNCABLE_FIELDS\n```\n\nThe edited row held `NULL` in a delivery-number column. HTML forms submit **every**\nfield, so the browser sent `\"\"`. Rails recorded `nil → \"\"` as a change. That empty\nvalue was then copied onto the other 25 rows and erased their real delivery numbers.\nThe same mechanism on a different column wiped 35 assignee names, and a downstream\ncallback then removed 35 QC checkmarks because those stages no longer had an assignee.\nAcross six weeks and 9 projects, roughly 1,400 real values were replaced with blanks.\nNobody noticed until a customer did.\n\n**The root cause, stated as a principle:**\n\n\u003e **The server inferred user intent from a state difference.** \"What changed in the\n\u003e database\" and \"what did the user ask for\" are different questions. A state difference\n\u003e also contains fields the browser sent empty, fields holding stale values because the\n\u003e page loaded ten minutes ago, and fields a callback wrote on its own.\n\nEverything below exists to make that inference impossible.\n\n---\n\n## The 80/20 in one breath\n\n1. **The request names the fields.** The payload carries an explicit list of columns to\n   set and columns to clear. The server never asks `changed`, `previous_changes`, or\n   diffs old against new.\n2. **Three states, not two.** Every column is *set on all*, *cleared on all*, or\n   **left alone per row**. \"Left alone\" = absent from both lists.\n3. **Mixed values show as \"multiple values\", never blank.** A blank is a false statement\n   about the selection.\n4. **Preview before write.** Same endpoint, `dry_run: true`, so the preview can't drift\n   from the write.\n5. **The batch is a record.** A `BulkEdit` row with an id, the user, the field list, the\n   target ids, and the before-value of every row it touched — so you can audit and undo it.\n6. **A guard refuses to blank a real value** unless the user explicitly asked to clear\n   that field. This is the one rule that catches the failure nobody predicted.\n\n---\n\n## Layer 1 — The payload contract\n\nThis is `params[:column]` from the single-cell pattern, grown up. It is **not** a record.\n\n```jsonc\n// POST /contacts/bulk_update\n{\n  \"selected_ids\": [12, 15, 19, 23],\n  \"set\":   { \"status\": \"delivered\", \"owner_id\": 4 },  // apply to every selected row\n  \"clear\": [\"notes\"],                                 // blank on every selected row\n  \"dry_run\": false\n  // Any column in NEITHER list keeps each row's own value. That is the third state.\n}\n```\n\nThree properties make this safe:\n\n- **`set` is the instruction.** Nothing is inferred from record state.\n- **`clear` is separate from `set`.** \"Make this empty\" is a deliberate, distinguishable\n  act — not an empty string that looks like an untouched form field.\n- **Absence means \"leave alone\".** A single-cell form has no way to express this, which\n  is exactly why extending a row form into a bulk form destroys data.\n\n\u003e ⚠️ **If your bulk form cannot express \"leave this field alone\", it will destroy data.**\n\u003e That is not a risk, it's a certainty on a long enough timeline.\n\n---\n\n## Layer 2 — Model, batch record \u0026 SQL\n\n`app/models/contact.rb` — reuse the **same** whitelist as single-cell editing. It is the\nsecurity boundary for both paths, applied to every column in the list.\n\n```ruby\nclass Contact \u003c ApplicationRecord\n  belongs_to :account\n  belongs_to :owner, class_name: \"User\", optional: true\n\n  EDITABLE_COLUMNS = %w[name company status owner_id notes].freeze\n\n  # Columns a bulk edit may touch. Usually a SUBSET of EDITABLE_COLUMNS —\n  # per-row identity fields (name, email) rarely make sense to mass-assign.\n  BULK_COLUMNS = %w[company status owner_id notes].freeze\nend\n```\n\n`app/models/bulk_edit.rb` — the batch's identity. Without this you cannot undo a batch,\naudit it as a unit, or alert on it.\n\n```ruby\nclass BulkEdit \u003c ApplicationRecord\n  belongs_to :user\n  belongs_to :account\n\n  # target_type    : \"Contact\"\n  # target_ids     : jsonb array of ids\n  # set_values     : jsonb hash  { \"status\" =\u003e \"delivered\" }\n  # cleared_columns: jsonb array [ \"notes\" ]\n  # results        : jsonb array [ { \"id\" =\u003e 12, \"status\" =\u003e \"ok\", \"before\" =\u003e {...} } ]\n\n  def columns_touched = set_values.keys + cleared_columns\n\n  def undo!(actor:)\n    transaction do\n      results.select { _1[\"status\"] == \"ok\" }.each do |row|\n        account.contacts.where(id: row[\"id\"]).update_all(row[\"before\"].symbolize_keys)\n      end\n      update!(undone_at: Time.current, undone_by_id: actor.id)\n    end\n  end\nend\n```\n\n```ruby\n# db/migrate/XXXX_create_bulk_edits.rb\nclass CreateBulkEdits \u003c ActiveRecord::Migration[7.1]\n  def change\n    create_table :bulk_edits do |t|\n      t.references :user,    null: false, foreign_key: true\n      t.references :account, null: false, foreign_key: true\n      t.string  :target_type,    null: false\n      t.jsonb   :target_ids,     null: false, default: []\n      t.jsonb   :set_values,     null: false, default: {}\n      t.jsonb   :cleared_columns, null: false, default: []\n      t.jsonb   :results,        null: false, default: []\n      t.integer :undone_by_id\n      t.datetime :undone_at\n      t.timestamps\n    end\n    add_index :bulk_edits, [:account_id, :created_at]\n  end\nend\n```\n\nIf you keep an audit/version log, **stamp `bulk_edit_id` on every record it writes.** In\nthe incident above, investigators had to reconstruct the batch by finding audit rows\nwritten within 0.2 seconds of each other, because nothing recorded that a bulk edit had\nhappened at all.\n\n---\n\n## Layer 3 — The apply service (and the guard)\n\n`app/services/bulk_edits/apply.rb` — one class, used by both the preview and the write.\n\n```ruby\nmodule BulkEdits\n  class Apply\n    class Unsafe \u003c StandardError; end\n\n    Preview = Struct.new(:target_count, :per_column, :destructive_count, keyword_init: true)\n\n    def initialize(scope:, ids:, set: {}, clear: [], user:, dry_run: false)\n      @scope   = scope                                   # already tenant-scoped\n      @ids     = Array(ids).map(\u0026:to_i).uniq\n      @set     = set.to_h.stringify_keys.slice(*Contact::BULK_COLUMNS)\n      @clear   = Array(clear).map(\u0026:to_s) \u0026 Contact::BULK_COLUMNS\n      @user    = user\n      @dry_run = dry_run\n    end\n\n    def call\n      raise Unsafe, \"No rows selected\"  if @ids.empty?\n      raise Unsafe, \"No fields to apply\" if @set.empty? \u0026\u0026 @clear.empty?\n      raise Unsafe, \"A field cannot be both set and cleared\" if (@set.keys \u0026 @clear).any?\n\n      # ── RULE 6: the guard. ────────────────────────────────────────────────\n      # A bulk apply never replaces a real value with an empty one unless the\n      # user explicitly asked to clear that field. This fires no matter which\n      # column, user, or form is involved — including the ones nobody predicted.\n      blank_sets = @set.select { |_, v| blank_value?(v) }.keys\n      raise Unsafe, \"Refusing to blank #{blank_sets.join(', ')} — use `clear` to do that on purpose\" if blank_sets.any?\n\n      records = @scope.where(id: @ids).to_a\n      return preview(records) if @dry_run\n\n      write!(records)\n    end\n\n    private\n\n    def blank_value?(v) = v.nil? || (v.is_a?(String) \u0026\u0026 v.strip.empty?)\n\n    def assignments = @set.merge(@clear.index_with(nil))\n\n    def preview(records)\n      per_column = assignments.keys.index_with do |col|\n        target = assignments[col]\n        {\n          changing: records.count { |r| normalize(r[col]) != normalize(target) },\n          losing:   records.count { |r| !blank_value?(r[col]) \u0026\u0026 blank_value?(target) }\n        }\n      end\n      Preview.new(\n        target_count:     records.size,\n        per_column:       per_column,\n        destructive_count: per_column.values.sum { _1[:losing] }\n      )\n    end\n\n    def write!(records)\n      bulk = nil\n      ActiveRecord::Base.transaction do\n        bulk = BulkEdit.create!(\n          user: @user, account: @user.account, target_type: @scope.klass.name,\n          target_ids: records.map(\u0026:id), set_values: @set, cleared_columns: @clear\n        )\n        results = records.map do |record|\n          before = assignments.keys.index_with { |col| record[col] }\n          record.assign_attributes(assignments)\n          if record.save\n            { id: record.id, status: \"ok\", before: before }\n          else\n            { id: record.id, status: \"failed\", errors: record.errors.full_messages }\n          end\n        end\n        bulk.update!(results: results)\n      end\n      bulk\n    end\n\n    # One canonical representation of \"no value\" — see Gotchas.\n    def normalize(v) = blank_value?(v) ? nil : v\n  end\nend\n```\n\n**Read the guard twice.** Rules 1–5 require the author to have correctly predicted the\nfailure mode. The guard does not. It is the backstop that catches the class, not the\ninstance — and it is what would have stopped the incident in one line.\n\n---\n\n## Layer 4 — Controller\n\n`app/controllers/contacts_controller.rb` — one action, two modes.\n\n```ruby\n# POST /contacts/bulk_update\ndef bulk_update\n  service = BulkEdits::Apply.new(\n    scope:   current_user.account.contacts,          # tenant scope, always\n    ids:     params[:selected_ids],\n    set:     params.fetch(:set, {}).permit(*Contact::BULK_COLUMNS).to_h,\n    clear:   params[:clear],\n    user:    current_user,\n    dry_run: ActiveModel::Type::Boolean.new.cast(params[:dry_run])\n  ).call\n\n  if service.is_a?(BulkEdits::Apply::Preview)\n    render json: { ok: true, preview: service.to_h }\n  else\n    render json: { ok: true, bulk_edit_id: service.id,\n                   applied: service.results.count { _1[\"status\"] == \"ok\" },\n                   failed:  service.results.count { _1[\"status\"] == \"failed\" } }\n  end\nrescue BulkEdits::Apply::Unsafe =\u003e e\n  render json: { ok: false, error: e.message }, status: :unprocessable_entity\nend\n\n# POST /contacts/bulk_edits/:id/undo\ndef undo_bulk_edit\n  bulk = current_user.account.bulk_edits.find(params[:id])\n  bulk.undo!(actor: current_user)\n  render json: { ok: true, reverted: bulk.results.count { _1[\"status\"] == \"ok\" } }\nend\n```\n\n```ruby\n# config/routes.rb\nresources :contacts, only: [:index, :update] do\n  collection { post :bulk_update }\nend\npost \"bulk_edits/:id/undo\", to: \"contacts#undo_bulk_edit\", as: :undo_bulk_edit\n```\n\n\u003e **Preview and write are the same code path.** `dry_run` is the only difference. A\n\u003e preview computed by separate code will eventually disagree with the write, and the\n\u003e user trusted the preview.\n\n---\n\n## Layer 5 — Multi-select in the view\n\nTwo pieces on top of the inline-editable table: a **checkbox column** (the selection),\nand a **bulk panel** that only appears when something is selected (the instruction).\n\n### The selection model, before any markup\n\nFour rules that decide whether multi-select is safe, independent of how it looks:\n\n1. **The selection is one list of ids, held in one place.** Not \"whatever is checked in\n   the DOM right now\" — the DOM is re-rendered by sort, search, and Turbo, and a\n   checkbox that scrolled out of the filtered set is still selected. Keep a `Set` of ids\n   and re-derive the checkboxes from it, never the other way round. **A real incident\n   (2026-08-05) hit 1,565 rows because deselected rows were still in the submitted set.**\n2. **The count is the primary safety signal, so show it constantly.** \"26 rows selected\"\n   belongs next to the Apply button, in the panel header, at all times — not in a\n   confirm dialog that appears after the user has decided.\n3. **\"Select all\" must say which \"all\" it means.** On a paginated table, the 50 rows on\n   screen and the 2,431 rows matching the filter are wildly different blast radii. Offer\n   both explicitly (\"Select 50 on this page\" / \"Select all 2,431 matching\") and never let\n   a header checkbox silently mean the larger one.\n4. **A selection must survive a re-render, or be cleared by it.** Pick one and be\n   obvious. Silently keeping a stale selection across a filter change is how a user\n   applies an edit to rows they can no longer see.\n\n### The markup\n\n```erb\n\u003c%# app/views/contacts/index.html.erb — inside the existing table %\u003e\n\u003ctd class=\"px-3 py-2 border-t\"\u003e\n  \u003cinput type=\"checkbox\" value=\"\u003c%= c.id %\u003e\"\n         data-bulk-edit-target=\"rowCheckbox\"\n         data-action=\"change-\u003ebulk-edit#selectionChanged\"\n         data-values=\"\u003c%= Contact::BULK_COLUMNS.index_with { |k| c[k] }.to_json %\u003e\"\u003e\n\u003c/td\u003e\n```\n\n```erb\n\u003c%# app/views/contacts/_bulk_panel.html.erb %\u003e\n\u003cdiv data-controller=\"bulk-edit\" data-bulk-edit-url-value=\"\u003c%= bulk_update_contacts_path %\u003e\"\u003e\n  \u003cdiv data-bulk-edit-target=\"panel\" hidden\n       class=\"fixed bottom-0 inset-x-0 border-t bg-white shadow-lg p-4\"\u003e\n\n    \u003cp class=\"text-sm font-medium mb-3\"\u003e\n      \u003cspan data-bulk-edit-target=\"count\"\u003e0\u003c/span\u003e rows selected\n    \u003c/p\u003e\n\n    \u003c% %w[company status owner_id notes].each do |col| %\u003e\n      \u003clabel class=\"flex items-center gap-2 mb-2 text-sm\"\u003e\n        \u003c%# The \"apply\" checkbox IS the third state. Unticked = leave each row alone. %\u003e\n        \u003cinput type=\"checkbox\" data-bulk-edit-target=\"applyToggle\" data-column=\"\u003c%= col %\u003e\"\n               data-action=\"change-\u003ebulk-edit#toggleField\"\u003e\n        \u003cspan class=\"w-28 text-gray-600\"\u003e\u003c%= col.humanize %\u003e\u003c/span\u003e\n        \u003cinput type=\"text\" data-bulk-edit-target=\"fieldInput\" data-column=\"\u003c%= col %\u003e\" disabled\n               class=\"border rounded px-2 py-1 flex-1 disabled:bg-gray-50 disabled:text-gray-400\"\u003e\n        \u003cbutton type=\"button\" data-action=\"bulk-edit#clearField\" data-column=\"\u003c%= col %\u003e\"\n                class=\"text-xs text-red-600 hover:underline\"\u003eClear on all\u003c/button\u003e\n      \u003c/label\u003e\n    \u003c% end %\u003e\n\n    \u003cp data-bulk-edit-target=\"blastRadius\" class=\"text-sm my-3 text-amber-700\"\u003e\u003c/p\u003e\n\n    \u003cbutton data-action=\"bulk-edit#preview\" class=\"px-3 py-1.5 border rounded\"\u003ePreview\u003c/button\u003e\n    \u003cbutton data-action=\"bulk-edit#apply\" data-bulk-edit-target=\"applyButton\" disabled\n            class=\"px-3 py-1.5 bg-blue-600 text-white rounded disabled:opacity-40\"\u003eApply\u003c/button\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\nTwo rules the markup encodes:\n\n- **The \"apply\" checkbox is the third state made visible.** Unticked means \"leave each\n  row's own value alone\", and the input is disabled so it cannot be typed into by accident.\n- **\"Clear on all\" is a separate, red, deliberate control.** It is the only way to write\n  an empty value, and it never happens by leaving a box empty.\n\n---\n\n## Layer 6 — The Stimulus controller\n\n`app/javascript/controllers/bulk_edit_controller.js`\n\n```javascript\nimport { Controller } from \"@hotwired/stimulus\"\n\nconst MIXED = \"— multiple values —\"\n\nexport default class extends Controller {\n  static targets = [\"rowCheckbox\", \"panel\", \"count\", \"applyToggle\", \"fieldInput\",\n                    \"blastRadius\", \"applyButton\"]\n  static values  = { url: String }\n\n  connect() { this._cleared = new Set(); this._csrf = document.querySelector('meta[name=\"csrf-token\"]')?.content }\n\n  get selected() { return this.rowCheckboxTargets.filter(c =\u003e c.checked) }\n  get ids()      { return this.selected.map(c =\u003e c.value) }\n\n  selectionChanged() {\n    const n = this.selected.length\n    this.panelTarget.hidden = n === 0\n    this.countTarget.textContent = n\n    this.showMixedState()\n    this.invalidatePreview()\n  }\n\n  // RULE 3 — show the SELECTION's state, not one row's state.\n  // A blank here would be a false statement about the selection.\n  showMixedState() {\n    const rows = this.selected.map(c =\u003e JSON.parse(c.dataset.values))\n    this.fieldInputTargets.forEach(input =\u003e {\n      const col    = input.dataset.column\n      const values = [...new Set(rows.map(r =\u003e (r[col] ?? \"\") + \"\"))]\n      input.placeholder = values.length \u003e 1 ? MIXED : (values[0] || \"(empty on all)\")\n    })\n  }\n\n  toggleField(e) {\n    const col   = e.target.dataset.column\n    const input = this.fieldInputTargets.find(i =\u003e i.dataset.column === col)\n    input.disabled = !e.target.checked\n    if (!e.target.checked) input.value = \"\"\n    this._cleared.delete(col)\n    this.invalidatePreview()\n  }\n\n  clearField(e) {\n    const col = e.target.dataset.column\n    if (!confirm(`Clear ${col} on all ${this.ids.length} selected rows?`)) return\n    this._cleared.add(col)\n    this.invalidatePreview()\n    this.preview()\n  }\n\n  payload(dryRun) {\n    const set = {}\n    this.applyToggleTargets.filter(t =\u003e t.checked).forEach(t =\u003e {\n      const col = t.dataset.column\n      if (this._cleared.has(col)) return\n      set[col] = this.fieldInputTargets.find(i =\u003e i.dataset.column === col).value\n    })\n    // Columns in NEITHER `set` nor `clear` are left alone, per row. That is the point.\n    return { selected_ids: this.ids, set, clear: [...this._cleared], dry_run: dryRun }\n  }\n\n  // RULE 4 — the user's cost is one click; the consequence can be hundreds of rows.\n  async preview() {\n    const data = await this.post(this.payload(true))\n    if (!data.ok) return this.fail(data.error)\n    const p = data.preview\n    const parts = Object.entries(p.per_column)\n      .map(([col, s]) =\u003e `${s.changing} rows change ${col}`)\n    let msg = `This updates ${p.target_count} rows. ${parts.join(\", \")}.`\n    if (p.destructive_count \u003e 0) msg += ` ⚠ ${p.destructive_count} rows will LOSE an existing value.`\n    this.blastRadiusTarget.textContent = msg\n    this.applyButtonTarget.disabled = false      // Apply unlocks only after a preview\n  }\n\n  async apply() {\n    const data = await this.post(this.payload(false))\n    if (!data.ok) return this.fail(data.error)\n    this.blastRadiusTarget.innerHTML =\n      `✓ Updated ${data.applied} rows. \u003ca href=\"/bulk_edits/${data.bulk_edit_id}/undo\"\n        data-turbo-method=\"post\" class=\"underline\"\u003eUndo this batch\u003c/a\u003e`\n    setTimeout(() =\u003e location.reload(), 2500)\n  }\n\n  invalidatePreview() {\n    this.applyButtonTarget.disabled = true\n    this.blastRadiusTarget.textContent = \"\"\n  }\n\n  async post(body) {\n    const res = await fetch(this.urlValue, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\", \"Accept\": \"application/json\",\n                 \"X-CSRF-Token\": this._csrf },\n      body: JSON.stringify(body)\n    })\n    return res.json()\n  }\n\n  fail(msg) { this.blastRadiusTarget.textContent = `⚠ ${msg}`; this.applyButtonTarget.disabled = true }\n}\n```\n\nNote `invalidatePreview()`: **Apply is disabled until a preview has run for the current\npayload.** Change anything and the preview goes stale, so the button locks again. The\npreview is a gate, not a decoration.\n\n---\n\n## Detection: an audit log is not detection\n\nThe incident above was **fully recorded in the audit log as it happened**, and nobody\nnoticed for six weeks. Logging tells you what happened after someone asks. Alerting tells\nyou it happened.\n\nRun this hourly and alert on a spike (PaperTrail-style `versions.object_changes` jsonb —\nadapt to your audit table):\n\n```sql\n-- Real values replaced with blanks, by column, in the last hour.\nSELECT v.item_type,\n       c.key            AS column_name,\n       count(*)         AS blanked_rows,\n       count(DISTINCT v.whodunnit) AS users\nFROM versions v\nCROSS JOIN LATERAL jsonb_each(v.object_changes) AS c(key, val)\nWHERE v.event = 'update'\n  AND v.created_at \u003e now() - interval '1 hour'\n  AND coalesce(val-\u003e\u003e0, '') \u003c\u003e ''      -- had a real value\n  AND coalesce(val-\u003e\u003e1, '') =  ''      -- now blank\nGROUP BY 1, 2\nHAVING count(*) \u003e= 10\nORDER BY blanked_rows DESC;\n```\n\nThe audit log **cannot** distinguish a deliberate clear from an accidental blanking —\nwhich is itself part of the lesson. Once `clear` is an explicit payload field, it can:\njoin to `bulk_edits.cleared_columns` and everything left over is suspect.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Never derive the field list from `previous_changes`, `changed`, `saved_changes`, or\n  any old-vs-new comparison.** This is the entire bug. A state difference is not a user\n  instruction. If you catch yourself writing `\u0026 SYNCABLE_FIELDS`, stop.\n- **HTML forms submit every field, including untouched ones.** A whole-row form has no\n  way to tell \"the user left this empty\" from \"the user emptied this\". Do not build a\n  bulk editor on top of one.\n- **`nil` and `\"\"` are two spellings of nothing, and they will eventually be compared.**\n  Normalize at the boundary (`normalize` above, or a `before_validation` that maps `\"\"`\n  to `nil`) and pick one canonical form. Comparing across the two returns the wrong answer.\n- **Import paths and form paths produce different data shapes.** In the incident, the\n  `NULL` precondition only arose from a bulk import using `insert_all!`, which skips\n  callbacks, validations, and column defaults. Rows created through the form held `\"\"`\n  and were immune — which made the bug nearly impossible to reproduce by hand, because\n  test data made through the UI never has the precondition. **Seed your test data through\n  the import path too**, or normalize inside the import.\n- **Watch for callbacks that cascade.** Blanking an assignee removed 35 QC checkmarks via\n  an `after_save`. Multiply every callback by the batch size before you ship.\n- **Apply the whitelist to every column in the list**, not just the first. `slice(*BULK_COLUMNS)`\n  on `set` and `\u0026 BULK_COLUMNS` on `clear`, as above. A dynamic column name is the\n  mass-assignment boundary here, exactly as in the single-cell pattern.\n- **Wrap the write in one transaction** so a mid-batch failure doesn't leave half a\n  reassignment. If partial success is genuinely wanted, record it per row in `results`\n  and say so in the UI — don't leave it ambiguous.\n- **\"Edit the primary row and apply to the rest\" is a weak model.** It cannot show mixed\n  state, and it invites the user to treat one row's values as the selection's values.\n  Prefer a dedicated panel that describes the *selection*.\n- **Select-all means the filtered set, not the page.** If your table paginates, \"select\n  all\" must be explicit about which it means — `2,431 rows match this filter` is a very\n  different blast radius from the 50 on screen.\n- **Never read the selection out of the DOM at submit time.** Sorting, searching, paging\n  and any Turbo Frame swap rebuild the rows; a checkbox that is no longer rendered is\n  not unchecked, it is *gone*. Hold the ids in a `Set` and render checkboxes from it.\n  The 2026-08-05 incident was exactly this: the user deselected rows, the UI still\n  treated the original set as selected, and one edit hit 1,565 rows.\n- **Deselect must be as easy as select.** If clearing a selection takes more clicks than\n  making one, users will apply an edit rather than start over. A visible \"Clear\n  selection\" and an Escape binding cost nothing.\n- **Shift-click range select is worth the 10 lines**, and it is also a blast-radius\n  multiplier — a mis-aimed shift-click grabs 200 rows as easily as 5. It is another\n  reason the count and the preview must be impossible to miss.\n- **Cap the batch size** (a few hundred) and run bigger ones as a background job with a\n  progress indicator. A 30-second synchronous request that times out mid-write is the\n  worst possible outcome.\n\n---\n\n## Files this pattern touches\n\n```\napp/models/\u003cmodel\u003e.rb                                # BULK_COLUMNS whitelist\napp/models/bulk_edit.rb                              # batch identity + undo!\napp/services/bulk_edits/apply.rb                     # one path for preview and write + the guard\napp/controllers/\u003cplural\u003e_controller.rb               # bulk_update (dry_run) + undo_bulk_edit\napp/views/\u003cplural\u003e/index.html.erb                    # multi-select checkbox column + data-values\napp/views/\u003cplural\u003e/_bulk_panel.html.erb              # selection panel (count, fields, preview)\napp/javascript/controllers/bulk_edit_controller.js   # selection Set, mixed state, preview, apply\nconfig/routes.rb                                     # collection post :bulk_update + undo\ndb/migrate/XXXX_create_bulk_edits.rb                 # the batch table\n```\n\n## How to adapt to your schema\n\n1. Replace `Contact`/`contacts` with your model and set `BULK_COLUMNS` — start with the\n   smallest useful set. Identity fields (name, email) almost never belong in it.\n2. Replace `current_user.account.contacts` with your tenant scope. The service takes the\n   scope, never a class, so it cannot reach another tenant's rows.\n3. Swap the text inputs in the panel for the right control per column (a `\u003cselect\u003e` for\n   `status`, a searchable picker for `owner_id`). The `applyToggle` + `MIXED` placeholder\n   contract stays the same whatever the control is.\n4. If you have no audit log yet, `bulk_edits.results` already holds every before-value —\n   that alone gives you undo and a post-hoc investigation trail.\n5. **Do not skip the guard** to save time. It is nine lines, and it is the only mechanism\n   here that catches a failure mode you did not think of.\n"}