concerns_on_rails 1.32.1 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +581 -0
  3. data/README.md +220 -73
  4. data/lib/concerns_on_rails/controllers/authorizable.rb +62 -7
  5. data/lib/concerns_on_rails/controllers/cacheable.rb +9 -1
  6. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +159 -11
  7. data/lib/concerns_on_rails/controllers/error_handleable.rb +61 -24
  8. data/lib/concerns_on_rails/controllers/filterable.rb +254 -10
  9. data/lib/concerns_on_rails/controllers/includable.rb +41 -6
  10. data/lib/concerns_on_rails/controllers/localizable.rb +28 -6
  11. data/lib/concerns_on_rails/controllers/paginatable.rb +63 -11
  12. data/lib/concerns_on_rails/controllers/respondable.rb +32 -12
  13. data/lib/concerns_on_rails/controllers/secure_headable.rb +80 -11
  14. data/lib/concerns_on_rails/controllers/sortable.rb +19 -2
  15. data/lib/concerns_on_rails/controllers/timezoneable.rb +34 -14
  16. data/lib/concerns_on_rails/encryption.rb +29 -6
  17. data/lib/concerns_on_rails/legacy_aliases.rb +15 -3
  18. data/lib/concerns_on_rails/models/activatable.rb +41 -5
  19. data/lib/concerns_on_rails/models/addressable.rb +59 -16
  20. data/lib/concerns_on_rails/models/anonymizable.rb +38 -6
  21. data/lib/concerns_on_rails/models/auditable.rb +29 -36
  22. data/lib/concerns_on_rails/models/counter_cacheable.rb +118 -18
  23. data/lib/concerns_on_rails/models/duplicable.rb +262 -22
  24. data/lib/concerns_on_rails/models/encryptable.rb +272 -51
  25. data/lib/concerns_on_rails/models/expirable.rb +114 -26
  26. data/lib/concerns_on_rails/models/hashable.rb +6 -1
  27. data/lib/concerns_on_rails/models/lockable.rb +29 -8
  28. data/lib/concerns_on_rails/models/maskable.rb +5 -1
  29. data/lib/concerns_on_rails/models/monetizable.rb +14 -7
  30. data/lib/concerns_on_rails/models/normalizable.rb +86 -17
  31. data/lib/concerns_on_rails/models/publishable.rb +99 -19
  32. data/lib/concerns_on_rails/models/sanitizable.rb +49 -11
  33. data/lib/concerns_on_rails/models/schedulable.rb +22 -6
  34. data/lib/concerns_on_rails/models/searchable.rb +5 -2
  35. data/lib/concerns_on_rails/models/sequenceable.rb +127 -24
  36. data/lib/concerns_on_rails/models/sluggable.rb +70 -13
  37. data/lib/concerns_on_rails/models/soft_deletable.rb +238 -22
  38. data/lib/concerns_on_rails/models/sortable.rb +93 -6
  39. data/lib/concerns_on_rails/models/stateable.rb +115 -24
  40. data/lib/concerns_on_rails/models/storable.rb +19 -19
  41. data/lib/concerns_on_rails/models/taggable.rb +58 -10
  42. data/lib/concerns_on_rails/models/tokenizable.rb +18 -2
  43. data/lib/concerns_on_rails/support/address_data.rb +76 -4
  44. data/lib/concerns_on_rails/support/affix.rb +69 -0
  45. data/lib/concerns_on_rails/support/association_scope.rb +21 -0
  46. data/lib/concerns_on_rails/support/batch_ops.rb +125 -17
  47. data/lib/concerns_on_rails/support/contextual_destroy.rb +321 -0
  48. data/lib/concerns_on_rails/support/error_envelope.rb +32 -1
  49. data/lib/concerns_on_rails/support/generated_values.rb +94 -14
  50. data/lib/concerns_on_rails/support/hooked_write.rb +30 -16
  51. data/lib/concerns_on_rails/support/html_sanitizers.rb +19 -6
  52. data/lib/concerns_on_rails/support/locking.rb +9 -4
  53. data/lib/concerns_on_rails/support/money.rb +58 -7
  54. data/lib/concerns_on_rails/support/native_json.rb +31 -0
  55. data/lib/concerns_on_rails/support/numeric_operand.rb +22 -3
  56. data/lib/concerns_on_rails/support/primary_key.rb +28 -0
  57. data/lib/concerns_on_rails/support/scalar_param.rb +21 -6
  58. data/lib/concerns_on_rails/support/sequence_calculator.rb +55 -11
  59. data/lib/concerns_on_rails/support/time_value.rb +28 -0
  60. data/lib/concerns_on_rails/support/transform_backstop.rb +167 -0
  61. data/lib/concerns_on_rails/support/write_boundary.rb +64 -0
  62. data/lib/concerns_on_rails/version.rb +1 -1
  63. data/lib/concerns_on_rails.rb +5 -0
  64. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e44cfe4621a22897c4d3f6e2ade5b1485d7c686f10dd72fb0dd582a50eadd73
4
- data.tar.gz: 478bc126777b1627ffba7275f1f81421c0ce112b01e3348c30b4010a023af75c
3
+ metadata.gz: 9acfbd10f8e27c5a4535a3486fec52255d7d5e997e3b7654547e864802bdcf58
4
+ data.tar.gz: 31701400ec820ac47a7cd26e8b68579dab0fba3d43bd1d8cea87b63c60f244ce
5
5
  SHA512:
