concerns_on_rails 1.28.7 → 1.28.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b713f3fd8cd14e5f3a12db7b3c390111118df1fa143d5d833dee009544a8a64
4
- data.tar.gz: 16f6d5447ac1d44c5be3dc1cdb0113d9a9e64457de14b87d4199bb95993189d8
3
+ metadata.gz: f292a3f1cf7d1fae3b1e47be9daa416ae0b6c8b69ce0d4fd2efd583496f2d9aa
4
+ data.tar.gz: 661ffce3f3fa4fb305957b239fbb560752ea22f369da3268d03575efb35701da
5
5
  SHA512:
6
- metadata.gz: 7afa091b97fe9359b7294f965593af629d5d66d797e77a7c3ebde396eddc7fcdf627937fae2c90c2ee0c034c23e91f3713ac3827ab201ee8edda0438094ad89c
7
- data.tar.gz: 683d74ea9d1b258e1c79bcdcd7243bcbcd22b10ba5c862946267e5baab118c3753c69baf7af496436a7424286cbd33be9f5e6bfba889928f6f212b190d9ed3ca
6
+ metadata.gz: a49931859e637327e55313e7473236a4ceca6a5f61c01f7f701a7d7acba317c8fe6a83ec05ec2dafa5d7e61da45d8d2623a26f15a876d9e7d12036cd9c40c451
7
+ data.tar.gz: 625395f36729c2d789b303cfeec6537dfa173364b70c0a6c971707616a0fb3161d5eef083417aadcc9586a7b99c89d9a7617355e08284cdb76d210a62a3df82f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,185 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.9 (2026-09-19)
4
+
5
+ CI now runs the matrix the gemspec actually claims: Rails 7.0–8.0 across Ruby 3.2/3.3/3.4,
6
+ plus **PostgreSQL and MySQL** alongside SQLite. The suite had only ever run on SQLite, which
7
+ is the most permissive of the three, and the first green-on-SQLite run of the new matrix
8
+ failed 29 examples on MySQL and 7 on PostgreSQL.
9
+
10
+ Almost all of those were specs asserting SQLite's own SQL rather than the gem misbehaving —
11
+ but one was a real bug, and it is the reason this is a release and not just a CI change.
12
+
13
+ ### Fixed
14
+ - **Models::Taggable**: `tagged_with` was broken on MySQL and inconsistent on PostgreSQL.
15
+ - On **MySQL** every `tagged_with` query was a **syntax error**. The clause inlined
16
+ `ESCAPE '\'` into hand-written SQL, and MySQL treats backslash as an escape character
17
+ inside string literals, so the escape consumed its own closing quote. SQLite and
18
+ PostgreSQL (with `standard_conforming_strings`) read it as a literal backslash, which is
19
+ why this was invisible for so long. The clause is now built with Arel `matches`, so the
20
+ adapter quotes the escape character itself — one code path, no per-adapter branching.
21
+ - On **PostgreSQL** `tagged_with` was case-SENSITIVE, while on SQLite and MySQL it folded
22
+ case: the same query returned different rows depending on the database. It is now
23
+ case-insensitive on all three (`ILIKE` on PostgreSQL).
24
+
25
+ ⚠️ **PostgreSQL users: this widens `tagged_with`.** A lookup that was case-sensitive before
26
+ now also matches differently-cased tags. That is the documented intent of the concern and
27
+ it makes the three adapters agree, but it is a behaviour change on upgrade. If you relied
28
+ on case-sensitive matching, declare `taggable_by :tags, downcase: true` and fold on write.
29
+ SQLite and MySQL users see no change. Note the Ruby-side helpers (`tagged_with?`,
30
+ `all_tags`, `tag_counts`) still compare exactly — `downcase: true` is what makes the scope
31
+ and the helpers agree.
32
+
33
+ ### Internal
34
+ - **CI**: `.github/workflows/ci.yml` gains a Rails-version axis (7.0, 7.1, 7.2, 8.0 × Ruby
35
+ 3.2/3.3/3.4, via `gemfiles/*.gemfile`) and an adapter axis (PostgreSQL and MySQL service
36
+ containers). `DB=postgresql` / `DB=mysql2` selects the adapter locally; SQLite stays the
37
+ default so a plain checkout still needs no services. (#102)
38
+ - **Specs**: examples that asserted SQL now build the expected fragment from the
39
+ connection's own quoting, via new `TestDatabase.quoted_table` / `quoted_column` /
40
+ `qualified` helpers and `sqlite?` / `postgresql?` / `mysql?` predicates — so an assertion
41
+ means the same thing on every adapter instead of encoding SQLite's. Several examples got
42
+ *stronger* in the process: the Sortable NULL-ordering pair now asserts the PostgreSQL
43
+ native-`NULLS` branch and the portable-`CASE` branch separately, each also asserting the
44
+ other is absent.
45
+ - Three examples depended on SQLite-only tolerance rather than on the gem: a grouped
46
+ relation selecting `*` (rejected by PostgreSQL, and by MySQL under `only_full_group_by`),
47
+ a non-finite Float written to a float column (SQLite's adapter overrides `quote` for
48
+ those; MySQL emits a bare `NaN`), and a NULL page-boundary fixture that assumed SQLite's
49
+ NULLs-first ordering (PostgreSQL sorts NULLs last ascending).
50
+
51
+ ### Known gap
52
+ - `Models::Storable` guards its JSON extraction with `json_valid` on SQLite only. MySQL has
53
+ `JSON_VALID()` (5.7.8+) and could have the same guard; it was left alone deliberately
54
+ rather than shipped unverified, since a wrong guess there would break every
55
+ `where_<key>` query on that adapter. PostgreSQL has no equivalent before PG 16's
56
+ `IS JSON`. Now that the MySQL leg runs, this is a small follow-up.
57
+
58
+ ## 1.28.8 (2026-09-19)
59
+
60
+ The last six open feature PRs, released as a patch by request. These had never been
61
+ reviewed — they were excluded from the previous two waves because they would not merge —
62
+ so each was rebased onto master, reviewed adversarially, and fixed. **Five of the six
63
+ carried a CRITICAL defect**, two of which could destroy data. The `### Fixed` section is
64
+ the important one; several entries describe behaviour that never worked as documented.
65
+
66
+ Also fixes a live defect in `Support::ErrorEnvelope` found during the review, which
67
+ affected eight concerns on 1.28.7 and earlier. 1828 examples, 0 failures.
68
+
69
+ ### Added
70
+ - **Models::Encryptable**: key rotation. `ConcernsOnRails.configure_encryption { |c|
71
+ c.key_id = 1; c.previous_keys = { 0 => old } }` — new writes stamp `key_id`, reads
72
+ decrypt with whichever configured key the envelope names (unknown id → `DecryptionError`
73
+ naming it), and blind-index lookups match current + previous digests.
74
+ `Model.needs_reencryption(*fields)`, `Model.reencrypt_all!(*fields)` /
75
+ `record.reencrypt!`, and `<field>_key_id`. Per-field `key:` fields sit outside rotation.
76
+ The envelope format is unchanged: 1.28.x already wrote and authenticated a `key_id` byte,
77
+ so every existing row is a key-id-0 envelope and decrypts untouched after upgrading. (#77)
78
+ - **Models::Lockable**: `lockable_by … unlock_token:` mints a URL-safe token in the same
79
+ write as the lock, for self-service unlock links without Devise.
80
+ `User.unlock_by_token(token)` consumes it once — constant-time compare, one conditional
81
+ UPDATE decides the winner, hooks fire — and the token lives exactly as long as the lock.
82
+ Cleared on every unlock path, reset by Duplicable, and registered with the
83
+ filter_parameters registry. (#61)
84
+ - **Models::Stateable**: `stateable_by … timestamps: true` (or a list of states) stamps
85
+ `<state>_at` in the same write as the state change, for guarded events, direct setters,
86
+ `transition_to!` and `transition_all`. Per-event `before_<event>` / `after_<event>` hooks
87
+ fire inside the generic `before_transition` / `after_transition` pair and the same
88
+ transaction, and may be declared `private`. (#49)
89
+ - **Controllers::Filterable**: `filter_by … operators: true` (or a subset) adds `not`,
90
+ `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `null`, `contains` and `starts_with` to
91
+ direct-where filters, read as a suffix (`?price_gte=10`) or in bracket form
92
+ (`?price[gte]=10`). Every operator comes from a frozen allow-list mapped to a fixed Arel
93
+ node, so no param string reaches SQL. Values are cast through the column's type or the
94
+ new `type:` option, which also pre-casts what a `with:` lambda receives. Opt-in per
95
+ filter; existing filters are unchanged. (#46)
96
+ - **Controllers::WebhookVerifiable**: `verify_webhook … replay:` / `replay_ttl:` rejects a
97
+ redelivery with 409 `webhook_replayed` — replay protection for the schemes that carry no
98
+ timestamp (GitHub, Shopify, plain HMAC). The check runs only after the signature
99
+ verifies. A short claim is taken before the action and promoted on completion, and
100
+ released on a 5xx so a transient failure does not burn the delivery id. (#60)
101
+ - **Controllers::Respondable**: `render_success` accepts `location:` and `headers:`, and
102
+ gains `render_created(data:, location:)` (201 + `Location`) and `render_invalid(record)`
103
+ (422 + the record's `full_messages`). Header names and values are stripped of bytes a
104
+ header line cannot carry, and a `Link` value is appended to an existing one — matched
105
+ case-insensitively — rather than replacing it. Works in both the envelope and the
106
+ problem-details format. (#105, replacing #74)
107
+
108
+ ### Changed
109
+ - **Controllers::Filterable**: an uncastable comparison value (`?price_gte=abc`) returns
110
+ `none` rather than matching. Fail-closed was chosen because ignoring the filter would
111
+ return the very rows the client tried to exclude; it matches what plain `where` already
112
+ does with the same input, and is scoped to the four new comparison operators so no
113
+ pre-existing filter changes on upgrade. `contains` / `starts_with` are case-INsensitive
114
+ on every adapter — there is no `case_sensitive:` option. (#46)
115
+ - **Models::Stateable**: `timestamps:` refuses to derive a stamp column from a state named
116
+ `created` or `updated`, which would otherwise target Rails' own `created_at`/`updated_at`.
117
+ Stamp columns are NOT affixed by `prefix:`/`suffix:`, so a state named `published` or
118
+ `deleted` collides with Publishable's and SoftDeletable's columns — rename the state or
119
+ leave it out of `timestamps:`. (#49)
120
+ - **Models::Encryptable**: `record.reencrypt!` rewrites through a guarded `UPDATE` that
121
+ matches the ciphertext read at load, then reloads. A row written by someone else in the
122
+ meantime is left alone instead of being reverted, and a field with unsaved changes is
123
+ skipped rather than silently committed. (#77)
124
+
125
+ ### Fixed
126
+ - **Support::ErrorEnvelope**: the `errors:` keyword was passed to a host app's
127
+ `render_error` whenever details were present, but several concerns document the override
128
+ contract as `render_error(message:, status:, code:)`. Such an app got `ArgumentError:
129
+ unknown keyword: :errors` at request time — a 500 instead of the 4xx, on exactly the path
130
+ that has something to report. The previous guard tested `details` alone, so it covered
131
+ only the empty case, i.e. the one that was never broken. This reached every concern that
132
+ funnels through the envelope, most commonly ErrorHandleable's rescued `RecordInvalid`.
133
+ - **Models::Encryptable**: `previous_keys=` printed the offending hash's keys on a bad
134
+ shape, so an inverted `{ ENV["KEY_V1"] => 0 }` raised **with the live key in the exception
135
+ message** — into logs, backtraces and error trackers. Only the value's class is reported
136
+ now. (#77)
137
+ - **Models::Encryptable**: `needs_reencryption` detected MySQL by adapter name, so Trilogy
138
+ (Rails 7.1+) and MariaDB fell through to a case-folding comparison and returned **zero
139
+ rows**. `reencrypt_all!` then reported 0, an operator would conclude the rotation was
140
+ complete and drop the old key, and every pre-rotation row would become permanently
141
+ undecryptable. (#77)
142
+ - **Models::Lockable**: a `before_unlock` / `after_unlock` hook that vetoed the unlock
143
+ permanently burned the token while leaving the account locked — the claim ran in a bare
144
+ transaction that joined the enclosing one, so `unlock_access!`'s savepoint rolled back
145
+ while the claim committed. The user's emailed link was dead with no way back. The claim
146
+ now takes its own savepoint and is rolled back with the unlock. (#61)
147
+ - **Models::Lockable**: `unlock_by_token` no longer honours a token once the lock has
148
+ lapsed, and no longer uses `unscoped` — a mailed link could otherwise reach a row hidden
149
+ behind a `default_scope` (a soft-deleted or deactivated account). (#61)
150
+ - **Controllers::WebhookVerifiable**: a store answering `#write` but not `#read` passed
151
+ declaration-time validation and then provided **no replay protection at all**, silently —
152
+ the endpoint looked protected and accepted every redelivery. Both methods are now
153
+ required. (#60)
154
+ - **Controllers::WebhookVerifiable**: the Stripe replay key hashed the raw
155
+ `Stripe-Signature` header, which is *parsed* rather than compared — appending an unknown
156
+ `v0=…` pair or re-spacing the commas produced a still-valid header with a fresh key,
157
+ defeating the protection. Stripe now keys off the signed `"<timestamp>.<body>"` payload.
158
+ (#60)
159
+ - **Controllers::WebhookVerifiable**: `replay: false` raised at class load, so
160
+ `replay: Rails.env.production?` broke controller loading in development and test. It is
161
+ now a synonym for "off". (#60)
162
+ - **Controllers::Respondable**: caller-supplied header **names** reached the response
163
+ unsanitized, so a CR/LF in an interpolated name was response splitting. Names now go
164
+ through the same filter as values, widened from CR/LF to the full illegal-byte range.
165
+ (#105)
166
+ - **Controllers::Filterable**: a bracket-form operator with a blank value
167
+ (`?price[gte]=`) became `price > NULL` and returned **nothing**, while the documented
168
+ equivalent `?price_gte=` correctly returned everything — an empty min/max box silently
169
+ emptied the result set. Both forms now skip blanks, and both honour a boolean `false`.
170
+ (#46)
171
+ - **Controllers::Filterable**: `?stock_gt=twelve` silently became `stock > 0` and returned
172
+ the stocked rows (`ActiveModel::Type::Integer#cast("twelve")` is `0`, not `nil`), while
173
+ the same typo on a datetime column returned none — one malformed request answered two
174
+ opposite ways. (#46)
175
+ - **Controllers::Filterable**: LIKE escaping no longer relies on `sanitize_sql_like`, which
176
+ is not public before Rails 5.1 while the gemspec claims `>= 5.0`. (#46)
177
+
178
+ ### Internal
179
+ - Docs corrected: "Never `update_column(s)` an encrypted field — those bypass the type and
180
+ write raw plaintext" was **false** (`update_columns` serializes through the attribute
181
+ type), and `reencrypt_all!` is built on it. (#77)
182
+
3
183
  ## 1.28.7 (2026-09-19)
4
184
 
5
185
  The eight PRs held back from 1.28.6, released as a patch by request. Each carried a
data/README.md CHANGED
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,730 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,830 RSpec examples** run against a real database on every CI build
152
152
  - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
153
153
 
154
154
  ---
@@ -1035,6 +1035,26 @@ guard rejects are skipped (not errors). Unlike every other batch verb in this ge
1035
1035
  the guarded `<event>!` method, because that path runs validations via `update!` while a bulk
1036
1036
  `update_all` would silently skip them.
1037
1037
 
1038
+ **Timestamps and per-event hooks**
1039
+
1040
+ ```ruby
1041
+ stateable_by :status, states: %i[draft review published archived], default: :draft,
1042
+ timestamps: true, # or %i[published archived] — stamps <state>_at
1043
+ transitions: { publish: { from: %i[draft review], to: :published }, archive: { to: :archived } }
1044
+
1045
+ article.publish! # status = "published" AND published_at = Time.current, in ONE update!
1046
+ article.archived! # direct setters and transition_to! stamp too; the default state on create does not
1047
+
1048
+ def before_publish = check_embargo! # per-event hooks, fired inside the generic pair and the
1049
+ def after_publish = notify_subscribers # same transaction: before_transition → before_publish →
1050
+ # write → after_publish → after_transition
1051
+ ```
1052
+
1053
+ `timestamps:` requires the `<state>_at` columns (checked at class load, one typed migration hint); the column is
1054
+ **not** affixed, and one Rails owns (`created_at` / `updated_at`) is refused. Per-event hooks follow the affixed
1055
+ event name (`before_status_publish` with `prefix: true`), may be private, and — like the generic hooks — fire only
1056
+ for guarded `<event>!` transitions. `Model.stateable_timestamps` lists the stamped states.
1057
+
1038
1058
  **Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
1039
1059
 
1040
1060
  ```ruby
@@ -1180,6 +1200,7 @@ Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7
1180
1200
 
1181
1201
  **Notes**
1182
1202
  - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
1203
+ - `tagged_with` matches **case-insensitively on every adapter** — `LIKE` on SQLite and MySQL, `ILIKE` on PostgreSQL — so one call means one thing everywhere (how non-ASCII characters fold is still the database collation's business). The Ruby-side helpers (`tagged_with?`, `all_tags`, `tag_counts`) compare exactly, so `downcase: true` — which folds on write — is what makes the scope and the helpers agree.
1183
1204
  - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
1184
1205
  - `tag_counts` runs one `GROUP BY` on the raw column — identical tag strings ship once with their row count and are split in Ruby — so it scales with distinct tag strings, not rows; ordered by count desc then name, `limit:` keeps the top N.
1185
1206
  - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, or polymorphic tags shared across models.
@@ -1360,7 +1381,7 @@ One entry is recorded **per changed field per save** (creates record `"from" =>
1360
1381
 
1361
1382
  ## 🔐 Lockable
1362
1383
 
1363
- Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table; no tokens, no mailers.
1384
+ Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table (plus an optional unlock-token column for self-service unlock links); no mailers.
1364
1385
 
1365
1386
  ```ruby
1366
1387
  class User < ApplicationRecord
@@ -1380,9 +1401,14 @@ user.unlock_access! # manual unlock (hooks: before/after_unlock)
1380
1401
  User.locked / User.unlocked # expiry-aware scopes
1381
1402
 
1382
1403
  User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
1404
+
1405
+ # Self-service unlock (Devise's :email strategy, minus the mailer) — needs a string column:
1406
+ # lockable_by max_attempts: 5, unlock_token: :unlock_token
1407
+ user.lock_access!; user.unlock_token # minted in the same write as the lock — mail it as a link
1408
+ User.unlock_by_token(params[:token]) # constant-time lookup; unlocks once (hooks fire), returns the user or nil
1383
1409
  ```
1384
1410
 
1385
- **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
1411
+ **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `unlock_token:` (`nil`; a string column that receives a 43-char URL-safe token on lock, is cleared by every unlock path, and is honoured only while the lock is live — so `unlock_in:` doubles as the link's TTL), `prefix:` / `suffix:` (affix the scope names).
1386
1412
 
1387
1413
  **Bulk operations**
1388
1414
 
@@ -1532,13 +1558,31 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1532
1558
 
1533
1559
  **Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`), `key:` (per-field override; a String or lazy Proc), `blind_index:` (`true`, or `{ column:, expression: }` — maintains a deterministic keyed-HMAC companion column, default `<field>_bidx`, for equality lookups; `expression:` normalizes symmetrically on write and query).
1534
1560
 
1561
+ **Key rotation** — bump the key id, keep the old key for decrypting, re-encrypt, drop the old key:
1562
+
1563
+ ```ruby
1564
+ ConcernsOnRails.configure_encryption do |c|
1565
+ c.key = ENV["ENCRYPTION_KEY_V2"] # encrypts every new write
1566
+ c.key_id = 1 # stamped into the envelope header (any id 0..255)
1567
+ c.previous_keys = { 0 => ENV["ENCRYPTION_KEY_V1"] } # still DECRYPTS rows written before the rotation
1568
+ end
1569
+
1570
+ Patient.needs_reencryption.count # rows still under an old key — a prefix compare on the envelope, no decryption
1571
+ Patient.reencrypt_all! # rewrite them (and their blind indexes) under the current key → count
1572
+ patient.ssn_key_id # => 1
1573
+ # then remove `0 =>` from previous_keys
1574
+ ```
1575
+
1576
+ Reads pick the key by the envelope's id, so old and new rows coexist; `find_by_<field>` / `where_<field>` match blind-index digests under the current **and** previous keys during the window. Per-field `key:` fields sit outside rotation.
1577
+
1535
1578
  **Notes**
1536
1579
  - The declared column must be `text`/binary (it stores an opaque envelope, not the logical type); a blind-index column holds a 64-char hex digest — add an index on it.
1537
1580
  - Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
1538
1581
  - `ssn_ciphertext` is `nil` while the field has an unsaved change (so it can never return the plaintext you just assigned), and `ssn_encrypted?` asks whether what is stored really is an envelope.
1539
- - Never `update_column(s)` an encrypted field that bypasses the type and writes raw plaintext. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
1582
+ - `update_column(s)` on an encrypted field DOES encrypt (the value still serializes through the attribute type), but it skips validations, callbacks, dirty tracking and the blind-index refresh — so a value written that way is unsearchable until the row is saved normally. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
1540
1583
  - Wrong key / tampered ciphertext / malformed envelope raise `Encryption::DecryptionError`. Encrypted field names are auto-registered with Rails' `filter_parameters` (via the gem's railtie), so they're redacted from request logs.
1541
- - Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need key rotation today or Rails-managed key infrastructure (rotation is planned the envelope already reserves the `key_id` byte).
1584
+ - Rotation is gem-level (`key_id` / `previous_keys`); `reencrypt_all!` streams with `find_each` and rewrites each row with one UPDATE no validations/callbacks (only the ciphertext changes), guarded on the ciphertext it read so a concurrent write is never reverted, and skipping any field with an unsaved change. A row whose key id is no longer configured raises `DecryptionError` naming the id.
1585
+ - Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need Rails-managed key infrastructure (KMS, per-record keys) or deterministic encryption.
1542
1586
 
1543
1587
  ---
1544
1588
 
@@ -1753,6 +1797,8 @@ class ArticlesController < ApplicationController
1753
1797
  filter_by :status, :category # ?status=draft → .where(status: 'draft')
1754
1798
  filter_by :published, scope: :published # ?published=1 → Article.published
1755
1799
  filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }
1800
+ filter_by :price, :created_at, operators: true # ?price_gte=10&created_at_lt=2026-01-01
1801
+ filter_by :min_stock, type: :integer, with: ->(rel, v) { rel.where(rel.model.arel_table[:stock].gteq(v)) }
1756
1802
 
1757
1803
  def index
1758
1804
  render json: filtered(Article.all)
@@ -1760,6 +1806,24 @@ class ArticlesController < ApplicationController
1760
1806
  end
1761
1807
  ```
1762
1808
 
1809
+ **Operators** (opt-in per filter, direct-where mode only — `operators: true` or a subset like `%i[gte lte]`),
1810
+ accepted as a suffix `?price_gte=10` or in bracket form `?price[gte]=10&price[lte]=50`:
1811
+
1812
+ | Operator | Param | SQL |
1813
+ |----------|------------------------------------|---------------------------------------|
1814
+ | `not` | `?status_not=draft` | `status != 'draft'` |
1815
+ | `gt` `gte` `lt` `lte` | `?price_gte=10` | `price >= 10` (cast through the column type) |
1816
+ | `in` `not_in` | `?status_in=a,b` or `?status_in[]=a` | `status IN ('a','b')` |
1817
+ | `null` | `?deleted_at_null=true` | `deleted_at IS NULL` (`false` → `IS NOT NULL`) |
1818
+ | `contains` `starts_with` | `?title_contains=rails` | `title LIKE '%rails%'` (wildcards escaped; ILIKE on PostgreSQL) |
1819
+
1820
+ Comparison values are cast the way ActiveRecord casts them (the column's own type), or through `type:`
1821
+ (any ActiveModel type name); `type:` also pre-casts the value handed to a `with:` lambda. Blank values
1822
+ are skipped and unknown operators / non-scalar values ignored. A `gt`/`gte`/`lt`/`lte` value the type
1823
+ cannot represent (`?price_gte=abc`) matches **nothing** rather than silently comparing against `0` —
1824
+ nothing raises at request time. `contains`/`starts_with` are case-insensitive on PostgreSQL, MySQL and
1825
+ SQLite alike. For strict, validated contracts reach for `Permittable`.
1826
+
1763
1827
  **Modes**
1764
1828
 
1765
1829
  | Mode | Declaration | What it does |
@@ -1770,7 +1834,7 @@ end
1770
1834
 
1771
1835
  **Notes**
1772
1836
  - Blank params are skipped — unset filters don't narrow the relation.
1773
- - Passing both `:scope` and `:with` raises `ArgumentError`.
1837
+ - Passing both `:scope` and `:with` raises `ArgumentError`; so do `operators:` on a `scope:`/`with:` filter, an unknown operator name, or an unknown `type:` — all at class load.
1774
1838
  - Scope mode pairs naturally with `Publishable.published`, `SoftDeletable.active`, `Expirable.active`, etc.
1775
1839
 
1776
1840
  ---
@@ -1825,9 +1889,9 @@ class Api::ArticlesController < ApplicationController
1825
1889
  def create
1826
1890
  article = Article.new(article_params)
1827
1891
  if article.save
1828
- render_success(data: article, status: :created)
1892
+ render_created(data: article, location: article_url(article)) # 201 + Location
1829
1893
  else
1830
- render_error(message: "Invalid", errors: article.errors.full_messages)
1894
+ render_invalid(article) # 422 record_invalid + full_messages
1831
1895
  end
1832
1896
  end
1833
1897
  end
@@ -1861,7 +1925,9 @@ respondable_by error_format: :problem_details, problem_type_base: "https://api.e
1861
1925
 
1862
1926
  | Method | Signature |
1863
1927
  |-------------------|--------------------------------------------------------------------------------------------|
1864
- | `render_success` | `render_success(data: nil, status: :ok, meta: {})` |
1928
+ | `render_success` | `render_success(data: nil, status: :ok, meta: {}, location: nil, headers: {})` — `location:` sets the `Location` header (a String, or anything `url_for` resolves); `headers:` sets extra response headers (names and values are coerced to String and stripped of CR/LF, so caller data cannot split the response) |
1929
+ | `render_created` | `render_created(data: nil, location: nil, meta: {}, headers: {})` — `render_success` with `status: :created` |
1930
+ | `render_invalid` | `render_invalid(record_or_errors, message: "Validation failed", status: :unprocessable_entity, code: "record_invalid")` — `render_error` with `errors.full_messages` as `details` (omitted when empty, and when an app's `render_error` override cannot take an `errors:` keyword); same shape as ErrorHandleable's `RecordInvalid` handler, problem-details aware |
1865
1931
  | `render_error` | `render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)` |
1866
1932
  | `respondable_by` | `respondable_by(error_format: :envelope, problem_type_base: nil)` — class-level; `error_format:` is `:envelope` (default) or `:problem_details` |
1867
1933
 
@@ -2229,12 +2295,13 @@ end
2229
2295
  | `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
2230
2296
  | `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
2231
2297
 
2232
- **Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only).
2298
+ **Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only), `replay:` (`true` = the gem-wide `cache_store`, a store object, or `false`/`nil` for off) + `replay_ttl:` (`24.hours`) — replay protection for the schemes that carry no timestamp.
2233
2299
 
2234
2300
  **Notes**
2235
2301
  - Comparison is constant-time and the attacker-controlled header is **never decoded** — garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
2236
2302
  - A secret that resolves **blank at request time raises `ArgumentError`** — a misconfigured endpoint should page you, not 401 into the provider's silent retry loop.
2237
- - Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` 401; `webhook_signature_malformed` (unparseable Stripe header) 400. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
2303
+ - **Replay protection** (`replay:`): GitHub, Shopify and plain-HMAC signatures carry no timestamp, so a captured delivery verifies forever. After a signature verifies, a SHA256 of what identifies the delivery — the signature header, or for Stripe the signed `"#{t}.#{body}"` payload, since unknown `v0=` keys and stray whitespace let a captured Stripe header be mutated without invalidating it — is written to the store with `unless_exist:` (atomic — memcached `add` / Redis `SET NX` via `Rails.cache`); a second delivery with the same signature within `replay_ttl:` is rejected with **409 `webhook_replayed`**. Forged traffic never consumes a slot; the key is scoped per controller action. The store must answer `#write(key, value, expires_in:, unless_exist:)` and `#read(key)` (`#delete(key)` is optional but recommended) — `Rails.cache` does. The marker is a short 60-second claim until the action finishes, then promoted to `replay_ttl:` so a handler that 500s releases it at once, and one that raises (or a filter that halts after verification) leaves only the 60-second claim to expire, either way the provider's retry (identical body, identical signature) gets through. If the store is unreachable the check fails **open**: Rails' Redis and memcached stores return false from `#write` on a connection error, and rejecting every inbound delivery during a cache blip would be worse than accepting a rare duplicate. A per-process `MemoryStore` gives no protection across workers. Stripe's own retries re-sign with a new `t=`, so they pass; a manual GitHub redelivery has the identical signature and is treated as a replay — override `webhook_verification_failed` if you'd rather answer 200.
2304
+ - Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` → 401; `webhook_signature_malformed` (unparseable Stripe header) → 400; `webhook_replayed` → 409. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
2238
2305
  - Declare **before** `Idempotentable` (a 401 cached by its around filter would be replayed) and before `Throttleable` (forged traffic shouldn't burn rate budget). Webhook endpoints also need `skip_before_action :verify_authenticity_token`.
2239
2306
  - In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
2240
2307
 
@@ -2401,12 +2468,12 @@ Both forms reference the same module, so you can freely mix them.
2401
2468
 
2402
2469
  | Need | Use instead |
2403
2470
  |------|-------------|
2404
- | Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
2471
+ | Complex state machines — guard clauses, multi-state events, a full transition audit log (`Stateable` has per-event hooks and `<state>_at` stamps, but records no transition history) | [`aasm`](https://github.com/aasm/aasm) |
2405
2472
  | Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
2406
2473
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
2407
2474
  | Indexed full-text search — stemming, tsvector/GIN, typo tolerance (`Searchable` ranks LIKE matches, but never builds an index) | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
2408
2475
  | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
2409
- | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
2476
+ | Field encryption with KMS-backed / per-record keys or Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
2410
2477
  | Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
2411
2478
 
2412
2479
  `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
@@ -2436,9 +2503,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2436
2503
 
2437
2504
  ```sh
2438
2505
  bundle install # install dev dependencies
2439
- bundle exec rspec # run the test suite (1,730 examples)
2506
+ bundle exec rspec # run the test suite (1,830 examples)
2440
2507
  gem build concerns_on_rails.gemspec # build the gem
2441
- gem install ./concerns_on_rails-1.28.7.gem # install locally
2508
+ gem install ./concerns_on_rails-1.28.9.gem # install locally
2442
2509
 
2443
2510
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2444
2511
  cd docs && python3 -m http.server 8000 # → http://localhost:8000