concerns_on_rails 1.17.0 → 1.19.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5759f8cf9f86e11087369181f53f807875909047c17fe8abb19a5b6bafaae697
4
- data.tar.gz: d6e34235431b2c54a36de7126b4829b9892b05f66f0fc6b476ff561731452100
3
+ metadata.gz: 191acd17664d56588e54832b2377ede09cf836fe481a0e4c27246adae9f2eb76
4
+ data.tar.gz: 95dc3faff52552da6688961fd51e926d4eb2a041f9974f05ea77a491b698d434
5
5
  SHA512:
6
- metadata.gz: 2b101b659f6d47f0adcface39ef7069a978d8342e6499661d358af12037c12fb1b7d317cdd075ca9b5593989df2941e121ecf46e02d51c11e4f445cdf91801dd
7
- data.tar.gz: 984342a4be9bae7692c880bb2436de85cd86a3a0e8cd4ba1c167168db297bf4a0ce42a3a7025de910b7e33495d67e8352667ea302b8d47e12c1824cd7bd4ae31
6
+ metadata.gz: f472e83548c6ccd7850c3d3c9994cbb1efac4637210f07f35f69e16ca700aa7f131bed7d24737e825896aa8301cd2786dfe70aa0f563002f1bc8d199056fae28
7
+ data.tar.gz: 6c231481577b2db38df1730cb16ad72c2f06ea7bba9d9dc0730e0c8068dce94597b26b514241e1ebd6be51b91caf4ae75dbb8ea32aa11f1aef9109cd45f3d38b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.19.0 (2026-06-13)
4
+
5
+ Two new concerns, selected by a unanimous three-judge design panel over six independently-proposed candidates (typed-JSON-settings and RFC-deprecation-headers each beat conditional counter caches, deep cloning, anonymization, params contracts, feature gates, and maintenance mode on value × shippability), then hardened in review (an `ActiveSupport::TimeWithZone` passed as `deprecated_at:`/`sunset_at:` — i.e. `Time.current` — was spuriously rejected as unparseable because `Module#===` ignores TimeWithZone's `is_a?(Time)` lie; a bare `Date` written to a `:datetime` key now anchors to midnight UTC instead of the host zone via `Date#to_time`; an unknown `header_format:` now raises at declaration time instead of silently emitting the RFC form). 894 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Storable**: typed, defaulted, optionally-validated accessors over a single JSON-or-text column ("store_attribute-lite") — native `store_accessor` is untyped on every supported Rails version (a form-submitted `"true"` stays a String), has no defaults and no per-key dirty methods. `storable_by :settings, theme: { type: :string, default: "light", in: %w[light dark] }, ...` (repeatable — same column merges keys, columns independent, subclasses add keys without mutating the parent; `prefix:`/`suffix:` affix the generated names) gives a casting reader/writer, a `?` predicate (boolean keys), `_changed?`/`_was` computed per key against the column's own previous value, and `reset_<key>` (key removal → default applies again, distinct from an explicitly-written nil, which reads back as nil). Casting via `ActiveModel::Type` with JSON-safe representations: `:decimal` as a precision-safe String, `:datetime` as UTC ISO8601 with microseconds, `:date` as ISO8601, `:json` passthrough (reader returns a dup — reassign, don't mutate). The codec never uses `serialize` (sidestepping the Rails 7.1 kwarg drift entirely): a plain text column is JSON-encoded/decoded manually and tolerantly (corrupt JSON → `{}`, garbage values cast to nil, readers never raise), while native `json`/`jsonb` columns and host-app-`serialize`d columns are detected lazily and handed the Hash. Undeclared keys are preserved; string keys throughout; `in:` adds an inclusion validation on the (affixed) accessor name, nil/absent passing. Every generated name is collision-checked against methods and columns at macro time; key specs are shape- and type-validated (`ArgumentError`, ColumnGuard for the column). Whole-column dirty / last-write-wins semantics documented. Zero new runtime dependencies.
9
+ - **Controllers::Deprecatable**: standards-based API endpoint deprecation — RFC 9745 `Deprecation` (final structured-fields form `@<unix>`, or the widely-deployed pre-RFC draft literal `true` via `header_format: :legacy`), RFC 8594 `Sunset` (IMF-fixdate via `Time#httpdate`, not the classic hand-rolled ISO8601 bug), and RFC 8288 `Link` rels (`rel="deprecation"` migration docs + `rel="successor-version"`, appended to any existing Link header, never clobbered). `deprecate_actions :index, :show, deprecated_at:, sunset_at:, link:, successor:, after_sunset:, header_format:, notify:` — repeatable, inherited; no positional actions = whole-controller catch-all; the LAST matching rule wins (deliberately the reverse of Idempotentable's first-match: deprecation rules are configuration overrides, so a base-controller catch-all is naturally overridden by a later action-specific declaration) and exactly one Deprecation header is ever emitted. Times parse eagerly at declaration (`ArgumentError` on garbage; bare dates = 00:00 UTC — sunset is an instant; `sunset_at >= deprecated_at` enforced; TimeWithZone accepted). `after_sunset: :gone` (requires `sunset_at`) halts with 410 `endpoint_sunset` at/after the boundary instant — headers still ride the 410 so the cut-off self-documents — via Respondable's `render_error` when present, inline envelope otherwise; the default `:headers` never blocks. Every matching hit instruments `deprecated_endpoint.concerns_on_rails` (`ActiveSupport::Notifications`) and `instance_exec`s `notify:` (raising notify propagates — broken metrics should be loud) through the `on_deprecated_access(rule)` override point, so teams can measure stragglers before flipping enforcement. `deprecation_active?`/`sunset_passed?` predicates for serializers; one UTC clock seam (`deprecation_now`). Zero new runtime dependencies.
10
+
11
+ ## 1.18.0 (2026-06-12)
12
+
13
+ Two new concerns, designed and hardened through adversarial design- and code-review rounds (a shared-reflection registration strategy that produced invalid SQL was caught and replaced before implementation; a time-serialization path that silently lost sub-second precision likewise; a NULL page-boundary value that would have silently dropped rows now raises; a post-review pass fixed `has_many :through` aliasing, which crashed at macro time under lazy class loading and re-derived its `source:` from the alias name at query time). A follow-up enhancement round added bidirectional cursors, allow-listed order presets, and row-value predicates to CursorPaginatable, and `only:`/`except:`/`deprecated:`/`alias_foreign_key:` to Aliasable. 793 examples, 0 failures.
14
+
15
+ ### Added
16
+ - **Models::Aliasable**: full association aliasing — `alias_association :writer, :author` (argument order mirrors `alias_method new, old`) gives read, write/assign, `build_`/`create_`/`create_!`/`reload_`/`reset_`, the `_ids` pair, and the query side (`joins`/`includes`/`preload`/`eager_load`/`where`-hash/`reflect_on_association`). One loaded cache under two names (`record.association(:alias)` IS the source's proxy) and only the source macro installs callbacks, so `dependent:`, counter caches, autosave and validations run exactly once. The query side registers a *renamed reflection copy* (registering the same object emits mismatched JOIN/WHERE table names — invalid SQL), with `class_name`/`foreign_key`/`foreign_type` (direct associations) or `source:` (`has_many`/`has_one :through` — resolved exactly when the through class is already loaded, anchored to the source association's own name when it is not) pinned so nothing re-derives from the alias name; the `_reflections` key form (String on <= 7.x, Symbol on newer) is probed at runtime. Aliases are inherited; re-declaring one with the same source in a subclass after redefining that source refreshes the reflection (descendant caches cleared), while repointing an existing alias at a different source raises. Collision validation sweeps every generated method name against associations, methods, columns, and virtual attributes — skipped gracefully when no database is reachable at load time. Aliases of aliases collapse to the terminal source; `has_and_belongs_to_many` is rejected (use `has_many :through`); the `belongs_to` FK attribute is not aliased by default (`alias_foreign_key: true` aliases the `<alias>_id`/`<alias>_type` pair via `alias_attribute`, collision-checked). Options: `only:`/`except:` narrow the generated method map by group (`:reader`/`:writer`/`:build`/`:reload`/`:ids`; narrowing re-declares prune their stale delegators); `deprecated:` (true or a String hint) warns through the new `ConcernsOnRails.deprecator` on every delegator call — the gradual-rename story. Zero new runtime dependencies.
17
+ - **Controllers::CursorPaginatable**: cursor/keyset pagination — the no-COUNT, concurrent-insert-stable complement to Paginatable. `cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200` (+ per-call `order:`/`per_page:`); `cursor_paginated(scope)` returns the page (loaded Array, limit+1 has-more detection) and sets `X-Per-Page`, `X-Count` (this page — totals deliberately never computed), `X-Has-More`, `X-Next-Cursor`; `cursor_pagination_meta` memoizes for Respondable `meta:` composition. The primary key is always appended as a strict tiebreaker (duplicate values never skip/repeat rows; proven by a ties-walk spec) and the keyset WHERE is Arel OR-expansion, portable across adapters and mixed asc/desc. Cursors are opaque URL-safe Base64 tokens pinned to the table + column:direction list — malformed, tampered (non-scalar values, wrong arity), cross-model, or stale-config cursors raise `InvalidCursor`, auto-rescued to a 400 (`invalid_cursor`, `render_invalid_cursor` override point, delegates to Respondable) on any Rescuable controller. Boundary timestamps serialize at microsecond precision (`iso8601(6)`) and cast back through the model's attribute types so each adapter quotes natively. Ordering columns are chosen in code only, validated against the schema; `reorder` defeats `default_scope` ordering; composite/PK-less tables raise `ArgumentError`, as does a NULL ordering value on a page-boundary row (which would otherwise silently drop rows — SQL three-valued logic). Opt-in extras: `bidirectional: true` mints `X-Prev-Cursor`/`X-Has-Prev` (backward fetches walk the inverted ordering and flip back to canonical order; direction is pinned in the token, so prev tokens replayed at forward-only endpoints 400 and pre-bidirectional tokens stay valid forward cursors); `order_presets:`/`default_preset:`/`order_param:` give clients allow-listed named orderings (unknown names → 400 `invalid_order_preset`; switching presets mid-walk invalidates the cursor); `predicate: :auto` upgrades the keyset WHERE to a composite-index-friendly row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite under uniform directions, falling back to the portable OR-expansion (`:row` forces tuples and raises on mixed directions; `:or` forces the expansion). Zero new runtime dependencies (URL-safe Base64 via `pack("m0")`, as in WebhookVerifiable).
18
+
3
19
  ## 1.17.0 (2026-06-10)
4
20
 
5
21
  Two new concerns, hardened by two adversarial review rounds (in-memory state is restored when a raising lock/unlock hook rolls the write back, so retries can't silently no-op; a hook aborting via `ActiveRecord::Rollback` makes `lock_access!`/`unlock_access!` return false instead of a fake success; invalid-UTF-8 signature headers fail closed instead of raising). 679 examples, 0 failures.
data/README.md CHANGED
@@ -45,8 +45,11 @@ Article.published.without_deleted.find("hello-world")
45
45
  - [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
46
46
  - [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
47
47
  - [Lockable](#-lockable) — failed-attempt tracking + account lockout
48
+ - [Aliasable](#-aliasable) — full read/write/query aliases for associations
49
+ - [Storable](#-storable) — typed accessors over one JSON settings column ("store_attribute-lite")
48
50
  - **Controller concerns**
49
51
  - [Paginatable](#-paginatable) — offset pagination with headers
52
+ - [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
50
53
  - [Filterable](#-filterable) — declarative URL-param filters
51
54
  - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
52
55
  - [Respondable](#-respondable) — standardized JSON envelopes
@@ -59,6 +62,7 @@ Article.published.without_deleted.find("hello-world")
59
62
  - [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
60
63
  - [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
61
64
  - [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
65
+ - [Deprecatable](#-deprecatable) — RFC `Deprecation`/`Sunset` headers + 410 sunset enforcement
62
66
  - [Module paths & namespacing](#-module-paths--namespacing)
63
67
  - [Development](#-development)
64
68
  - [Contributing](#-contributing)
@@ -68,7 +72,7 @@ Article.published.without_deleted.find("hello-world")
68
72
 
69
73
  ## ✨ Why this gem?
70
74
 
71
- - **Twenty model concerns + thirteen controller concerns**, all production-ready
75
+ - **Twenty-one model concerns + fourteen controller concerns**, all production-ready
72
76
  - **One include, one macro** — no boilerplate, no glue code
73
77
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
74
78
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -1004,6 +1008,75 @@ User.locked / User.unlocked # expiry-aware scopes
1004
1008
 
1005
1009
  ---
1006
1010
 
1011
+ ## 🪞 Aliasable
1012
+
1013
+ Alias an existing association under a second name with **full** semantics — read, write/assign, build/create, and the query side (`joins` / `includes` / `where`-hash) — not just a delegated reader. (`alias_attribute` covers columns only; Rails has no built-in association aliasing.)
1014
+
1015
+ ```ruby
1016
+ class Book < ApplicationRecord
1017
+ include ConcernsOnRails::Aliasable
1018
+
1019
+ belongs_to :author
1020
+ has_many :chapters
1021
+
1022
+ alias_association :writer, :author # alias_method order: new, old
1023
+ alias_association :sections, :chapters
1024
+ end
1025
+
1026
+ book.writer # same cached object as book.author
1027
+ book.writer = user # assigns through the original association
1028
+ book.build_writer(...) # build_ / create_ / create_! / reload_ (singular)
1029
+ book.sections << chapter # the same CollectionProxy as book.chapters
1030
+ book.section_ids # ids reader/writer (collection)
1031
+ Book.joins(:sections).where(sections: { title: "Intro" })
1032
+ ```
1033
+
1034
+ **Options**: `alias_association new_name, source_name` — repeatable; declare it **after** the source association; re-declaring an existing alias with the **same** source (e.g. in a subclass that redefined the source) is allowed and refreshes it, while repointing an alias at a *different* source raises. Keyword options: `only:`/`except:` narrow the generated methods by group (`:reader`, `:writer`, `:build`, `:reload`, `:ids`); `deprecated: true` (or a String hint) makes every delegator warn through `ConcernsOnRails.deprecator` — the gradual-rename story; `alias_foreign_key: true` (`belongs_to` only) also aliases `<alias>_id` (and `<alias>_type` when polymorphic) via `alias_attribute`.
1035
+
1036
+ **Notes**
1037
+ - One loaded cache under two names: `record.association(:alias)` IS `record.association(:source)`, and only the source macro installs callbacks — `dependent:`, counter caches, autosave and validations run exactly once.
1038
+ - The where-hash key must match the name you joined under (stock-Rails rule): `joins(:sections).where(sections: {...})` works; `joins(:chapters).where(sections: {...})` does not.
1039
+ - The `belongs_to` foreign-key **attribute** is not aliased — pair with `alias_attribute :writer_id, :author_id` if you need it.
1040
+ - `has_and_belongs_to_many` cannot be aliased (use `has_many :through`). `has_many`/`has_one :through` **can** — the copy pins `source:` so it is not re-derived from the alias name; if your classes load lazily and the through model names the source differently (e.g. `belongs_to :author` behind `has_many :authors`), declare `source:` explicitly on the original association. Aliases are inherited by subclasses.
1041
+
1042
+ ---
1043
+
1044
+ ## ⚙️ Storable
1045
+
1046
+ Typed, defaulted, optionally-validated accessors over a **single JSON (or text) column** ("store_attribute-lite"). Rails' native `store_accessor` is untyped on every supported version — a form-submitted `"true"` stays the String `"true"` — with no defaults and no per-key dirty tracking; that gap is why the `store_attribute` / `jsonb_accessor` gems exist.
1047
+
1048
+ ```ruby
1049
+ class Account < ApplicationRecord
1050
+ include ConcernsOnRails::Storable
1051
+
1052
+ storable_by :settings,
1053
+ theme: { type: :string, default: "light", in: %w[light dark] },
1054
+ notifications: { type: :boolean, default: true },
1055
+ items_per_page: { type: :integer, default: 25 },
1056
+ trial_ends_at: { type: :datetime }
1057
+ storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
1058
+ end
1059
+
1060
+ account.theme # => "light" (virtual default; nothing persisted)
1061
+ account.notifications = "0" # params arrive as strings…
1062
+ account.notifications # => false (…and read back cast)
1063
+ account.notifications? # boolean keys get a predicate
1064
+ account.items_per_page_changed? # per-key dirty (and items_per_page_was)
1065
+ account.reset_theme # drop the key → the default applies again
1066
+ account.flag_beta # affixed accessor
1067
+ ```
1068
+
1069
+ **Options** (per key): `type:` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:json`), `default:` (a value, or a Proc `instance_exec`'d per read), `in:` (inclusion validation, errors on the accessor name). Macro options: `prefix:` / `suffix:` affix the generated method names (the collision escape hatch). The macro is repeatable — repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
1070
+
1071
+ **Notes**
1072
+ - Works on a plain `text` column (JSON encoded/decoded internally), a native `json`/`jsonb` column, or a column the host app already `serialize`d — detected automatically. `serialize` itself is never used, so the Rails 7.1 API drift is irrelevant.
1073
+ - nil vs unset: a written `nil` (explicit JSON null) reads back as `nil` and does **not** fall back to the default; `reset_<key>` removes the key so the default applies again. `:decimal` is stored as a precision-safe string, `:date`/`:datetime` as ISO8601 (datetime in UTC at microsecond precision).
1074
+ - Writing one key dirties (and saves) the **whole column** — concurrent writers to different keys are last-write-wins on the hash. Undeclared keys are preserved. `:json` readers return a dup: reassign, don't mutate in place.
1075
+ - Generated names are collision-checked against existing methods and columns at macro time (`ArgumentError`; affix to escape). Read-side casting never raises — corrupt column JSON decodes as `{}`, garbage values cast to `nil`.
1076
+ - Reach for [`store_attribute`](https://github.com/palkan/store_attribute) / [`jsonb_accessor`](https://github.com/madeintandem/jsonb_accessor) when you need to **query** into the store (jsonb operators, store-backed scopes).
1077
+
1078
+ ---
1079
+
1007
1080
  # 🎮 Controller Concerns
1008
1081
 
1009
1082
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1035,6 +1108,41 @@ end
1035
1108
 
1036
1109
  ---
1037
1110
 
1111
+ ## 🧭 CursorPaginatable
1112
+
1113
+ Cursor (keyset) pagination — the constant-time complement to Paginatable: **no COUNT query**, stable under concurrent inserts, ideal for infinite scroll and sync feeds.
1114
+
1115
+ ```ruby
1116
+ class ArticlesController < ApplicationController
1117
+ include ConcernsOnRails::Controllers::CursorPaginatable
1118
+
1119
+ cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
1120
+
1121
+ def index
1122
+ render json: cursor_paginated(Article.all) # bad cursors are rescued to a 400 automatically
1123
+ end
1124
+ end
1125
+ ```
1126
+
1127
+ **URL params**
1128
+
1129
+ | Param | Default | Notes |
1130
+ |--------------|---------|----------------------------------------------------------|
1131
+ | `?cursor=` | — | The opaque token from `X-Next-Cursor` (omit for page 1) |
1132
+ | `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
1133
+ | `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
1134
+
1135
+ **Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`.
1136
+
1137
+ **Notes**
1138
+ - The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params) and should be `NOT NULL` (a NULL boundary value raises rather than silently dropping rows).
1139
+ - Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). They are **not signed**: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary.
1140
+ - `cursor_paginated` uses `reorder` (replaces any `default_scope` ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's `sorted` — pass `order:` per call instead.
1141
+ - Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy).
1142
+ - Use Paginatable when you need page numbers and totals.
1143
+
1144
+ ---
1145
+
1038
1146
  ## 🔎 Filterable
1039
1147
 
1040
1148
  Declarative URL-param filtering with three modes per filter.
@@ -1434,6 +1542,39 @@ end
1434
1542
 
1435
1543
  ---
1436
1544
 
1545
+ ## 🌅 Deprecatable
1546
+
1547
+ Standards-based **API endpoint deprecation**: the RFC 9745 `Deprecation` and RFC 8594 `Sunset` headers, `Link` rels pointing at the migration docs and the successor endpoint, an instrumentation hook to measure who still calls the endpoint, and optional **410 Gone** enforcement once the sunset instant passes. This is how Stripe/GitHub/Zalando retire API versions — and nothing native exists on any Rails version.
1548
+
1549
+ ```ruby
1550
+ class Api::V1::OrdersController < ApplicationController
1551
+ include ConcernsOnRails::Controllers::Deprecatable
1552
+
1553
+ deprecate_actions :index, :show,
1554
+ deprecated_at: "2026-06-01",
1555
+ sunset_at: "2026-12-31T00:00:00Z",
1556
+ link: "https://docs.example.com/v1-migration",
1557
+ successor: "https://api.example.com/v2/orders",
1558
+ after_sunset: :gone, # default :headers — announce, never block
1559
+ notify: -> { StatsD.increment("api.v1.orders.deprecated") }
1560
+ end
1561
+
1562
+ # Every matching response then carries:
1563
+ # Deprecation: @1780272000
1564
+ # Sunset: Thu, 31 Dec 2026 00:00:00 GMT
1565
+ # Link: <https://docs.example.com/v1-migration>; rel="deprecation", <https://api.example.com/v2/orders>; rel="successor-version"
1566
+ ```
1567
+
1568
+ **Options**: `deprecated_at:` (required; Time/Date/String — parsed eagerly, normalized to UTC), `sunset_at:` (optional, must be ≥ `deprecated_at`; a bare date means **00:00 UTC that day** — sunset is an instant, not end-of-day), `link:` / `successor:` (URLs), `after_sunset:` (`:headers` default | `:gone` → 410 with code `endpoint_sunset` at/after the sunset instant), `header_format:` (`:rfc9745` default, `@<unix>` | `:legacy`, the widely-deployed draft literal `true`), `notify:` (callable, `instance_exec`'d per matching request — a raising notify propagates on purpose). No positional actions = catch-all for the whole controller. **The last matching rule wins**, so an action-specific declaration naturally overrides a base controller's catch-all.
1569
+
1570
+ **Notes**
1571
+ - Headers go out on every matching response — **including the 410 itself**, so the cut-off self-documents. `Link` values are appended to any existing `Link` header (pagination, CDN), never clobbered.
1572
+ - Each hit instruments `deprecated_endpoint.concerns_on_rails` (`ActiveSupport::Notifications`) with `{controller:, action:, deprecated_at:, sunset_at:}` — subscribe to count stragglers *before* flipping `after_sunset: :gone`. Override `on_deprecated_access(rule)` to replace the default instrumentation.
1573
+ - 410 bodies delegate to `Respondable`'s `render_error` when present (inline JSON envelope otherwise). `skip_before_action :apply_api_deprecations` opts an action out; `deprecation_active?` / `sunset_passed?` are available for serializers/response bodies.
1574
+ - Flipping `:gone` is a deliberate, customer-facing cut-off — coordinate it with `notify:`-driven outreach, and mind CDN-cached responses that may outlive the headers.
1575
+
1576
+ ---
1577
+
1437
1578
  ## 🗂️ Module paths & namespacing
1438
1579
 
1439
1580
  Every concern is available under two paths: