permittable 0.10.0 → 0.11.1
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 +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be564cf1f5aa7d54ad207d4a32ef3327a7a03346bd3945bdcdcc41eb6f96cca4
|
|
4
|
+
data.tar.gz: 24c723ef714fc5c092ecd243c3391f8aecdc0399454c6af8cafeb0efff750e7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da85ce6eb68d7b4a2225dd35b8b43d5e8e3f2bb5ccd9ea03a877b25bba8f7f18f51ecc3e720b0f8a3d68f6e882ef47e955ac293ad513c61ec232a5a1b82c505a
|
|
7
|
+
data.tar.gz: 8401cd45a001ddec34c642af47f2aa6cab961beba5632e952f5868e56f0add46635973934a83c7f385f05682fa5f9f2ad5ee1d5a14800c2f5085bbd6c894d14a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.11.1 (2026-10-18)
|
|
4
|
+
<!-- title: sensitive values stay out of logs and exports, a crash and a wrong label fixed, and generated documents that match what the server does -->
|
|
5
|
+
|
|
6
|
+
Three security fixes and twenty-seven fixes, no new API. A `sensitive:` field of any type is now redacted in ActiveRecord's SQL bind log, an array's `default:` and `example:` are withheld from the exported schema when the array holds a sensitive field, and a sensitive field's own `in:` list and hand-written `format:` are withheld too. A custom three-keyword `render_error` no longer turns every rejection into a 500, the JSON error envelope is labelled `application/json` under `respond_to`, and a `fields_for` form, which Rails itself accepts, is no longer refused. The OpenAPI export and the JSON Schema stop describing things the server does not do: verbs OpenAPI has no field for, optional-group paths Rails never dispatches, a rule's `finalize`, a required decimal, date or datetime that accepts `""`. The generator, the audit and the RSpec matchers lose a set of smaller gaps.
|
|
7
|
+
|
|
8
|
+
Patch rather than minor: nothing that works today stops working. Every entry is a security fix, a crash, a wrong label, input that was wrongly refused, documentation that did not match the code, an export correction that makes the document agree with what the server already does, or development tooling. Five things are visible from outside and worth knowing before you upgrade. In monitor mode a malformed JSON body no longer gets a 400 before the action runs, as monitor mode promised (#115). A sub-field `transform:` below an array that declares `validate:` now runs at boot on the authored `default:` or `example:`, as it already does in the general case since 0.10.0 (#124). Exported documents gain keywords (`minLength: 1` on a required decimal, date or datetime, and `x-permittable-finalize` on a rule with a `finalize` block) and lose content: paths Rails never dispatches (#135: `//{theme}`, `.{b}`, and `/` for `(:locale(/:region))`) and the `default` and `examples` of an array that holds a sensitive field (#132). A committed OpenAPI file diffs once, and a client generated from the old file loses those paths. A spec that only passed because of the bug now fails: `not_to with_default(...)` on a `:json` field (#118) and `not_to matching(...)` with a String preset (#119). And a sensitive field that has an `in:` list or a hand-written `format:` loses them in the export; see the **Security** entries.
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **A `sensitive:` field whose value is not a String was logged in the clear in ActiveRecord's SQL bind log.** The Railtie registered each sensitive name only in `config.filter_parameters`, which ActiveRecord copies into `ActiveRecord::Base.filter_attributes` once, when it loads. In a real app that happens before any controller registers a name, so the copy held only the gem's own filter, which redacts Strings and never an Integer, a Date or a Hash: `optional :pin_code, :integer, sensitive: true` logged `4321` in an `INSERT`. The Railtie now adds every sensitive name to `ActiveRecord::Base.filter_attributes` directly, under one lock, and each registration restores any registered name that an app's own `filter_attributes = [...]` assignment dropped. Append to `filter_attributes` rather than assigning it; an aliased or virtual attribute whose name does not contain the param name must still be added to `filter_parameters` by the app, and the README says so. On Rails 6.1 the bind log is unchanged, since it does not filter bind values at all, but `#inspect` now redacts a non-String sensitive value too. (#149)
|
|
12
|
+
- **An array's `default:` and `example:` were exported with the value of a sensitive sub-field inside.** The field's own `default:` and `example:` were already withheld, but `array :cards, default: [{}] do optional :pan, :string, sensitive: true, default: "4111111111111111" end` published the number under the array's `default`. A field now publishes no `default` or `examples` when it is sensitive or holds a sensitive field at any depth, even an empty `default: []`. The whole value is withheld rather than redacted, since a placeholder could fail a sub-field's `format:` or `in:`. An array with nothing sensitive inside, or only sub-fields that opt out with `sensitive: false`, is unchanged. (#132)
|
|
13
|
+
- **A sensitive field's `in:` list and `format:` pattern were exported word for word.** For `optional :pin, :string, in: %w[PIN_SECRET1 PIN_SECRET2], sensitive: true`, the exported enum was the allowlist of the only accepted secrets, and a Range or a regexp leaked through `minimum`/`maximum`, `x-permittable-range` or `x-permittable-pattern`. For a sensitive field, including a child that inherited `sensitive:`, `in:` (a list or a Range) and a hand-written `format:` are now left out, and the field is flagged `x-permittable-custom-validation`, which is how the export already marks a rule that is real but not published. The schema is looser than the server there. A `format:` preset such as `:email` is public and keeps its `format` and `pattern`, `length:` is still published, and an explicit `sensitive: false` under a sensitive parent still publishes. A field marked `sensitive:` for privacy rather than secrecy, with a public set of values (`in: %w[male female other]`), also loses its enum and bounds in the export. (#142)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **A three-keyword `render_error` turned every rejection into a 500.** The README said `errors:` is passed only when details exist, but every rejection has details, so a public `render_error(message:, status:, code:)` was called with `errors:` as well and raised `ArgumentError: unknown keyword: :errors` inside `rescue_from`. `errors:` now goes only to a method that can take it: one that names `errors:`, takes `**kwargs`, or declares no keywords at all. A public `render_error` is still always delegated to. (#98)
|
|
17
|
+
- **The JSON error envelope was labelled `text/html` under `respond_to`.** An action that negotiates with `respond_to` and lists `html` first answered an invalid request with the JSON envelope but `Content-Type: text/html`, because `respond_to` sets the content type before the block runs and Rails' `render json:` only supplies one when none is set. The inline envelope is now sent as `application/json` unless the response already carries a JSON type (for example `application/vnd.api+json`), which is kept. A host's own `render_error` keeps its own content type, and `error_format = :problem` already passed its media type. (#147)
|
|
18
|
+
- **A `Parameters` object nested inside a `Contract`'s Hash input was refused.** `Contract#call` converts a top-level `ActionController::Parameters`, but a slice of one inside a plain Hash, such as `CreateUser.call(user: params[:user])`, was reported `invalid_type`. Nested values that answer `to_unsafe_h` are now converted, inside Hashes and Arrays. Input that holds none is passed through as the same object. (#102)
|
|
19
|
+
- **`unknown: :log` did nothing for a standalone `Contract`.** The warning needed a logger on the host, and the Contract's host has none, so `Contract.define(unknown: :log)` behaved as `:ignore`. It now logs through `Rails.logger` when Rails is loaded and has one, read on every call, and otherwise through a `Logger` on `$stderr`. (#131)
|
|
20
|
+
- **A rejected standalone `Contract#call` was instrumented with `action: nil`; the payload's `action` is now `"call"`.** The README documents `payload[:action]` as a String, so a subscriber that interpolated it or called `to_sym` on it got `"Permittable::Contract#"` or a `NoMethodError`. The instrumentation payload's `action` for a standalone Contract is now `"call"` instead of nil, and the `unknown: :log` warning names the contract instead of printing a bare `#`. (#130)
|
|
21
|
+
- **A form's `f.button` or `button_tag` was a 422 under `unknown: :error`, and so was `image_submit_tag`.** `button_tag` submits `button=`, and a browser submitting through an image input posts its click point as `x` and `y` (`save.x` and `save.y` when named). Both are now exempt from the unknown-key check on a rootless contract, as `f.submit`'s `commit` already was. The `x`/`y` pair is exempt only at the top level, only as a pair, and only when both are undeclared non-negative integer Strings. Monitor mode still hands them back untouched. (#129)
|
|
22
|
+
- **A `fields_for` form was refused by an `array ... do` field.** The form builder sends nested records as a Hash keyed by index (`user[addresses_attributes][0][city]`), which strong params permits and `accepts_nested_attributes_for` reads in order. An `array` field now reads a non-empty Hash whose keys are all integers and whose values are all Hashes as the records it encodes, in the order sent, checks them like any array's elements, and hands the action an Array. Any other Hash, an empty one, and an index-keyed Hash of scalars are still `invalid_type`. Violation paths count records in the order sent, which matches the client's keys for `0..n` and not for timestamp keys. `permittable:generate` drafts a `*_attributes` key over a collection association as `array ... do`; a `has_one` keeps its nested block. (#112)
|
|
23
|
+
- **A `:decimal` refused every `Rational`.** The cast built the BigDecimal from `Rational#to_s`, which is `"1/2"`, so even `3r` was `invalid_type` and `default: 1/2r` raised at class load. A Rational whose reduced denominator has no prime factor but 2 and 5 is converted exactly (`Rational(3, 40)` is `0.075`); any other (`1/3`) is still `invalid_type`, because a BigDecimal could hold it only rounded. This reaches a standalone `Contract` and authored `default:`/`example:` values; Rails params never produce a Rational. (#143)
|
|
24
|
+
- **An `example:` under `transform:` was published with the wrong type, and an array-of-hashes `example:` was published transformed.** `optional :age, :integer, example: "18", transform: ->(v) { v * 12 }` exported `"examples": ["18"]` on an integer schema, and a cents `transform:` on a sub-field was run over an array example before it was published. An example is now cast like any other value and never run through `transform:`; an array example is stored as sent, with no transform at any depth and no sub-field `default:` filled in. Its validation still reads it as a request would, so an array-level `validate:` sees the shape it gets from a request, and that read runs only when an array `validate:` consumes it. A class-load walk no longer suppresses every transform below an array that declares its own `transform:`: sub-field `transform:`s now run at class load below an array that declares `validate:`, even when the array has its own `transform:`, and are still skipped elsewhere, so a contract that is valid for every request no longer raises at load. (#124)
|
|
25
|
+
- **A live `in:` object that was empty at class load raised at boot.** An `in:` that is a registry filled by an initializer or `after_initialize` raised "no value can satisfy it" in production, where `eager_load!` runs first, and booted in development. The emptiness check now applies only to what the gem reads itself, a literal list or a Range. An authored `default:` or `example:` is no longer checked for inclusion against a live object; its type, format, length and `validate:` still are. (#125)
|
|
26
|
+
- **Monitor mode's eager check could stop the action.** The README promises that in monitor mode nothing raises and the action runs, but the `before_action` that validates monitor rules reads `params`, so a malformed JSON body answered 400 before the action ran and a `validate:` that raised was a 500. The check now rescues any `StandardError`, logs `[monitor] #create could not be checked: <exception class> — the action runs unchecked`, and lets the action run. Only the class is logged, because a parse error's message can quote the body. An action that then reads `permitted_params` meets the same exception as it would with no contract, and an enforced rule outside monitor mode is unchanged. (#115)
|
|
27
|
+
- **The audit's stale and unmatched-ignore sections were dropped when no action was routed.** `Audit.format` returned early on an empty entry list, which is when a contract left on a renamed route and a mistyped `audit_ignore` entry matter most. It now prints its one-line notice and then both sections; with nothing to add the output is unchanged. (#109)
|
|
28
|
+
- **The OpenAPI export wrote verbs OpenAPI does not define, which made the whole document invalid.** Rails accepts every verb in `ActionDispatch::Request::HTTP_METHODS`, so `via: %i[report mkcalendar search]` wrote `report` as a Path Item key, and OpenAPI 3.1 allows only `get`, `put`, `post`, `delete`, `options`, `head`, `patch` and `trace`. An operation is now placed only at those verbs. One reached only by others is listed under `x-permittable-controllers`, as an unrouted operation is, and one reached by both (`via: [:post, :report]`) is documented under `post`. (#114)
|
|
29
|
+
- **Optional-group variants Rails never dispatches were documented.** Rails adds a leading `/`, squeezes runs of slashes and strips a trailing one before matching, so `scope "(:locale)" { scope "(:theme)" { get "" } }` exported `//{theme}`, `get "(:a)(/:b)"` exported `//{b}`, `get "(:locale(/:region))"` exported `/` for the group absent, and `get "(:a)(.:b)"` exported `.{b}`. A path whose optional group leaves an empty leading segment is no longer documented. `/` is still documented wherever the router answers it. (#135)
|
|
30
|
+
- **A rule's `finalize` block left no trace in the export.** The README and the module comment say `validate:`, `transform:` and `finalize` are flagged with `x-permittable-*` extensions, but only the first two were. A rule that declares `finalize` now carries `x-permittable-finalize: true` on its object schema, inside `root:` when there is one, and on the OpenAPI operation, since an operation whose rule has only path-variable fields has no `requestBody` to carry it. Consumers that filter on `x-permittable-*` will see a new key. (#133)
|
|
31
|
+
- **The export let a required decimal, date or datetime be `""`.** `""` is absent for every type, so the server answers `missing`, but only `:string` told the schema so. A required `:decimal`, `:date` or `:datetime` now exports `minLength: 1`. A nullable field is left out, because it reads `""` as an explicit null. A whitespace-only string for a required `:decimal` still passes the schema and gets a 422, as `"abc"` does. (#134)
|
|
32
|
+
- **Editing an exported description rewrote the contract.** `default:`, `example:`, `in:` and `message:` are deep-copied and frozen when declared; `desc:`, on the rule and on each field, was stored and exported by reference, so a caller that appended "(deprecated)" to an exported document rewrote the contract, and every later export in the process carried it. The stored `desc:` is now a frozen copy, and every export hands out an unfrozen copy, so editing an exported document in place affects only that document. The host's own String is left unfrozen. (#113)
|
|
33
|
+
- **`permittable:generate` read `user_params.permit` as a rootless `params.permit`.** The call patterns had no boundary before `params`, so a helper that merely ends in it matched, and the rootless call beat the model's own `user` root: a `{user: {...}}` request would then be rejected as missing its fields. `params` must now not follow a word character, `@` or `$`; `self.params.permit` and `@_params.require(:user).permit(...)` are still read. (#138)
|
|
34
|
+
- **`permittable:generate` lost the root and the fields of a `%i[...]` list.** RuboCop's default `Style/SymbolArray` rewrites `params.expect(user: [:name, :email])` to `%i[name email]`, and the generator accepted only `[`, so the draft dropped `root: :user` and the fields of `permit(%i[name email])`. A word list of plain keys (`%i`, `%I`, `%w`, `%W`) is now read as the bracketed list it equals, a list among the filters is flattened as Rails flattens it, and an `expect` envelope whose value cannot be read keeps its root with the value as an unparsed TODO. A TODO still quotes the source as written. (#137)
|
|
35
|
+
- **`permittable:generate` skipped `permit` and `expect` calls written without parentheses, and String roots.** `params.require(:user).permit :name, :email` and `params.require("user").permit(:name)` matched nothing, so the draft fell back to columns alone and dropped virtual keys such as `password_confirmation` with no TODO. A paren-less call is now read to the end of its statement (Ruby's own continuation rule), a trailing `if`/`unless`/`rescue` modifier is dropped, a String root that is a plain key is read like a Symbol, and a dropped comment no longer joins its line to the next. A parenthesis in the arguments still skips the call. (#104)
|
|
36
|
+
- **`permittable:generate` drafted `:create, :update` for actions a controller does not define.** A controller with only `create` and `destroy` got an `:update` rule no action could reach, which `permittable:audit` then listed as stale, and an abstract base could be drafted with a rule every controller below it inherits. Each drafted action the controller does not define is now left out, reading actions through `Audit.missing_lookup` so `action_missing`, template-only and inherited actions count as defined. A controller that cannot list its actions is assumed to define both, and one with none left is skipped. (#139)
|
|
37
|
+
- **`permittable:generate` could not find a permitted primary key or timestamp.** The column lookup dropped them, so `permit(:id, :title, :created_at)` drafted `created_at` as a string with `virtual: true` and a "not a database column" TODO. A permitted column is now drafted with its real type, optional even when NOT NULL when the database assigns it. A primary key counts as assigned when the adapter reports `auto_increment?` (or, for an adapter without that flag, when it is a single integer key), or when it has a default or default function; a natural or composite key the client sends is required on create when NOT NULL. (#140)
|
|
38
|
+
- **The type guard refused the generator's own integer-enum recipe.** With `check_column_types` on, following the TODO that every integer-backed enum draft carries (add the stored integers to `in:` and map them back with `transform:`) failed the deploy with "declared :string with an in: listing values it would refuse". The guard now accepts an integer-backed enum's stored integers, as strings, in `in:` when the field declares a `transform:`; anything else in `in:` is still refused, and the error says a `transform:` is what admits them. The TODO now spells a transform that keeps names intact, `->(v) { Order.statuses.key(Integer(v, exception: false)) || v }`; the obvious `key(v.to_i)` read `"shipped"` as `0`. (#103)
|
|
39
|
+
- **The behaviour matchers instrumented a rejection with a nil controller and action.** A subscriber that relies on `payload[:controller]` being a String, as the README documents it (`"users"`), for example `payload[:controller].tr("/", ".")`, raised out of the matcher. The matcher now reports a controller's `controller_path` ("the controller" when anonymous), `Permittable::Contract` for a contract as `Contract#call` does, and the resolved action. (#96)
|
|
40
|
+
- **`with_default` on a `:json` field failed for one of the two key spellings.** A `:json` default is stored as authored but read String-keyed by the action, so `with_default("theme" => "dark")` failed for `default: { theme: "dark" }` and the reverse failed for a String-keyed one. A Hash expected against a `:json` field's Hash default is now compared as a `HashWithIndifferentAccess`, at every depth and inside arrays. A nil default on a nullable `:json` field still fails `with_default({})`. (#118)
|
|
41
|
+
- **`matching("email")` failed for a format preset spelled as a String.** A contract accepts `format: :email` and `format: "email"` alike, but the matcher compared the String against the resolved Regexp. A String is now read as the preset it names, and the failure message and description name it the same way for both spellings. (#119)
|
|
42
|
+
- **A matcher's failure message named the wrong kind of subject.** `permit_param` on a `Contract` said "the controller", and `accept_params` on an anonymous controller class said "the contract". The fallback is now "the contract" for a `Permittable::Contract` and "the controller" for anything else; a named class is still reported by name. (#120)
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- **The README did not say a locale switch must wrap the enforce check.** With `enforce: true`, and for every monitor-mode rule, violations are resolved in the `before_action` that `include Permittable` registers, so a locale switch declared as an `around_action` after the include runs after it and the same request gets a different language depending on `enforce:`. The README's I18n section now has a "Per-request locales" paragraph: put the switch first with `prepend_around_action`, or declare it before the include. No behaviour changes. (#148)
|
|
46
|
+
- **The README has a complete example that uses every option.** Documentation only; no behaviour changes. (#92)
|
|
47
|
+
|
|
48
|
+
### Test suite
|
|
49
|
+
- **The generator spec stubbed a frozen object on Rails main.** A model's `_default_attributes` is a frozen `ActiveModel::ReadOnlyAttributeSet` there, and rspec-mocks cannot proxy a frozen object, so four examples in "when the model raises while it is being read" failed in `before` and broke the `rails main, ruby 4.0` job on every branch. The spec now freezes the set itself and stubs the model's accessor to return a delegator that raises for the two targeted columns. The library needed no change. (#141)
|
|
50
|
+
- **The test matrix runs on every supported Ruby and Rails pair.** `gemfiles/activesupport_6.1.gemfile` now lists base64, benchmark, bigdecimal, drb, logger and mutex_m, because Rails 6.1 needs them on Ruby 3.4 and 4.0 (an app on that pair must list them too), and two non-blocking early-warning jobs run: Ruby head, and Rails main on Ruby 4.0. The gem is unchanged. (#93)
|
|
51
|
+
|
|
52
|
+
## 0.11.0 (2026-10-05)
|
|
53
|
+
<!-- title: an audit ignore list, route-order-stable operationIds, and routes read the way Rails dispatches them -->
|
|
54
|
+
|
|
55
|
+
One new setting and six fixes, closing every open issue. `Permittable.audit_ignore` lets `permittable:audit[strict]` pass in an app that uses ActiveStorage or a catch-all 404 route, and controllers from engines and gems are now listed apart from the app's own rather than counted against it. The OpenAPI export and the audit now read the route set the way Rails dispatches it: an earlier unconstrained route wins its verb and path over a later one (including a later `via: :all`), optional segments expand in Rails' own match order with constraints taken into account, and two routes no longer export paths that differ only in variable names. Colliding operationIds are ranked by the operation itself, so reordering `routes.rb` no longer renames a generated client method. The audit stops reporting a contract on a shared base controller as stale, and the wrapper-key exemption from 0.10.0 now holds whatever order ParamsWrapper and Permittable are included in, and no longer probes the request when no contract uses its answer. The compatibility matrix now runs the Railtie specs on every supported Rails line.
|
|
56
|
+
|
|
57
|
+
Minor rather than patch: there is new public API, and three changes are visible from outside. Controllers whose source is outside `Rails.root` no longer count towards `[strict]` by default (set `Permittable.audit_ignore_outside_root = false` to keep counting them). Some existing operationIds change once on upgrade: see the operationId entry under **Changed** for which. And a route that an earlier unconstrained route already answers for the same verb and path is no longer described, so a contract left on its action is now reported as stale.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
- **`Permittable.audit_ignore`: leave controllers and actions out of `permittable:audit` and `permittable:generate`.** Two endpoints failed `[strict]` in nearly every app without being a risk the app could fix: ActiveStorage's direct uploads controller (a POST with no contract, which `permittable:generate` also drafted), and a catch-all 404 route, whose `via: :all` reads as accepting a body on POST, PUT and PATCH. `Permittable.audit_ignore = ["active_storage/direct_uploads", "application#not_found"]` takes controller paths as Rails routes them and `controller#action` strings, validated on assignment: a class name raises with the path it meant, and a Class or a Regexp is refused. Controllers whose source is outside `Rails.root` (engines and gems: ActiveStorage, ActionMailbox, Devise, Rails' own `rails/*`) are ignored by default, including gems installed under the app's own `vendor/bundle`; `Permittable.audit_ignore_outside_root = false` audits them too. Ignored endpoints leave the counts and never fail `[strict]`, but the report still lists them in a section per reason, along with any ignore entry that matches no routed action. The generator's sweep skips ignored controllers and leaves an ignored action out of its draft; naming a controller still drafts it whole. (#69)
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
- **Colliding operationIds are ranked by the operation itself, not by load and route order.** The rank is routed first, then controller path and action, then the shallowest path, then GET, POST, PATCH, PUT, DELETE. Reordering `routes.rb`, or a controller loading earlier, therefore never renames a generated client method. **This can change some existing operationIds once on upgrade**: two controllers that fold to one id (`admin/users` and `admin_users`) whose load order disagreed with string order, one action reached by separate routes at several paths or verbs (`post "admin/legacy"` before `post "legacy"`, `via: [:post, :get]`), and routes with nested optional groups. `resources` PATCH/PUT pairs, a single optional scope, and ids that never collided are unchanged. Adding or removing a colliding route can still rename the other members of its group. (#68)
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
- **The export and the audit described routes Rails never dispatches to.** A route that an earlier, unconstrained route already answers for the same verb and path is no longer described. A `match ..., via: :all` route keeps only the verbs no earlier route answers on that path, so `get "hooks"` → `index` drawn before it no longer produces a phantom `GET /hooks receive` audit row or hands that slot to `receive` in the export. A route hidden this way is dropped, and a contract left on its action is reported as stale. A constrained route (a segment requirement, a subdomain or host, a constraint object or lambda) never hides the routes behind it, because Rails passes on to them every request the constraint turns away: the audit is a gate, and it errs towards listing a route rather than hiding an unguarded one. Only identical path templates are compared. (#68)
|
|
67
|
+
- **Optional route segments were expanded by string, not as Rails matches them, and two routes could export paths that differ only in variable names.** `scope "(:locale)"` put its root at `/{locale}` and its `get ":slug"` at `/{slug}`, two templates OpenAPI forbids side by side; and since Rails sends `GET /foo` to the root, the second never reached `pages#show` unless a constraint on `:locale` turned the request away. Such paths are now one path, spelled the way the route Rails tries first spells it: a later route under another verb is placed there with its own spelling in `x-permittable-path`, and one under the same verb is listed in the earlier operation's `x-permittable-shadows` instead of being emitted (its operation lands in `x-permittable-controllers` if it has no other path). Expansion now follows Rails' own match order, constraints included: `get "x(/:a)(/new)"` no longer documents `/x/new`, which Rails reads as `a = "new"` (unless `:a`'s constraint rejects it), and `x(/:a)(/:b)` with `a: /\d+/`, where Rails binds `/x/foo` to `:b`, lists `/x/{b}` on `/x/{a}` rather than dropping it silently. The route is read from the parsed Journey tree rather than its printed string, once per route rather than once per verb; a Journey-shaped stand-in with a String spec now honours escaped parentheses and raises on unbalanced ones instead of truncating the path. (#67)
|
|
68
|
+
- **`Audit.stale` reported a contract inherited from a shared base controller as stale.** `permittable_contracts` is a `class_attribute`, so a base controller with no routes of its own, and every subclass routed to a different slice of its actions, was listed under "Contracts declared for actions no route reaches". A contract's action is now stale only when no controller that carries the contract (the declaring class or any of its subclasses) reaches it, and it is listed once, under the class that declared it. The audit summary also agrees in number when a count is one ("1 of those accepts a request body", "1 covered action declares no model:, so no schema-drift guard runs for it"). (#49)
|
|
69
|
+
- **The wrapper-key exemption switched off when ParamsWrapper came before Permittable in the ancestor chain.** It recorded ParamsWrapper's key in `process_action`, relying on running before ParamsWrapper wrapped the body. A controller that includes `ActionController::ParamsWrapper` after Permittable, such as a Metal subclass, ran ParamsWrapper first, and every wrapped JSON request to a rootless `unknown: :error` contract was a 422 again. The key is now read in `process`, which every `process_action` runs inside, so module order no longer matters. The probe also no longer touches the request unless some contract on the controller acts on its answer: a rootless one that checks unknown keys or declares a field of the wrapper's name. (#70)
|
|
70
|
+
|
|
71
|
+
### Test suite
|
|
72
|
+
- **The compatibility matrix never ran the Railtie specs.** None of the `gemfiles/activesupport_*.gemfile` variants bundled `railties`, so `spec/railtie_spec.rb` skipped itself in every job but the root `Gemfile`'s: the `filter_parameters` wiring, its reach into ActiveRecord's `filter_attributes`, the late-bound registry and rake-task loading went untested on every supported Rails line but the newest. Each gemfile now pins `railties` to its own line, and the root `Gemfile`'s floor moves to 6.1 alongside `actionpack`/`activerecord`. Running the spec on old Rails surfaced two problems in its probe app, none in the gem: the probe booted without loading `logger` first (a NameError on activesupport 6.1), and asserted `precompile_filter_parameters`, which arrived in Rails 7.1. The gem itself needed no change on any version. (#50)
|
|
73
|
+
- **The conformance spec read exported patterns as Ruby regexps, so `^` and `$` anchored a line.** `spec/support/tiny_json_schema.rb` compiled each `pattern` with `Regexp.new`, but the document holds ECMA-262, where `^` and `$` without the m flag anchor the whole input. `format: /\Aabc\z/` exports `^abc$`; the server and every real validator reject `"abc\n"` against it, and the instrument accepted it — a spurious "schema looser than server" divergence in the one direction the spec says cannot happen, which also hid a real anchor regression. The instrument now undoes the anchor translation before compiling (`^` → `\A`, `$` → `\z`), tokenizing escape pairs and character classes as units the way `EcmaPattern` does, and `spec/ecma_pattern_spec.rb` checks it against node over every exported pattern. No gem behaviour changes. (#48)
|
|
74
|
+
|
|
3
75
|
## 0.10.0 (2026-09-30)
|
|
4
76
|
<!-- title: canonical numeric strings, the app's own CSRF param, and defaults that agree with requests -->
|
|
5
77
|
|