`#reify` returns the downcased value for `encrypts ... ignore_case: true` attributes

Author: cbortzCreated Sep 7, 2026Updated Sep 7, 2026
  • This is not a usage question, this is a bug report
  • This bug can be reproduced with the script I provide below
  • This bug can be reproduced in the latest release of the paper_trail gem
ruby
# frozen_string_literal: true

# Use this template to report PaperTrail bugs.
# Please include only the minimum code necessary to reproduce your issue.
require "bundler/inline"

# STEP ONE: What versions are you using?
gemfile(true) do
  ruby "4.0.3"
  source "https://rubygems.org"
  gem "activerecord", "8.1.3.1"
  gem "minitest", "6.0.6"
  gem "paper_trail", "17.0.0", require: false
  gem "sqlite3", "2.9.6"
end

require "active_record"
require "minitest/autorun"
require "logger"

# Please use sqlite for your bug reports, if possible.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = nil

# `ignore_case: true` requires deterministic encryption, hence the keys.
#
# `support_unencrypted_data: true` is the trigger for this bug. With `false`,
# the reader override in ActiveRecord::Encryption::EncryptableRecord takes its
# other branch and reify happens to return the right value.
ActiveRecord::Encryption.configure(
  primary_key: "an-arbitrary-primary-key",
  deterministic_key: "an-arbitrary-deterministic-key",
  key_derivation_salt: "an-arbitrary-key-derivation-salt",
  support_unencrypted_data: true
)

ActiveRecord::Schema.define do
  # STEP TWO: Define your tables here.
  create_table :users, force: true do |t|
    t.text :name, null: false
    t.text :original_name # required by `encrypts ignore_case: true`
    t.text :username, null: false
  end

  create_table :versions do |t|
    t.string :item_type, null: false
    t.integer :item_id, null: false
    t.string :event, null: false
    t.string :whodunnit
    t.text :object, limit: 1_073_741_823
    t.text :object_changes, limit: 1_073_741_823
    t.datetime :created_at
  end
  add_index :versions, %i[item_type item_id]
end
ActiveRecord::Base.logger = Logger.new($stdout)
require "paper_trail"

# STEP FOUR: Define your AR models here.
class User < ActiveRecord::Base
  has_paper_trail
  encrypts :name, deterministic: true, ignore_case: true
end

# STEP FIVE: Please write a test that demonstrates your issue.
class BugTest < ActiveSupport::TestCase
  def test_reify_preserves_the_original_case
    user = User.create!(name: "Jane Doe", username: "jdoe123")

    # The reload matters. Rails downcases when it serializes, not when you
    # assign, so an unreloaded record still holds "Jane Doe" in the `name`
    # attribute itself and the version would record the original case there too.
    user = User.find(user.id)
    assert_equal "Jane Doe", user.name
    assert_equal "jane doe", user[:name]
    assert_equal "jdoe123", user.username
    assert_equal "jdoe123", user[:username]

    user.update!(name: "Sue Storm", username: "sstorm456")

    # Must be an update version: `PaperTrail::Events::Create#data` never sets
    # `object`, and `reify` returns nil when `object` is nil.
    version = user.versions.last
    assert_equal "update", version.event

    # control, behaves as expected
    assert_equal "jdoe123", version.reify.username

    # The original case IS preserved in the version, so nothing is lost...
    assert_equal "Jane Doe", version.reify.original_name

    # ...but the reader serves the downcased value. This is the bug.
    assert_equal "Jane Doe", version.reify.name
  end
end

# STEP SIX: Run this script using `ruby my_bug_report.rb`

Output:

  1) Failure:
BugTest#test_reify_preserves_the_original_case [my_bug_report.rb:93]:
Expected: "Jane Doe"
  Actual: "jane doe"

1 runs, 8 assertions, 1 failures, 0 errors, 0 skips

What

Note this only reproduces when config.active_record.encryption.support_unencrypted_data is true. With it set to false, #reify returns the original case and there's no bug.

When using Active Record Encryption, you can use :ignore_case and :deterministic to allow for case-insensitive filtering on a table. From the Rails documentation:

With the :ignore_case option, you need to add a new column named original_<column_name> to store the encrypted content with the case unchanged. When reading the name attribute, Rails will serve the version with the original case. When querying name, it will ignore case.

How it works, basically, is name is lowercased and encrypted. When the record is read and #name is invoked, the decrypted #original_name is returned back instead.

When using #reify on the versions, #name will return back the decrypted and lowercased value, instead of the value present in #original_name. That's the bug, i.e. #reify doesn't apply the same retrieval mechanism. Concretely: a record saved with name of "Jane Doe" reads back as "Jane Doe" from User.find(id).name, but "jane doe" from version.reify.name.

Proposed fix

Patch Reifier#reify_attributes to introspect on encrypted fields and conditionally reify from an "original" column when necessary.


I'll open a PR for this later this week.

One note on scope. A broader fix would be to leave encrypted attributes as ciphertext in #reify and let Rails decrypt them on read. That also fixes #encrypted_attribute? and #ciphertext_for, which misreport on any reified record with encrypted attributes.

I'm not proposing it here because of scope, not because it can't work. Writing those attributes from the database side leaves them not dirty, so reify would also have to mark them changed itself, or version.reify.save! becomes a no-op. That combination works, I've tried it, but it changes how every encrypted attribute is reconstructed. Let me know if you'd rather go that way.

Source: paper-trail-gem/paper_trail