6
- metadata.gz: bf067b1d2068e59ed7007bddfc41e2b70a287a57e6e3727e0fba8653ef4d4c1f892e158d6e012b96fbfb862eb1a2a8e48c17d60acc0891c2b85fc43f9c8bb17f
7
- data.tar.gz: 71db79d7dd7e673b111d20a89a99d2ea7a95cd41c13ff3ededd017f27ef10a4351d7689b7aebcf04a2c8aedebbc0cee5bb8d8f57e1ac96bec1aaf5f838852171
6
+ metadata.gz: 0fc6561854bcdb26fbbae74b6f9b623bd9af28b240b313f56dcaa5c40da9c03726db39908e2b6ddb6af67d326fdd1f597c4ec81d31fc951711753320f44cf533
7
+ data.tar.gz: 4471742ad951b6b0dfd9998c93c43ba2e6627d44e5d6b94c0e48c366dcd5732f0c51b5df7886d3d88f0c6a71cf63a33f1a6695adfa1bf8292b8d51eb91d49609
data/CHANGELOG.md CHANGED
@@ -1,5 +1,586 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.34.0 (2026-10-29)
4
+
5
+ A correctness release: it fixes the 43 bugs proven by the 2026-10-29 audit of 1.33.0. Some fixes change behaviour,
6
+ and five configurations that used to be silently broken now raise at class load. Read **Upgrading** first.
7
+
8
+ ### Upgrading
9
+
10
+ **Class-load (boot) raises for configurations that were silently broken.**
11
+
12
+ - [ ] **CounterCacheable, composite keys:** `counter_cacheable_by` raises `ArgumentError` for a `belongs_to` with a
13
+ composite `foreign_key:` (`[:shop_id, :order_id]`), a composite association primary key or `query_constraints:`
14
+ (Rails 7.1+), as it does for a polymorphic one. It was accepted, never counted the children and made
15
+ `recount_counter_caches!` raise invalid SQL. Count on a single-column key instead. A model-level
16
+ `query_constraints` must be declared before `counter_cacheable_by`. (GRAPH-2)
17
+ - [ ] **CursorPaginatable, `predicate: :row`:** `predicate: :row` with a mixed-direction `order:` or `order_presets:`
18
+ entry raises `ArgumentError` at class load (also when a later re-declaration combines with an inherited one), and a
19
+ per-call `cursor_paginated(order:)` with mixed directions raises before page 1. This includes a nullable ordering
20
+ column, which used to fall back to the OR-expansion silently. Switch to `predicate: :auto` (it falls back to the
21
+ OR-expansion for mixed directions) or make the directions uniform. (PARAMS-4)
22
+ - [ ] **Stateable, event named `transition`:** a transition event whose (affixed) name is `transition` raises
23
+ `ArgumentError` at the macro. It generated a `transition!` that always raised from the generic
24
+ `before_/after_transition` hooks. Rename the event or use `prefix:`/`suffix:`. (STATE-6)
25
+ - [ ] **Authorizable, `require_role`:** `require_role` with an empty list (`require_role []`, `[[]]`) or a nil/blank
26
+ role (`require_role ENV["ADMIN_ROLE"]&.to_sym` unset) raises `ArgumentError` at class load; a blank role used to
27
+ admit actors whose role is `""` or nil. (HTTP-2)
28
+ - [ ] **Encryptable, per-field `key:`:** a DECLARED per-field `key:` that resolves to nil or `""` (a Proc over a
29
+ credential that is not there yet, an explicit `key: nil`, an eagerly nil `ENV["X"]` / credential) is a missing key:
30
+ it raises `MissingKeyError` naming the field on write, read and blind-index lookup (or leaves the value plaintext
31
+ under `on_missing_key: :passthrough`) instead of silently encrypting under the global key. Only an OMITTED `key:`
32
+ uses the gem-level key. `key:` must be a String or respond to `call`; `key: false`, `key: 123` or `key: :sym` raises
33
+ `ArgumentError` at the macro. Rows already written under the global key are NOT repaired by `reencrypt_all!`
34
+ (it skips per-field fields); they fail to decrypt under the field key. For each such row, decrypt the raw column
35
+ value with the global key (`ConcernsOnRails::Support::Encryptor.decrypt(raw, key: <global key material>,
36
+ salt: ConcernsOnRails.encryption.key_derivation_salt)`; read `raw` with `<field>_ciphertext`, since the model's
37
+ reader raises for such rows), then write the plaintext back through the model while the field key resolves, so it
38
+ is re-sealed under the field key.
39
+ Rows left plaintext under `:passthrough` only need a plain re-save once the key resolves. (DATA-2)
40
+
41
+ **Behaviour changes.**
42
+
43
+ - [ ] **Normalizable / Sanitizable / Taggable / Addressable:** Normalizable no longer re-normalizes a later transform's
44
+ output at `before_save`, so with Normalizable included BEFORE a Sanitizable/Taggable/Addressable rule on the same
45
+ column its rule no longer gets "the last word" (`squish` then `strip` on: :write stores `" a b"` where it stored
46
+ `"a b"`). Include Normalizable AFTER the other transform to normalize its output. (TRANS-2)
47
+ - [ ] **Taggable:** with a self-overlapping delimiter (`"--"`, `"||"`, `" | "`) a tag is trimmed of a trailing part
48
+ that is the start of the delimiter, and of a leading part that is the end of it: `"a-"` becomes `"a"` and `"-b"`
49
+ becomes `"b"` under `"--"`. Delimiters that do not overlap themselves are unaffected. Stored tags with a leading
50
+ straddling part are not migrated; `Model.normalize_tags!` repairs them. (TRANS-4)
51
+ - [ ] **Paginatable / Includable:** re-declaring `paginate_by` or `includable` in a subclass changes only what it
52
+ passes; every omitted option now keeps its inherited value (before it reset to the gem default, for example a
53
+ parent `max_per_page: 20` became 200). A subclass that relied on the reset must pass the option explicitly.
54
+ For `includable` the association list still replaces the parent's, and an inherited `default:` path the new list no
55
+ longer contains is dropped with a stderr warning at class load. (PARAMS-2)
56
+ - [ ] **Filterable, `_null`:** `?<field>_null=` and `?field[null]=` read only `true/false/1/0/t/f/yes/no`
57
+ (case-insensitive) or a real boolean; any other NON-BLANK value (`maybe`, `on`, `off`, `nope`) matches nothing
58
+ instead of being read as `IS NULL` (`off` used to be `IS NOT NULL`). A blank value is skipped as before, and
59
+ surrounding whitespace is ignored. (PARAMS-6)
60
+ - [ ] **Filterable, encrypted columns:** on an `encryptable` column equality and `in` go through the blind index and every
61
+ other operator (`not`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`) fails closed (`none`); a field
62
+ without a blind index fails closed for every operator but `null`. Add `blind_index: true` to filter an encrypted
63
+ field by equality. (PARAMS-1)
64
+ - [ ] **Filterable, `with:` lambdas:** a lambda declared with a `type:` receives no Array/Hash/Parameters value; such a
65
+ value fails the filter closed (`none`) for every `type:`. (PARAMS-S4)
66
+ - [ ] **Filterable, float columns:** a decimal literal that a double does not read back as (more digits than a Float
67
+ keeps, e.g. `0.1000000000000000000001`) equals nothing and compares as the largest double at or below it. (PARAMS-8)
68
+ - [ ] **Paginatable / CursorPaginatable, `page` / `per_page`:** read as exact whole numbers (`1e3` is 1000, `2e0` is 2);
69
+ a fractional (`2.9`), trailing-garbage (`10abc`) or unreadable value falls back to the default instead of its
70
+ leading digits, and a value beyond 100 digits falls back to the default instead of clamping. (PARAMS-7)
71
+ - [ ] **CursorPaginatable, SQLite:** a hand-made cursor whose String boundary carries a NUL byte is accepted on SQLite
72
+ (it cannot be told from a minted one) instead of answering `InvalidCursor`. (PARAMS-5)
73
+ - [ ] **Publishable:** `unpublish!` on a stored draft (blank saved column; false/NULL on a boolean) is a no-op like
74
+ `publish!`: it returns `true`, writes nothing and fires no `before/after_unpublish`. A scheduled row is still
75
+ cleared with the hooks. (STATE-5)
76
+ - [ ] **SoftDeletable:** `soft_delete!`/`restore!` decide "already done" from the SAVED stamp. Assigning the stamp
77
+ without saving no longer makes the verb return `true` while writing nothing: it writes, fires the hooks and cascades.
78
+ A no-op verb now saves OTHER pending changes (previously dropped; a validation can make it return `false`). (STATE-12)
79
+ - [ ] **Expirable:** `expire_in!(nil)` and `extend_expiry!(by: nil)` (anything but an `ActiveSupport::Duration` or a
80
+ `Numeric`) raise the concern's `ArgumentError` instead of a bare `TypeError`; an object that merely responds to
81
+ `to_i` is refused too. (STATE-10)
82
+ - [ ] **Expirable, `date` columns:** `expire_in!(15.minutes)` on a `date` column writes today's date, so it now fires the
83
+ hooks, because the record is `expired?` (unchanged for UTC process zones). (STATE-2)
84
+ - [ ] **Anonymizable:** `anonymize!` returns `false` (no `after_anonymize`) when its UPDATE reached no row, and
85
+ `anonymize_all!` no longer counts such a row. (STATE-9)
86
+ - [ ] **Lockable:** `register_failed_attempt!` on a `readonly!` or destroyed record raises before incrementing, so
87
+ nothing is written; an already-locked record still just answers the count. (DATA-4)
88
+ - [ ] **Monetizable:** the writer casts an amount the cents column cannot hold to `nil`, like other oversized input
89
+ (a 4-byte integer column overflows at about 21.4 million units). (TRANS-S3)
90
+ - [ ] **Authorizable:** the default denial `message:` follows the status: 404 renders ErrorHandleable's exact
91
+ "Resource not found", 401 "Unauthorized", anything else "Forbidden". A `status: :not_found` / `:unauthorized` rule
92
+ without `message:` changes its body message and the `authorization_denied` payload's `message`; an explicit
93
+ `message:` still wins. (HTTP-1)
94
+ - [ ] **Authorizable, `require_role`:** Array arguments are flattened, so `require_role %i[admin editor]` and
95
+ `require_role ADMIN_ROLES` work (they used to deny everyone). (HTTP-2)
96
+ - [ ] **Timezoneable:** responses gain `Vary: Time-Zone` whenever the `Time-Zone` header source is on (default; no longer
97
+ only with `response_header:`) and `Vary: Cookie` whenever the cookie source is on. New `vary:` option (default
98
+ true; `vary: false` opts out). `response_header:` now controls only `X-Time-Zone`. (HTTP-3)
99
+ - [ ] **ErrorHandleable:** the RecordNotDestroyed 422 renders "Resource could not be deleted" instead of Rails'
100
+ "Failed to destroy <Model> with id=<id>"; the record's `errors.full_messages` still ride as `details`. The
101
+ `message` of that 422 and of the `handled_error` payload changed. (HTTP-4)
102
+ - [ ] **Respondable / ErrorEnvelope, status symbols:** on Rack >= 3.1 without Rails 8.1's own rewrite (Rails
103
+ 7.1/7.2/8.0) the gem renders the renamed symbol (`:unprocessable_content`, ...), so `rendered[:status]` is Rack
104
+ dependent; specs asserting `:unprocessable_entity` should compare to `ErrorEnvelope.renderable_status(:unprocessable_entity)`.
105
+ An app's OWN `render_error` override still receives the raw `:unprocessable_entity`; one that calls `render` with
106
+ it logs Rack's deprecation on Rack >= 3.1. (HTTP-5)
107
+ - [ ] **SecureHeadable, `:hsts`:** the `:hsts` preset is skipped when `Rails.application.config.force_ssl` is on (read
108
+ per request), so Rails' own HSTS (`config.ssl_options[:hsts]`) is no longer overridden by the preset's one-year
109
+ value. A custom `"Strict-Transport-Security"` pair with a different value is still sent. (HTTP-6)
110
+ - [ ] **SecureHeadable, header names:** names compare case-insensitively: a lower-case custom
111
+ `"strict-transport-security"` is withheld over plain HTTP, and a lower-case pair beside a preset
112
+ (`"x-frame-options"` + `:deny_frame`) is ONE header (later value wins, first-registered spelling kept), written
113
+ under the spelling the response already carries. (HTTP-7)
114
+ - [ ] **SoftDeletable:** after an association `destroy_all` the association cache is reset rather than marked
115
+ loaded-and-empty, so a `default_scope: false` / `-> { with_deleted }` association still shows its soft-deleted rows
116
+ on the next read (and `size`); one extra query on the next read. (STATE-4)
117
+ - [ ] **Batch verbs, partial `select`:** every `*_all` verb and a SoftDeletable relation `destroy_all` over a relation
118
+ with a partial `select` load whole rows (`table.*` is added; computed aliases a HAVING/ORDER reads stay; joins and
119
+ wheres kept) instead of raising `MissingAttributeError`. Accepted limits: a joined-table column, a Rails 7.1+ Hash
120
+ select or a comma-joined String repeating a column beside the star can still repeat `id` in the DISTINCT key
121
+ subquery on PostgreSQL/MySQL; a direct `BatchOps.each_record` caller selecting without the primary key still
122
+ gets nil ids. (STATE-7)
123
+ - [ ] **Stateable, `transition_all`:** a record failing validation raises `ActiveRecord::RecordNotSaved` (carrying the
124
+ record; batch rolled back as before) instead of `RecordInvalid`; rescue `RecordNotSaved`. Another record's failed
125
+ `save!` inside a hook still propagates as `RecordInvalid`. The single `<event>!` still raises `RecordInvalid`.
126
+ (STATE-8)
127
+ - [ ] **Sequenceable, `period_column:`:** a row numbered before a switch to a custom `period_column:` (NULL there)
128
+ formats (`formatted_<field>`, `sequenceable_period_time`) in its `created_at` period instead of today's. (GRAPH-1)
129
+ - [ ] **Sluggable, `history: true` + `scope:`:** moving a record into a scope where another record's friendly_id history
130
+ holds its slug regenerates the slug (it raised `RecordNotUnique` or took over the old URL). (GRAPH-5)
131
+ - [ ] **Duplicable:** a child or has_one target marked for destruction (`_destroy`, `mark_for_destruction`) is no longer
132
+ copied. (GRAPH-6)
133
+ - [ ] **Legacy aliases:** a racing first reference to a top-level alias (`ConcernsOnRails::Sluggable`) no longer prints
134
+ "already initialized constant". (GRAPH-4)
135
+
136
+
137
+ ### Fixed
138
+
139
+ - **Lockable**: `register_failed_attempt!`, `lock_access!`, `unlock_by_token`, the lapsed-lock clear and the lost-claim
140
+ adoption read no longer raise `ArgumentError` on a composite primary key (Rails 7.1+); every site addresses its row
141
+ through `Support::PrimaryKey`. (DATA-1)
142
+ - **Tokenizable**: `consume_<field>` no longer raises `ArgumentError` on a composite primary key. (DATA-1)
143
+ - **Lockable**: `register_failed_attempt!` on a `readonly!` (or destroyed) record raises `ReadOnlyRecord` /
144
+ `ActiveRecordError` before incrementing; it used to increment in SQL and then raise from `lock_access!`, leaving the
145
+ row at max attempts and unlocked. (DATA-4)
146
+ - **Tokenizable / Lockable**: `authenticate_by_<field>` / `consume_<field>` and `unlock_by_token` answer `nil`, without
147
+ a query, for a token holding a NUL byte (`?token=%00`); on PostgreSQL they raised (the driver refuses NUL in a bound
148
+ parameter). Uses `Support::ScalarParam.nul_free?` like Filterable. (DATA-3)
149
+ - **Encryptable**: a per-field `key:` that resolves to nil or `""` (a Proc over a missing credential, an explicit
150
+ `key: nil`, an eagerly nil `ENV[...]`) no longer falls back to the global key. It raises `MissingKeyError` naming
151
+ the field on write, read and blind-index lookup/refresh, or stays plaintext under `on_missing_key: :passthrough`;
152
+ `key:` must be a String or callable (`ArgumentError` otherwise). It used to seal a key_id-0 envelope under the
153
+ global key that the field key could not decrypt later. (DATA-2)
154
+ - **Authorizable**: the default denial `message:` follows the status (404 "Resource not found", 401 "Unauthorized",
155
+ else "Forbidden"), so a 404 denial is no longer distinguishable from ErrorHandleable's own 404. (HTTP-1)
156
+ - **Authorizable**: `require_role` flattens Array arguments (`require_role %i[admin editor]` used to deny everyone) and
157
+ refuses an empty list or a nil/blank role at class load. (HTTP-2)
158
+ - **SecureHeadable**: the `:hsts` preset is skipped under `config.force_ssl`, so it no longer overrides Rails' own HSTS
159
+ header. (HTTP-6)
160
+ - **SecureHeadable**: header names compare case-insensitively, so a lower-case custom pair beside a preset (or Rails'
161
+ default `X-Frame-Options` on Rails < 7.1) is one header, not two conflicting ones. (HTTP-7)
162
+ - **SoftDeletable**: `really_delete!`, `is_really_deleted?` and the trashed-row purge guard no longer raise on a
163
+ composite primary key. (STATE-3)
164
+ - **SoftDeletable**: `soft_delete!`/`restore!` read the SAVED stamp instead of the unsaved in-memory one; they used to
165
+ return `true` while writing nothing (no hooks, no cascade) after the stamp was assigned unsaved. `restore!` cascades
166
+ by the saved stamp. (STATE-12)
167
+ - **Sortable**: `reposition!` works on a composite primary key (Rails 7.1+). Ids are `[shop_id, id]` tuples (a record
168
+ is accepted too), cast per column and written one UPDATE each inside the transaction; a malformed tuple raises
169
+ `ArgumentError` before any write. A simple key keeps the single CASE UPDATE. It raised `TypeError: can't quote
170
+ Array`. (GRAPH-3)
171
+ - **CounterCacheable**: a composite-key `belongs_to` is refused at the macro (see Upgrading); it was accepted and never
172
+ counted. (GRAPH-2)
173
+ - **Expirable**: `time_until_expiry` no longer raises `TypeError` on a `date` column with a future date. It agrees with
174
+ `expired?` (0 when expired, today counts, in `Time.zone`), otherwise measures to the start of the date in
175
+ `Time.zone`, and is clamped at 0. Also fixes today's date in a zone ahead of UTC answering a positive duration
176
+ while `expired?` was true. (STATE-1)
177
+ - **Expirable**: `expire!` / `expire_in!` on a `date` column fire `before/after_expire` exactly when the written value
178
+ makes the record `expired?`, whatever the process TZ (`Date#to_time` used `ENV["TZ"]` and skipped the hooks for
179
+ hours a day). A blank time (`nil`, `""`, `false`) for `expire!`/`expire_all` is cast through the column like a given
180
+ time (today's `Date` on a `date` column; `expire_all(nil)` writes it). (STATE-2)
181
+ - **Expirable**: `expire_in!(nil)` and `extend_expiry!(by: nil)` raise the concern's `ArgumentError`
182
+ (shared `expirable_check_duration!`). (STATE-10)
183
+ - **Publishable**: `unpublish!` on a draft is idempotent like `publish!`; `unpublish_all` already skipped such rows, so
184
+ verb and batch now agree. (STATE-5)
185
+ - **Anonymizable**: the erasing UPDATE's result is honoured; a row deleted (or unwritable under optimistic locking)
186
+ between load and UPDATE is not counted, and `anonymize!` returns `false` instead of raising from the reload.
187
+ (STATE-9)
188
+ - **HookedWrite** (every hooked verb: `publish!`, `activate!`, `expire!`, `soft_delete!`, Stateable `<event>!`, ...): a
189
+ veto on a NEW record inside a caller's transaction left it `persisted?` with no id; the next `save` ran
190
+ `UPDATE ... WHERE id IS NULL`, returned `true` and wrote no row (`StaleObjectError` with `lock_version`). The
191
+ identity (`new_record?`, `destroyed?`, `previously_new_record?`, id) is now snapshotted and restored for new records
192
+ too. Rails 6.0 never showed the bug; 6.1 through 8.1 did. (STATE-11)
193
+ - **Storable / Auditable**: a PostgreSQL `jsonb` column and an `attribute :x, :json` override over a text column are
194
+ handed the Hash/Array instead of a JSON string scalar, so `where_<key>(v)` matches and `where_<key>(nil)` no longer
195
+ matches every row. One shared `Support::NativeJson.column?` backs both. (TRANS-1)
196
+ - **Normalizable**: no longer re-normalizes a later transform's output at `before_save`: `normalizable :title, with:
197
+ :titleize` plus `sanitizable :title, with: :strip, on: :write` stored `"A &Lt; B"` for a validated `"A &lt; B"`, and
198
+ an entity-decoding rule could store live markup in a `:strip` column. Every transform records what it wrote
199
+ (`Support::TransformBackstop.wrote`) and the settle step skips a field still holding it. (TRANS-2)
200
+ - **Normalizable / Sanitizable**: a save halted by a later `before_save` (or rolled back by `RecordNotUnique`), then
201
+ retried on the same record, no longer re-runs a non-idempotent rule on its own output (`"a!"` no longer becomes
202
+ `"a!!"`, `"Tom &amp; Jerry"` no longer `"Tom &amp;amp; Jerry"`), `update_attribute` saves included. The per-save
203
+ records are dropped after a successful save, kept across a rollback, and dup/clone no longer share them. (TRANS-3)
204
+ - **Taggable**: a tag ending with the start of a self-overlapping delimiter, or starting with its end (`["a-", "b"]`
205
+ under `"--"` read back as `["a", "-b"]`), is trimmed when cleaned; `tag_list=`/`add_tags` clean an Array item by item instead of joining and
206
+ re-splitting. (TRANS-4)
207
+ - **Monetizable**: `price = "1e18"` on an integer cents column casts to `nil` (checked against the column type's own
208
+ range) instead of a value whose save raised `ActiveModel::RangeError`. (TRANS-S3)
209
+ - **Paginatable / Includable**: `paginate_by` and `includable` re-declared in a subclass keep every omitted option
210
+ (an UNSET sentinel, as `cursor_paginate_by`); `paginate_by style:` re-derives both param paths, explicit
211
+ `page_param:`/`per_page_param:` still win; `includable` `fields:`/`default:`/`strategy:` inherit when omitted
212
+ (`default: nil` / `fields: {}` clear them). (PARAMS-2)
213
+ - **Paginatable**: a grouped relation with a `HAVING` on a select alias no longer raises `StatementInvalid`
214
+ (SQLite/MySQL); it is counted as a subquery that keeps its select list. A grouped relation with no `HAVING` or no
215
+ select list keeps the plain `COUNT ... GROUP BY`. (PARAMS-3)
216
+ - **CursorPaginatable**: `predicate: :row` with mixed directions is refused at the macro (see Upgrading); page 1
217
+ minted a cursor and every page-2 request answered 500. (PARAMS-4)
218
+ - **CursorPaginatable**: on SQLite a row whose String ordering value holds a NUL byte no longer ends the walk with a
219
+ 400 on its own cursor; the boundary goes into the keyset WHERE as `CAST(X'<hex>' AS TEXT)`, which assumes a UTF-8
220
+ SQLite database (Rails' default). MySQL already accepted it; PostgreSQL still answers `InvalidCursor`. (PARAMS-5)
221
+ - **Paginatable / CursorPaginatable**: `page` / `per_page` are read as exact whole numbers through
222
+ `Support::NumericOperand` instead of `String#to_i` (`?per_page=1e3` was 1, `?per_page=10abc` was 10); a non-whole
223
+ JSON Float falls back too. (PARAMS-7)
224
+ - **Filterable**: `?<field>_null=` reads only an explicit true/false spelling; any other non-blank value matches
225
+ nothing (see Upgrading). (PARAMS-6)
226
+ - **Filterable**: Encryptable-aware (`Support::EncryptedLookup.rule`), `alias_attribute` followed: equality and `in` use
227
+ the blind index, `not`/`not_in`/`gt`/`gte`/`lt`/`lte`/`contains`/`starts_with` fail closed. `?ssn_not=123` used to
228
+ return the excluded row and `=`/`in`/`contains` silently matched nothing. `null` keeps working on an encrypted
229
+ column. A nil member of an Array operand is dropped. (PARAMS-1)
230
+ - **Filterable**: a Float-column operand is no longer rounded to the nearest double and treated as exact;
231
+ `?score_gt=0.1000000000000000000001` keeps a stored 0.1. A literal the double keeps (`0.1`) stays exact. (PARAMS-8)
232
+ - **Filterable**: a `with:` lambda declared with a `type:` fails closed on an Array/Hash/Parameters value instead of
233
+ receiving a cast one (`?flag[]=0` was `true`). (PARAMS-S4)
234
+ - **Authorizable**: the default `message:` follows the denial status (404 "Resource not found", the shared
235
+ `Support::ErrorEnvelope::NOT_FOUND_MESSAGE`; 401 "Unauthorized"; else "Forbidden"), stored resolved at declaration so
236
+ the instrumentation payload carries it. `require_role` flattens its arguments and raises on an empty, nil or blank
237
+ role. (HTTP-1, HTTP-2)
238
+ - **ErrorHandleable**: the RecordNotDestroyed 422 no longer leaks the model class name and primary key. (HTTP-4)
239
+ - **Timezoneable**: `Vary: Time-Zone` / `Vary: Cookie` are appended (via `Support::VaryHeader`; `Vary: *` left alone)
240
+ even when the request carries no `Time-Zone` header; `vary: false` opts out. (HTTP-3)
241
+ - **Respondable / ErrorEnvelope**: the envelope and inline branches no longer log "Status code :unprocessable_entity is
242
+ deprecated" on Rack >= 3.1; `ErrorEnvelope.renderable_status` renames only when the running Rack dropped the old
243
+ name (Rack < 3.1 keeps it). (HTTP-5)
244
+ - **SecureHeadable**: `:hsts` yields to `force_ssl`; header names merge case-insensitively through
245
+ `SecureHeadable.merge_headers` (no more two conflicting `X-Frame-Options` lines on Rails < 7.1). (HTTP-6, HTTP-7)
246
+ - **SoftDeletable**: association `destroy_all` resets the association cache instead of `loaded!`-empty. (STATE-4)
247
+ - **BatchOps / SoftDeletable**: `BatchOps.full_rows(relation)` (used by `BatchOps.run` and the relation `destroy_all`)
248
+ adds the model's own star to a partial select; `limited_keys` keeps the own-table star first and drops only plain
249
+ own-table columns from a DISTINCT key subquery. (STATE-7)
250
+ - **Stateable**: an event named `transition` is refused at the macro (STATE-6); `transition_all` raises
251
+ `RecordNotSaved` for its own invalid record and propagates another record's `RecordInvalid` (STATE-8).
252
+ - **Sequenceable**: legacy rows (NULL custom `period_column:`) format in their `created_at` period; `period_time` /
253
+ `base_time` take the field. (GRAPH-1)
254
+ - **Sluggable**: a `history: true` + `scope:` move treats another record's history slug in the new scope as taken.
255
+ (GRAPH-5)
256
+ - **Duplicable**: records marked for destruction are skipped, loaded or not, has_one included. (GRAPH-6)
257
+ - **Legacy aliases**: `const_missing` resolution is serialized by a reentrant Monitor. (GRAPH-4)
258
+
259
+
260
+ ### Known issues (not fixed in this release)
261
+ - CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
262
+ mirrored counter/lock_version in memory.
263
+ - Aliasable: eager-loading an alias together with its source in one query loads each child twice.
264
+ - Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
265
+ cannot be `Marshal.dump`ed (`Rails.cache`).
266
+ - CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
267
+ its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
268
+ - Normalizable / Sanitizable: on an unvalidated (`update_attribute`) save that is halted and retried, with TWO
269
+ transforms on one column where the SECOND (not Normalizable) rewrites the first's output, Sanitizable's own record
270
+ holds only the first's output, so a validated retry re-sanitizes.
271
+ - Sluggable `history: true` + `scope:`: friendly_id's history finder ignores scope, so after a record moves scope its
272
+ stale history slug can still match `Model.where(scope).friendly.find(old_slug)`. This is stock friendly_id
273
+ behaviour; the gem does not change it. (GRAPH-5)
274
+
275
+
276
+ ## 1.33.0 (2026-10-18)
277
+
278
+ A correctness release: it fixes the 66 bugs proven by the 2026-10 audit of 1.32.1. Some fixes change
279
+ behaviour, and several configurations that used to be silently broken now raise at class load. Read
280
+ **Upgrading** first.
281
+
282
+ ### Upgrading
283
+
284
+ **Class-load (boot) raises for configurations that were silently broken.** Each of these raised nothing
285
+ before and misbehaved at run time; an app that boots today may fail to boot after upgrading.
286
+
287
+ - [ ] **Encryptable, uniqueness:** a uniqueness validation over an encrypted field (or an encrypted `scope:`
288
+ column) raises `ArgumentError`. It always passed and saved duplicates. Use `where_<field>` with a unique
289
+ index, or `blind_index: true`. (#187)
290
+ - [ ] **Encryptable, `serialize`/`normalizes`:** declared on the field before `encryptable` raises
291
+ (`serialize` on Rails 6.1+, `normalizes` on 7.1+). Declare `encryptable` first. (#188)
292
+ - [ ] **Encryptable, DB default:** a non-empty database default on an encrypted column raises. Drop it with the
293
+ `change_column_default` named in the message (only `NULL` or `""` are allowed). (#136)
294
+ - [ ] **Encryptable, scope columns:** an `encryptable` column as a Sequenceable `scope:`, a Sluggable/friendly_id
295
+ scope column or a Sortable `scope:` raises, in either order. Scope by an unencrypted column (a foreign key, a
296
+ plaintext copy). (#189)
297
+ - [ ] **Monetizable, marks:** `separator:` equal to `delimiter:` (the usual slip is `separator: ","` alone, since
298
+ the default delimiter is also `","`), or empty with `precision:` above 0, raises, at the macro and for
299
+ per-call `formatted_` overrides. A `precision: 0` field with `delimiter: "."` (IDR / VND / CLP) now raises
300
+ too: pass `separator: ","`. (#158)
301
+ - [ ] **Addressable, `default_country:`:** a value that resolves to no ISO country raises; a valid name or alpha-3
302
+ is stored as its alpha-2 code. Fix typos and unknown values. (#174)
303
+ - [ ] **Shared affixed names:** two concerns that generate the same affixed predicate or scope name (for example
304
+ `prefix: :term` on Expirable and Activatable) raise at the macro. Give them distinct affixes. (#184)
305
+ - [ ] **Sortable (controller):** `direction:` other than `asc`/`desc` (any case) raises (`:dsc`, `nil`). `"DESC"`
306
+ used to sort ascending. (#165)
307
+ - [ ] **Sequenceable, `period_column:`:** the macro refuses a missing, non-date or `:updated_at` column. Under `reset:` it also refuses a subclass that restates its parent's visible format with a different `period_column:`, because the two series would print the same numbers. Declare the option on the declaring class, or in an `assign:`-only re-declaration. (#191)
308
+
309
+ **Behaviour changes.**
310
+
311
+ - [ ] **SoftDeletable `destroy_all`:** on a relation or association it is now a contextual soft delete
312
+ (`where(...).destroy_all`, `destroy_by`, `in_batches.destroy_all`, `post.comments.destroy_all`); only the
313
+ bare class form did before, and the scoped forms hard-deleted. Live rows are soft-deleted through
314
+ `soft_delete!`. Already-trashed rows are purged for real only when reached through that model's own trash
315
+ scopes (`only_deleted`, `soft_deleted`, `with_deleted`, `deleted_within`), fixed at call start; every other
316
+ relation leaves the trash alone. A purge also purges the record's trashed `cascade:` children, of any stamp,
317
+ ignoring the association's limit; a vetoed child skips its parent on a relation but raises on an
318
+ association. A restored row is re-checked before its purge. Rails' own `has_many :through` maintenance stays
319
+ a hard delete. Escape hatches: `really_destroy_all` to hard-delete on purpose, or `soft_deletable_by ...,
320
+ relation_destroy_all: :hard` for the old behaviour of the scoped forms. Destroy callbacks no longer run for
321
+ live rows (`before/after_soft_delete` run instead), and `touch: true` runs validations and update callbacks;
322
+ a failing row raises `RecordNotSaved` and rolls the batch back. (#169)
323
+ - [ ] **Batch verbs on STI tables** (`publish_all`/`unpublish_all`, `soft_delete_all`/`restore_all`, `expire_all`,
324
+ `activate_all`/`deactivate_all`, `unlock_expired`) always stream per record, so subclass hooks and validations
325
+ run; slower on large tables. `self.inheritance_column = nil` keeps the single `UPDATE`. (#185)
326
+ - [ ] **Batch verbs with sibling bookkeeping:** on a model with a CounterCacheable rule using `if:`, or Auditable
327
+ tracking a column the verb writes, they take the per-record path instead of the single `UPDATE`. (#154)
328
+ - [ ] **Publishable/Activatable:** `publish!` on a published record, `activate!` on an active one and
329
+ `deactivate!` on an inactive one are no-ops (return `true`, no stamp, no hooks). Use `publish_at!` to re-stamp. (#146)
330
+ - [ ] **Expirable:** `expire!`/`expire_all` never push an expiry later. Use `expire_in!` or `extend_expiry!` to set a
331
+ later one. (#141)
332
+ - [ ] **Authorizable:** the denial envelope `code` follows `status:`: `"unauthorized"` for 401, `"not_found"` for
333
+ 404, `"forbidden"` otherwise. Clients matching on `forbidden` need updating. (#145)
334
+ - [ ] **Respondable:** a `Vary` in `headers:` is appended to the existing `Vary` instead of replacing it; a
335
+ caller's `Vary: *` still replaces. (#160)
336
+ - [ ] **ErrorHandleable:** a malformed query string renders the JSON 400 `bad_request` envelope instead of Rails'
337
+ HTML 400 page. `handle_errors except: :bad_request` restores propagation. (#155)
338
+ - [ ] **Encryptable/Auditable:** the blind index and the audit trail are captured at the write boundary, after
339
+ every `before_*` callback, so your own callbacks see them as loaded; the new values are in place from
340
+ `after_save`. (#172)
341
+ - [ ] **Anonymizable:** `Model.anonymize_all!` also erases SoftDeletable's trash and Publishable's unpublished rows
342
+ under `default_scope: true`. Call it on a relation to limit it. (#171)
343
+ - [ ] **Storable:** a key with `type:` reads its `default:` cast through that type (uncastable reads `nil`); keys
344
+ without `type:` and `:json` defaults return the default as written. (#130)
345
+ - [ ] **Filterable:** enum comparisons use labels (#163) and an unknown enum label matches nothing (#168);
346
+ `type: :datetime` reads a zone-less operand in `Time.zone` or `ActiveRecord.default_timezone`, no longer the
347
+ server's system zone (#170); an operand with a NUL byte matches nothing (#152).
348
+ - [ ] **Searchable:** `ranked:` search on a `DISTINCT` relation is returned unranked; order it yourself. (#135)
349
+ - [ ] **Sluggable:** clearing the slug source keeps the existing slug instead of a uuid. (#151)
350
+ - [ ] **Monetizable:** with `precision:` omitted and a power-of-ten `subunit_to_unit:` of 1000 or more, output
351
+ prints more decimals (3 for `1000`, 4 for `10_000`). Pass `precision:` to pin it. (#180)
352
+ - [ ] **Normalizable:** `:url` turns `//HOST/path` into `https://host/path`, and a host-less `/path` is returned
353
+ stripped as `/path`. (#177)
354
+ - [ ] **Timezoneable:** `Time.zone` is always restored after the action, also for the current zone. (#192)
355
+ - [ ] **Duplicable:** a copy of a Sortable record takes a fresh list position instead of the original's. (#166)
356
+ - [ ] **Sanitizable `on: :write`:** a persisted field the save does not change is no longer re-sanitized;
357
+ `sanitize_all!` remains the repair tool. (#148)
358
+ - [ ] **CursorPaginatable:** a numeric cursor boundary the column cannot hold is an `InvalidCursor` 400, not an
359
+ empty page (#182); a cursor minted for a `time` column is now the stored time of day, old cursors still
360
+ decode on Rails 7.0+ (#183); binary boundaries travel as Base64 (#162). Cursors minted before the upgrade
361
+ are not all safe: on Rails 6.0/6.1 an old `time` cursor repeats its boundary row once (#183), and an old
362
+ cursor on an ASCII-only binary column may be reinterpreted once (#162). The next cursor is in the new form.
363
+
364
+ ### Added
365
+ - **Sequenceable**: `period_column:` (default `:created_at`) names the datetime column a `reset:` period is cut
366
+ from. With `assign: :manual`, `period_column: :finalized_at` numbers a draft in the period it is finalized in
367
+ instead of the one it was drafted in; `assign_<field>!` stamps the column when blank. The MAX range, the
368
+ period token and `sequenceable_period_time` all read it. (#191)
369
+ - **SoftDeletable**: `soft_deletable_by ..., relation_destroy_all: :hard` restores ActiveRecord's own hard
370
+ delete for the relation and association forms of `destroy_all`. (#169)
371
+
372
+ ### Changed
373
+ - **Addressable**: `default_country:` is canonicalized like a country value (name, alpha-3 or alpha-2) and
374
+ stored as its alpha-2 code. A value that resolves to no ISO country raises `ArgumentError` when the macro
375
+ runs; `default_country: "USA"` or `"Canada"` used to silently disable postal, state and fingerprint matching
376
+ for every blank-country record. (#174)
377
+ - **Authorizable**: the denial's envelope `code` follows its `status:`: `"unauthorized"` for 401, `"not_found"`
378
+ for 404, `"forbidden"` for 403 and anything else (it was always `"forbidden"`). A `status: :not_found`
379
+ concealment rule now renders exactly what ErrorHandleable's missing-record 404 renders. The
380
+ `authorization_denied(status:, message:)` override signature is unchanged. (#145)
381
+ - **Anonymizable**: `Model.anonymize_all!` called on the model itself also erases the rows the gem's own default
382
+ scopes hide: SoftDeletable's trash and Publishable's unpublished rows under `default_scope: true`. The
383
+ application's own default scopes (a tenant, a discriminator) still apply. Called on a relation, an
384
+ association or inside `scoping`, it still erases exactly that relation. (#171)
385
+ - **Batch verbs (Publishable / SoftDeletable / Expirable / Activatable / Lockable)**: on an STI table (a
386
+ present `inheritance_column` that is a real column) `publish_all`/`unpublish_all`,
387
+ `soft_delete_all`/`restore_all`, `expire_all`, `activate_all`/`deactivate_all` and `unlock_expired` always
388
+ stream per record instead of one `UPDATE`, so a subclass's overridden hooks, validations and re-declared
389
+ options apply. A model with `self.inheritance_column = nil` keeps the single `UPDATE`. (#185)
390
+ - **Concerns sharing affixed names**: declaring two concerns that generate the same affixed predicate or scope
391
+ name (for example `prefix: :term` on Expirable and Activatable) raises `ArgumentError` at the macro. Before,
392
+ the second silently replaced the first's `term_active?` and `.term_active`. Unaffixed concerns that share plain
393
+ names are unchanged. (#184)
394
+ - **Encryptable**: a non-empty database default on an encrypted column is refused (`ArgumentError` at
395
+ declaration, with the `change_column_default` that drops it). Any default other than `NULL` or `""` is
396
+ plaintext that can never decrypt, so every `Model.new` raised `DecryptionError`. Skipped while the schema is
397
+ unreachable. (#136)
398
+ - **Encryptable**: a uniqueness validation over an encrypted field (or an encrypted `scope:` column), declared
399
+ before or after `encryptable`, raises `ArgumentError` at class load. Before, it always passed and duplicates
400
+ were saved. Uniqueness on plain columns is unchanged. (#187)
401
+ - **Encryptable**: `serialize` or `normalizes` declared on the same field before `encryptable` raises
402
+ `ArgumentError` at class load (Rails 6.1+ for `serialize`, 7.1+ for `normalizes`). Declare `encryptable` first.
403
+ Before, `encryptable` silently dropped the earlier decoration. Rails 6.0 serialize-first declarations are
404
+ unchanged. (#188)
405
+ - **Encryptable / Sequenceable / Sluggable / Sortable**: an `encryptable` column can no longer be a Sequenceable
406
+ `scope:`, a Sluggable/friendly_id scope column (an association's foreign key and multi-column scopes
407
+ included) or a Sortable (acts_as_list) `scope:`; declaring one raises `ArgumentError` from either order.
408
+ Scoped queries compared ciphertext and matched no row. Scope by an unencrypted column instead. (#189)
409
+ - **CursorPaginatable**: a numeric cursor boundary the column cannot hold (beyond its precision or range, finer
410
+ than its scale, non-numeric or not valid UTF-8, over 100 characters, or with an exponent beyond +-1000) is an
411
+ `InvalidCursor`, which renders the documented 400. Before, it silently ended the walk with an empty 200. (#182)
412
+ - **Expirable**: expiring never pushes an existing expiry later. `expire_all(time)` writes only rows whose expiry
413
+ is unset or later than `time`, and the count is the rows actually written. `expire!(time)` on a record whose
414
+ saved expiry is already at or before `time` returns `true`, writes nothing and fires no hook. To set a later
415
+ expiry, use `expire_in!` or `extend_expiry!`. (#141)
416
+ - **Filterable**: `type: :datetime` reads a zone-less operand exactly as the target column would: in `Time.zone`
417
+ on a zone-aware column, in `ActiveRecord.default_timezone` otherwise. A `with:` lambda under `type: :datetime`
418
+ receives the value in `Time.zone` while `time_zone_aware_attributes` is on, else in `default_timezone`.
419
+ `type: :date` / `:time` use ActiveRecord's own types. Results used to depend on the server's `ENV["TZ"]`. (#170)
420
+ - **Monetizable**: raises `ArgumentError` when `separator:` equals `delimiter:` or is empty while `precision:` is
421
+ above 0, at the macro and for per-call `formatted_` overrides. `formatted_price` used to print `"EUR1,234,56"`,
422
+ which the writer read back as `nil`. This includes `precision: 0` fields whose marks are equal (IDR / VND /
423
+ CLP with `delimiter: "."`); pass `separator: ","`. (#158)
424
+ - **Monetizable**: with `precision:` omitted it follows a power-of-ten `subunit_to_unit:`, with at least 2
425
+ decimals: 3 for `1000` (KWD, BHD, OMR, TND, JOD), 4 for `10_000`, so `formatted_price` round-trips. Output for
426
+ `100`, `10`, `1` and non-power-of-ten subunits is unchanged, an explicit `precision:` is used as given, and a
427
+ per-call `subunit_to_unit:` override gets its own default. (#180)
428
+ - **Publishable / Activatable**: `publish!` on a published record, `activate!` on an active one and
429
+ `deactivate!` on an inactive one are no-ops: they return `true`, stamp nothing and fire no hook. "Already"
430
+ means the saved state of a stored record with no unsaved change to the column; a no-op that finds other
431
+ pending changes saves them. `publish_at!` still re-stamps, `deactivate!` still writes `false` over `NULL`, and
432
+ `toggle_active!` is unchanged. (#146)
433
+ - **Sanitizable** `on: :write` no longer re-runs the writer on a persisted record's field that the save does
434
+ not change, so a non-idempotent `Proc` writer no longer compounds on every save. An unrelated save no longer
435
+ "repairs" a row written around the callbacks; `sanitize_all!` remains the repair tool. (#148)
436
+ - **Searchable**: `ranked:` search on a `DISTINCT` relation is returned unranked, like a grouped relation,
437
+ instead of ordering by a rank expression outside the SELECT list (rejected by PostgreSQL and MySQL 5.7.5+).
438
+ The relation's own ORDER BY is kept. (#135)
439
+ - **Sluggable**: clearing the slug source (`nil`, a blank string, or a value that normalizes to nothing such as
440
+ `"!!!"`) keeps the existing slug instead of replacing it with friendly_id's uuid fallback. The fallback still
441
+ applies to a record with no slug yet. (#151)
442
+ - **SoftDeletable**: `destroy_all` is a contextual soft delete on a relation and on an association too, as
443
+ documented (`Model.where(...).destroy_all`, `Model.destroy_by(...)`, `in_batches.destroy_all`,
444
+ `post.comments.destroy_all`). The scoped forms used to reach ActiveRecord's own `destroy_all` and hard-delete.
445
+ A live record is soft-deleted through `soft_delete!` (hooks and `cascade:` run; `RecordNotSaved` and rollback
446
+ on failure). A record already soft-deleted is purged only when reached through that model's own trash scopes
447
+ (`only_deleted` / `soft_deleted` / `with_deleted` / `deleted_within`), fixed at call start; any other
448
+ relation leaves the trash alone. A purge also purges the record's trashed `cascade:` children, of any stamp,
449
+ ignoring the association's limit. A vetoed child skips its parent on a relation but raises on an association
450
+ (`RecordNotDestroyed`). A restored row is re-checked before its purge. Rails' `has_many :through`
451
+ maintenance stays a hard delete, as do `dependent: :destroy`, `assoc.destroy(c)`, `replace` and nested
452
+ `_destroy`; instance `destroy` is unchanged. The relation and association forms return the records acted on
453
+ (the class form still returns the count). The `relation_destroy_all: :hard` option restores the old behaviour.
454
+ See **Upgrading**. (#169)
455
+ - **Sortable** (controller): `sortable_by`'s `direction:` is case-insensitive, and any other value (`:dsc`, `nil`)
456
+ raises `ArgumentError` at class load, as CursorPaginatable does. It used to be silently coerced to `:asc`. (#165)
457
+
458
+ ### Fixed
459
+ - **Addressable**: `normalize_country:` recognizes `"UK"`, `"Great Britain"`, `"United States of America"`, ISO's
460
+ official short names (`"Viet Nam"`, `"Russian Federation"`, `"Republic of Korea"`), former names (`"Czech
461
+ Republic"`) and accent-free spellings; `default_country:` (see Changed) uses the same lookup. (#174, #175)
462
+ - **Addressable**: stamps the `fingerprint:` column in a prepended `validate` callback (after every
463
+ `before_validation`, ahead of the app's own validations) as well as in `before_save`, so a uniqueness
464
+ validation on the column no longer accepts duplicates or judges an update against the old address. (#173)
465
+ - **Addressable**: `same_address_as?` accepts a digest String as well as a record, matching `with_address`. (#181)
466
+ - **Auditable** (with **Encryptable**): changes are captured at the write boundary, after every `before_*`
467
+ callback, so the trail records the values actually written; a field set or rewritten by a later callback is no
468
+ longer missing or stale, and a change a callback reverted no longer leaves a phantom entry. An audited
469
+ blind-index column records the refreshed digest. (#172)
470
+ - **Batch verbs** (`expire_all`, `activate_all`, `deactivate_all`, `unpublish_all`, SoftDeletable's): select their
471
+ rows by their own predicates, so a sibling concern owning the public `.active` or `.published` name can no
472
+ longer make `expire_all` overwrite long-expired rows and skip live ones. (#184)
473
+ - **Batch verbs** (Publishable / Expirable / Activatable): take their per-record path on a model with a
474
+ CounterCacheable rule using `if:`, or Auditable tracking a column the verb writes; the single-`UPDATE` fast
475
+ path used to leave the counter and the audit trail behind. Plain models keep the single `UPDATE`. (#154)
476
+ - **Cacheable**: `stale_resource?` on an Array (or other non-relation collection) no longer answers 500 on Rails
477
+ 7.0+ when a member has no `updated_at` or a nil one. Only an `ActiveRecord::Relation` takes the SQL-aggregate
478
+ branch; other collections use their newest non-nil member timestamp (none: ETag only). (#132)
479
+ - **CounterCacheable**: a parent with optimistic locking whose counted child hangs off a `has_one ...,
480
+ dependent: :destroy` can be destroyed again; the child's decrement no longer bumps the parent's
481
+ `lock_version`. A has_one replacement still gets its count. (#149)
482
+ - **CursorPaginatable**: a cursor whose String boundary is not valid UTF-8, or carries a NUL byte, is the
483
+ documented `InvalidCursor` 400 instead of a 500. MySQL adapters, which store a NUL, still accept it. (#144)
484
+ - **CursorPaginatable**: paginates tables with a binary primary key or a binary ordering column; a binary
485
+ boundary travels in the cursor as strict Base64 (invalid Base64 is an `InvalidCursor` 400). `JSON.generate`
486
+ used to raise on every page with a next page. (#162)
487
+ - **CursorPaginatable**: a `time` ordering column with fractional seconds no longer loops forever on Rails
488
+ 6.0/6.1. A `time` boundary is now its stored time of day (`"10:30:15.500000"`, in `default_timezone`); cursors
489
+ minted in the old form still decode on Rails 7.0+. (#183)
490
+ - **Duplicable**: `duplicate!` re-reads `lock_version` with the counter columns, for the copy and every nested
491
+ Duplicable copy, so the returned copy of an optimistically locked record saves again. (#143)
492
+ - **Duplicable**: a top-level copy of a Sortable (acts_as_list) record takes a fresh list position, placed per
493
+ `add_new_at:`, instead of the original's. A deep-copied child keeps its position only when its list is scoped
494
+ by the copied association's foreign key; a child in a shared list is blanked and appended. `use_acts_as_list:
495
+ false` keeps the copied position. (#166)
496
+ - **Encryptable / Anonymizable / Stateable / Activatable**: composite primary keys (Rails 7.1+) work in
497
+ `reencrypt!` / `reencrypt_all!`, in `anonymize!` under optimistic locking, and in the row lock behind Stateable
498
+ `lock: true` and Activatable `toggle_active!`. (#138)
499
+ - **Encryptable**: `<field>_ciphertext`, `<field>_encrypted?`, `<field>_key_id` and `reencrypt!` read the envelope
500
+ the record loaded or wrote, also after an assignment that leaves the value unchanged. (#150)
501
+ - **Encryptable**: an in-place edit of a decrypted String (`<<`, `gsub!`, `squish!`) is a change and is saved;
502
+ dirty checks decrypt the stored value to compare plaintext, so an unchanged re-save is still not re-encrypted. (#156)
503
+ - **Encryptable**: the blind index is refreshed at the write boundary, after every `before_save` /
504
+ `before_create` / `before_update` callback, so it is the digest of the value actually stored and `find_by_<field>`
505
+ no longer misses a row whose field a later callback rewrote. (#172)
506
+ - **ErrorHandleable**: a malformed query string (bad %-escape, invalid UTF-8) renders the documented 400
507
+ `bad_request` envelope (or problem document) instead of Rails' HTML 400 page. A private `process_action` hands
508
+ that one exception to the handler chain. (#155)
509
+ - **ErrorHandleable**: `handled_error.concerns_on_rails` and `on_handled_error` report the exception the handler
510
+ was matched on (the wrapper's `#cause`), not the wrapper's class. (#193)
511
+ - **Expirable / Schedulable / Publishable**: on a `date` column the instance predicates (`current?`,
512
+ `upcoming?`, `expired?`, `active_at?`, `overlaps?`, `active?`, `published?`, `scheduled?`) agree with their
513
+ scopes outside UTC, comparing against the date in `Time.zone`. Datetime columns are untouched. (#140)
514
+ - **Filterable**: compares an `enum` column by label (`?status_gte=review` is `status >= 1`); on an integer-backed
515
+ enum any other operand is read as a number. Every label comparison used to come back empty, and a numeric
516
+ operand raised `ArgumentError` on Rails 6.0/6.1. An unknown label matches nothing. (#163)
517
+ - **Filterable**: an unknown `enum` label in equality or `in` (`?status=drfat`) matches nothing instead of the
518
+ rows with no status (Rails 7.0+ emitted `IS NULL`). An explicit `nil` member keeps its `IS NULL` meaning. (#168)
519
+ - **Filterable**: an operand containing a NUL byte matches nothing instead of a `StatementInvalid` 500 (SQLite,
520
+ PostgreSQL). Binary columns are exempt. (#152)
521
+ - **Filterable / Sortable** (controller): `filtered` and `sorted` accept a bare model class (`filtered(Article)`),
522
+ as Paginatable and CursorPaginatable already did; a class used to raise `NoMethodError`. (#164)
523
+ - **Localizable**: the `Accept-Language` lookup truncates each tag one subtag at a time (RFC 4647 section 3.4), so
524
+ `zh-Hant-TW` matches an available `zh-Hant` before `zh`, and `sr-Latn-RS` matches `:sr_Latn` before `:sr`. (#167)
525
+ - **Maskable / Sanitizable**: `masked_attributes` / `sanitized_attributes` skip a declared field that a partial
526
+ `select` did not load instead of raising `ActiveModel::MissingAttributeError`. (#178)
527
+ - **Monetizable**: no longer reads a leading `0` digit group as grouped thousands: `"0,500"` in a default
528
+ `","` field is `nil` rather than 500.00, and `"0.500"` reads as 0.5 in a `delimiter: "."` field. Formatted
529
+ output still round-trips. (#179)
530
+ - **Normalizable / Sanitizable / Taggable / Addressable**: a rule no longer runs twice when `valid?` precedes
531
+ `save` (a non-idempotent Proc runs once per save, also when Normalizable and a Sanitizable `on: :write` rule share
532
+ a column), and `reload` forgets the earlier validation. The `before_save` backstops (saves that skip validation)
533
+ run in the same order as the `before_validation` passes through one shared prepended dispatcher, so two
534
+ transforms on one column store the same value on every save path. (#176)
535
+ - **Normalizable**: `:url` canonicalizes a protocol-relative `//HOST/path` to `https://host/path`; a host-less
536
+ `/path` comes back stripped rather than as `https:///path`. (#177)
537
+ - **Paginatable**: counts a relation whose select list is written with DISTINCT (`select("DISTINCT category")`,
538
+ `DISTINCT ON (...)`) as its distinct rows, fixing `X-Total-Count` / `X-Total-Pages` and `Link rel="last"`. (#161)
539
+ - **Publishable**: `unpublish!` and `unpublish_all` write `false` into a boolean publish column instead of
540
+ `NULL`, which raised `NotNullViolation` on `null: false` columns. Timestamp columns are still cleared. (#139)
541
+ - **Respondable**: a `Vary` passed in `render_success` / `render_created`'s `headers:` is appended to the existing
542
+ `Vary` (de-duplicated case-insensitively, `Vary: *` left alone) instead of replacing it, which dropped
543
+ Localizable's `Accept-Language` and Timezoneable's `Time-Zone`. A caller's `Vary: *` still replaces. (#160)
544
+ - **Sanitizable**: no longer raises `NameError: uninitialized constant Rails::HTML4` on rails-html-sanitizer
545
+ older than 1.6. The sanitizer namespace is feature-detected: HTML5 when available, else HTML4, else the gem's
546
+ `Rails::Html` sanitizers. (#133)
547
+ - **SecureHeadable**: `content_security_policy_for(report_only: false)` enforces over a parent controller's or
548
+ `config.content_security_policy_report_only`'s report-only flag. `report_only:` is now `true` / `false` /
549
+ `:inherit`; the default `:inherit` leaves the inherited flag as before, and anything else raises
550
+ `ArgumentError`. (#137)
551
+ - **Sequenceable / Duplicable**: a duplicated record is numbered in its own `reset:` period (the copy used to
552
+ inherit the original's memoized "now"); Duplicable blanks a custom `period_column:`. (#191)
553
+ - **Sequenceable / Tokenizable / Hashable / Sortable**: a number, token, code or list position generated on create
554
+ no longer survives a failed INSERT; a create that rolls back puts the generated columns back, so
555
+ `UniqueRetry.with_retries { record.save! }` draws a fresh value. Values you supplied are kept. (#190)
556
+ - **Sluggable**: an explicitly assigned blank slug (`""` or `nil`) is built from the source in the same save
557
+ instead of stored as is; a non-blank assigned slug still wins. (#131)
558
+ - **Sluggable**: `scope: [:account_id, :locale]` (friendly_id's multi-column scope) no longer raises
559
+ `NoMethodError` at class load; every scope column is validated and slugs are unique per combination. (#157)
560
+ - **SoftDeletable**: `really_delete!` marks the instance destroyed (`destroyed?` true, `persisted?` false)
561
+ before freezing it. (#134)
562
+ - **SoftDeletable / Publishable**: merging a soft-deletable model's relation into another model's no longer strips
563
+ the OUTER model's own `deleted_at IS NULL`. The default scope records no `unscope`, and the named scopes unscope
564
+ the column on their own table only (Rails 6.1+; on 6.0 a merged named scope still leaks). (#186)
565
+ - **Stateable**: `transition_all` on a `lock: true` model checks each guard under its row lock, so a row another
566
+ process moved is skipped instead of aborting the whole batch with `InvalidTransition`. (#147)
567
+ - **Stateable**: a base-class `transition_all` on an STI table judges each record by its own class's declaration:
568
+ subclass rows without the event are skipped (no `NoMethodError`) and rows a subclass's wider `from:` accepts
569
+ are transitioned. New public reader `stateable_transition_config(event)`. (#153)
570
+ - **Storable**: a key that declares `type:` reads its `default:` back cast through that type (`default: "false"`
571
+ on a `:boolean` key used to read the truthy String). An uncastable default reads `nil`; `:json` defaults and
572
+ untyped keys return the default as written. (#130)
573
+ - **Timezoneable**: `Time.zone` is restored after every action, also when the resolved zone is already the
574
+ current one; a `Time.zone = ...` made in the action no longer leaks to the next request. (#192)
575
+ - **Tokenizable**: `authenticate_by_<field>` / `consume_<field>` answer `nil` for a value that is not a String or
576
+ Integer (a Hash from `?token[a]=b`, an Array) instead of raising `TypeError`. (#142)
577
+
578
+ ### Internal
579
+ - Dev lock: permittable bumped to 0.11. (#159)
580
+ - New `Support::TransformBackstop`: one shared prepended `before_save` dispatcher for Normalizable, Sanitizable,
581
+ Taggable and Addressable. (#176)
582
+ - New `Support::ScalarParam.nul_free?` (NUL-byte operand check used by Filterable). (#152)
583
+
3
584
  ## 1.32.1 (2026-10-08)
4
585
 
5
586
  A bug-fix release from a six-angle audit of 1.32.0 — data and encryption, state and counters,