{"record":{"id":"7be9b1f0a12508fd","repo":"rubysherpas/paranoia","slug":"self-class-is-marked-as-readonly","errorCode":null,"errorMessage":"#{self.class} is marked as readonly","messagePattern":"#(.+?) is marked as readonly","errorType":"exception","errorClass":"ActiveRecord::ReadOnlyRecord","httpStatus":null,"severity":"error","filePath":"lib/paranoia.rb","lineNumber":110,"sourceCode":"    paranoia_destroy ||\n      raise(ActiveRecord::RecordNotDestroyed.new(\"Failed to destroy the record\", self))\n  end\n\n  def trigger_transactional_callbacks?\n    super || @_trigger_destroy_callback && paranoia_destroyed? ||\n      @_trigger_restore_callback && !paranoia_destroyed?\n  end\n\n  def transaction_include_any_action?(actions)\n    super || actions.any? do |action|\n      if action == :restore\n        paranoia_after_restore_commit && @_trigger_restore_callback\n      end\n    end\n  end\n\n  def paranoia_delete\n    raise ActiveRecord::ReadOnlyRecord, \"#{self.class} is marked as readonly\" if readonly?\n    if persisted?\n      # if a transaction exists, add the record so that after_commit\n      # callbacks can be run\n      add_to_transaction\n      update_columns(paranoia_destroy_attributes)\n    elsif !frozen?\n      assign_attributes(paranoia_destroy_attributes)\n    end\n    self\n  end\n  alias_method :delete, :paranoia_delete\n\n  def restore!(opts = {})\n    self.class.transaction do\n      run_callbacks(:restore) do\n        recovery_window_range = get_recovery_window_range(opts)\n        # Fixes a bug where the build would error because attributes were frozen.\n        # This only happened on Rails versions earlier than 4.1.","sourceCodeStart":92,"sourceCodeEnd":128,"githubUrl":"https://github.com/rubysherpas/paranoia/blob/a950fe498191ba01fc45d716e7157d1903ef30b7/lib/paranoia.rb#L92-L128","documentation":"Paranoia redefines destroy as a soft delete: paranoia_delete writes the deleted_at column via update_columns(paranoia_destroy_attributes). Because that is a real UPDATE, the method first checks readonly? and raises ActiveRecord::ReadOnlyRecord with \"#{self.class} is marked as readonly\" — the same failure Rails itself raises when saving readonly records — so the write never starts. A record is readonly when it was marked with readonly! or was materialized from a multi-table query (eager_load, or includes plus references) whose rows ActiveRecord protects from being saved.","triggerScenarios":"Calling record.destroy, record.paranoia_destroy, record.paranoia_destroy!, or record.paranoia_delete! (all funnel into paranoia_delete at lib/paranoia.rb:110) on a record where readonly? is true: a record loaded through Post.eager_load(:author).first or Post.includes(:author).references(:author).where(...).first (LEFT OUTER JOIN rows are readonly), or a record explicitly marked with record.readonly! / a .readonly scope. The readonly check runs at the top of paranoia_delete, before the persisted?/frozen? branches, so it fires regardless of record state.","commonSituations":"Adding acts_as_paranoid to a model whose destroy paths receive objects loaded by admin/reporting screens that eager_load or includes+references associations for display; deliberately guarding records with readonly! (audit code, demo seeds) and later soft-deleting them; test fixtures or factories marked readonly; Rails version upgrades changing which query shapes mark records readonly.","solutions":["Re-fetch a writable instance from the model's own table at the delete site: record = record.class.find(record.id); record.destroy — or call record.reload, which refreshes attributes from the base table and clears the readonly flag.","If the object came from includes(:assoc).references(:assoc) or eager_load, load it without the LEFT JOIN for the delete path (plain Model.find / Model.where, or preload instead of references) so the instance is writable.","If the readonly flag was set intentionally but this record must be soft-deleted, clear it first: record.readonly!(false) (Rails 5.2+) then record.destroy.","Audit the code path for explicit readonly! / .readonly calls and remove them where soft delete is the intended behavior."],"exampleFix":"# before — record came from a LEFT OUTER JOIN query, readonly? == true\npost = Post.includes(:author).references(:author).where(authors: { name: 'Jo' }).first\npost.destroy  # ActiveRecord::ReadOnlyRecord: Post is marked as readonly\n\n# after — reload from posts table first, then soft-delete\npost = Post.includes(:author).references(:author).where(authors: { name: 'Jo' }).first\npost.reload\npost.destroy  # paranoia_delete runs, sets deleted_at","handlingStrategy":"validation","validationCode":"record = Post.eager_load(:author).find(params[:id])\nrecord = record.class.find(record.id) if record.readonly?  # writable copy before any destroy\nrecord.destroy","typeGuard":"def soft_deletable?(record)\n  record.is_a?(ActiveRecord::Base) && record.persisted? && !record.readonly? && !record.frozen?\nend","tryCatchPattern":"begin\n  record.destroy\nrescue ActiveRecord::ReadOnlyRecord\n  raise if record.frozen? || !record.persisted?\n  record = record.class.find(record.id)  # fresh instance from the base table\n  record.destroy                          # retry once, then let errors propagate\nend","preventionTips":["Never destroy records loaded by eager_load or includes.references — re-fetch by primary key (Model.find(id)) at the write site.","Check record.readonly? in service objects or before_actions guarding destroy endpoints on acts_as_paranoid models.","Prefer preload over includes+references when you do not need WHERE conditions on the joined table, so display queries return writable records.","In specs, assert refute record.readonly? before exercising soft-delete paths."],"tags":["paranoia","soft-delete","activerecord","rails","readonly-record","destroy"],"backgroundTag":"activerecord-readonly-record","analyzedSha":"a950fe498191ba01fc45d716e7157d1903ef30b7","analyzedAt":"2026-08-23T15:28:42.766Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}