permittable 0.11.0 → 0.12.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: be27cc7405432910133e85a3fb187c0147f14a7a28d98f960c58bc7190fc0271
4
- data.tar.gz: e70282fd762f9a4c57d1b43e0c01b4306193997930b57015efc28b0af576564a
3
+ metadata.gz: 737a1777a6da7e8bcce3e969b82796279f0b5f1f51f3497abad696c4622f6951
4
+ data.tar.gz: 8c01787cc7081db7bbf6f4f0ce5a1f337b32e4d801d96bd6bd1f82678092c42e
5
5
  SHA512:
6
- metadata.gz: 61ec3dadad1f5fbc42c1c0403d228dff1d51c9ccaff13b8d455fb5d1ef795a01ddf7b64c0fa5e6dac76984bb22ad208b38ee50a1e6593326eb40704dbeecc8a6
7
- data.tar.gz: '0443649c00422967f30d26d3e5982735433756c2aa1a669e26c10eaeaec707d48c2b4f2992805c3544e0bf68d502e0d9879776e78da2d497a7beacb18949dcb9'
6
+ metadata.gz: 19320e84873a06b21eec376ddb236275ac7beaf217d29eed91e3dfab1e26c644c35538c6549ef57a73c6dd95b8bc9c5305aef8ed40075c3823f5d8a53dfd5404
7
+ data.tar.gz: 99cf9b0eee28ab54f31388fe647280b4f372022dc1bfc02ff01a09ef70032d68e0fde76816a51699fc9127b85f5230cd6938c2f4899981f954ec58d3e87a8de2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.12.0 (2026-10-21)
4
+ <!-- title: stricter numbers, dates and class-load checks, and matchers that stop passing for the wrong reason -->
5
+
6
+ Twenty-one changes to how a contract reads input, how it is checked and how its errors are rendered, plus one documentation fix and two small additions: the `DECIMAL_DIGIT_ALLOWANCE` constant (#110) and `date_select` multiparameter support (#128). A number a cast cannot hold, a decimal with an enormous exponent, a `DateTime` with a time of day, a `:datetime` that no request can match, and a `length:` Range with a negative end are now `invalid_type` or a class-load error where they were stored as something else or crashed later. A blank element a form's hidden field sends is dropped from a scalar array. `use` refuses a Contract whose `finalize` it would drop, and `use G, optional: true` no longer fills in the group's defaults on a partial update. A Hash, Array or Set subclass that keeps its base `include?` is read as a list. And the RSpec matchers stop passing in the wrong direction: a negated `accept_params` with no rule, a qualifier on the wrong matcher, an index on a field that is not an array, a Contract run around `#call`, and a CSRF parameter the controller exempts. An app whose `render_error` is private, as Rails convention makes helpers, now renders its own error body.
7
+
8
+ Minor rather than patch: each entry can make a request, a boot or a spec that passes today fail, or change what an action or a client receives. Before upgrading, read the entries that match what you use. A request can now be refused: a number that underflows to zero or that a cast cannot convert (a `Complex`), a `:datetime` offset such as `+24:00` or `XYZ` (#100), a `date_select` field with a missing or non-integer `(ni)` part where an optional field used to drop the parts silently (#128), a `:decimal` string whose plain form would dwarf the request, a `Time` or `DateTime` with a time of day sent to a `:date`. A boot can now fail: a `:datetime` `in:` member finer than a nanosecond, a `length:` Range with a negative, fractional or non-numeric end, a `use` of a Contract with a `finalize`, a scalar declared over a PostgreSQL array column under `check_column_types`, a `DateTime` `default:` at noon, and an empty `config_for` `in:` (#126). An action can now receive something different: a `:json` field's `default:` as a frozen string-keyed copy (a symbol-keyed default now gives String `keys` and `to_h`), no defaulted fields from `use G, optional: true`, an app's own error body from a private `render_error` (#150), `["a"]` for a JSON `["", "a"]`, a `:date` where a midnight `Time` was refused. And a spec can fail where it passed: the negated and qualified forms of the behaviour matchers, and a matcher run on a Contract.
9
+
10
+ ### Changed
11
+ - **`use G, optional: true` drops `default:` from the fields it relaxes.** The README's PATCH recipe overwrote data: `use UserFields, optional: true` with `optional :plan, :string, default: "free"` handed the action `plan: "free"` for a request that only renamed the user, and `User#update` then downgraded every "pro" user. A default is filled in for every request that omits the field, which on an update is every column the client left alone. The relaxation now drops `default:` as it already dropped `required:`, at the top level only: a nested hash the client does send keeps its sub-fields' defaults, and the group's own create rule keeps all of them. The exported schema of the update rule follows. This also applies to `virtual: true` fields and to non-PATCH uses, such as a search rule that relied on `page: 1`; use `use G, optional: true, except: [:page]` and redeclare the field. (#121)
12
+ - **`use` refuses a Contract that declares a `finalize`.** `use SomeContract` splices only the contract's fields, so a cross-field rule such as "ends_on is not before starts_on" silently never reached the controller. `group_fields!` now raises `ArgumentError` at class load when `use` is given a Contract with a `finalize`, at the top level or in a nested block. The message says how to share both halves: declare the fields in a `Permittable.fields` group that the contract and the `permit_params` block each use, and declare `finalize` in each (a single proc passed as `finalize(&rule)` keeps the rule in one place). `only:` and `except:` do not lift the refusal, since a `finalize` is opaque. A Contract without `finalize` is still accepted. (#122)
13
+ - **A Hash, Array or Set subclass that keeps its base `include?` is read as a list.** `in:` was read as a list only when its class was exactly `Array`, `Set`, `Hash` or `HashWithIndifferentAccess`, so `in: Rails.application.config_for(:plans)`, an `ActiveSupport::OrderedOptions` with Symbol keys, asked `include?("pro")` about `:pro` and refused every request, with no enum in the export. What makes a list is now which module owns the object's `include?`: a subclass that keeps its base's is read as that list at class load (an Array subclass through `.to_a`, a Hash or Set subclass stored as a Set) and exported as an enum. `HashWithIndifferentAccess` keeps its exemption, and a gem that prepends a module into `Array`, `Hash` or `Set` still does not turn plain lists opaque. A subclass override, a singleton method or an `extend` is used as given, which means a plain Hash or Array with a singleton or `extend`ed `include?` override is now used as given where it used to be read as a list. An empty `config_for` `in:` boots on 0.11.1 (#125) but raises on 0.12.0, because the object is now read as a list at class load and an empty list is refused as `in: []` always was. (#126)
14
+ - **A `:date` reads a `Time` or `DateTime` only at midnight UTC.** `cast_date` matched `when Date`, and a `DateTime` is a `Date`, so a noon `DateTime` was handed back whole, time of day included, and exported as `"2026-01-05T12:00:00Z"`. A `Time`, `DateTime` or `TimeWithZone` that is exactly midnight UTC is now that UTC date, returned as a plain `Date`, and any other instant is `invalid_type`. A `Time` or `TimeWithZone` at midnight UTC is accepted where it was refused, a `DateTime` or `Time` with a time of day, or at midnight in another zone, is refused where a `DateTime` was returned whole, and a noon `DateTime` `default:` fails at class load like any default that violates its field. (#127)
15
+ - **`date_select`, `datetime_select` and `time_select` submitted values a `:date` or `:datetime` field ignored or rejected.** Rails sends one value as `starts_on(1i)`, `starts_on(2i)` and `starts_on(3i)`; an optional field silently dropped them, a required one answered `missing`, and `unknown: :error` reported every part as unknown. A declared `:date` or `:datetime` field now assembles its parts into the ISO 8601 string it already accepts, which then takes the normal path (cast, `in:`, `validate:`, `transform:`, nullable and absence handling). Blank parts read the field as sent empty, a missing or non-integer part is `invalid_type`, and a `:datetime` reads a blank or missing hour, minute or second as `00`, which is what `f.date_select` on a datetime column sends. When both the field's key and its parts are sent, the key wins and the parts go through the unknown-key policy. Datetime parts are read as UTC. `time_select(ignore_date: true)` remains unsupported: it sends no date, so the field is `invalid_type`. (#128)
16
+ - **A `:datetime` `in:` member finer than a nanosecond fails at class load.** A member authored as a `Time` is stored exact, but the exporter prints at most nine fractional digits and a request's fraction is a finite decimal, so `in: [Time.at(1.1).utc]` could never be matched: the schema listed a value the server always refused. `Coercion.cast_in_member` now raises "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)". A member read off the clock (`Time.now`) and a nanosecond-exact member still load and match. (#106)
17
+ - **`length:` Ranges with a negative, fractional or non-numeric end fail at class load, and an infinite end is exported as no bound.** `length: ..-1` made every value a `length` violation forever, `length: -2..3` exported `minLength: -2`, `length: 1.5..3.5` exported fractional bounds that Ajv rejects for the whole document, and `length: 1..Float::INFINITY` made `JSON.generate` raise on the exported schema. Each end of a Range must now be a non-negative whole number (an Integer, or a whole Float, Rational or BigDecimal such as `10.0`) or open (`nil`, or an infinite Float on its own side), or the contract raises naming the fix (`1..` or `..80`). The exporter reads an infinite end as no bound. (#99)
18
+ - **A `:datetime` string is read as Gregorian, and a zone Ruby cannot honour is refused.** `DateTime.parse` defaults to the 1582 Italian reform, so with only `require "permittable"` (a standalone Contract outside Rails) `"1500-01-01"` became `1500-01-10`, while a Rails app kept the day. Strings are now parsed with the proleptic Gregorian calendar, so both agree. An offset Ruby cannot honour (`+24:00`, `+25:00`, `-99:00`, `+99`, an unknown abbreviation such as `XYZ`) used to be dropped and the wall clock read as UTC; a string whose parse reports a zone must now carry an offset under a day, or it is `invalid_type`. Every real offset (`+14:00`, `+23:59`, `-00:00`) and every abbreviation Ruby knows is read as before. (#100)
19
+ - **A `:decimal` that underflowed to zero is `invalid_type`.** `C.cast(:decimal, "1e-99999999999999999999")` returned `0.0`, storing a nonzero client value as zero, which `:float` already refused. `:decimal` now applies the same rule: a zero result from a string naming a nonzero significand is `invalid_type`. Genuine zeros and small representable values such as `"1e-400"` are unaffected. (#101)
20
+ - **A `:decimal` string whose plain form would dwarf the request is `invalid_type`.** `"1e4000000000000000000"` passed the cast as a finite value and then raised `NoMemoryError` the moment a database adapter, JSON rendering or a log line formatted it, and `"1e1000000000"` tried to allocate a gigabyte from a dozen bytes. A String whose plain form would run past both a thousand digits and the string's own length is now refused: `exponent.abs > [1000, source.length].max`. `"1e400"`, `"1e999"` and `"-1e-999"` still cast; a number spelled out digit by digit is accepted however long it is; `"1e1000"` and larger exponents are refused where they cast, and on the negative side `"1e-1001"` casts and `"1e-1002"` is refused (BigDecimal's exponent is the digit count plus one). The limit is the constant `DECIMAL_DIGIT_ALLOWANCE`. (#110)
21
+ - **A `Numeric` that a cast cannot hold is `invalid_type`, never `0.0` or a raise.** A `BigDecimal("1e-400")` or `Rational(1, 10**400)` given to `:float` was stored as `0.0`, and `Complex(1, 1)` raised `RangeError` out of `Contract#call` (a 500 behind `call!`), as did a custom Numeric whose `to_f` or `to_s` raised for `:float`, `:decimal` and `:string`. The Numeric branch now applies the same underflow rule as a String, any conversion failure of the caller's value in `:float`, `:decimal` and `:string` is `invalid_type`, and a `Complex` is never a `:float`, matching `:integer` and `:decimal`. Zero in any spelling and every value a Float can hold, subnormals included, are unchanged. This reaches a standalone `Contract` and authored `default:`/`example:` values. (#145)
22
+ - **A scalar array drops the blank element a form's hidden field sends.** `collection_check_boxes` and `select multiple: true` send a hidden `""` so that unchecking every box still sends the key, and `array :tag_ids, of: :integer` rejected every such submission as `invalid_type`; an `of: :string` array kept a blank tag. A scalar array's `""` elements are now treated as absent, dropped before casting, before `length:` counts, and before `validate:` and `transform:` see the array. Violation paths keep the index the client sent each element at, a `null` element is still `invalid_type`, and arrays of hashes are untouched. A JSON client sending `["", "a"]` to an `of: :string` array now gets `["a"]`. (#111)
23
+ - **`permittable:generate` drafts a PostgreSQL array column as an array, and `check_column_types` refuses a scalar declared over one.** Rails reports `t.string :tags, array: true` as `type: :string` with `array?: true`, so the generator drafted `optional :tags, :string`, which rejects the Array the column stores, and the guard compared groups and let the wrong shape load. The draft is now `array :tags, of: :string`, with `required: true` when a NOT NULL column without a default makes a create require it, and the same `length:` TODO a scanned `tags: []` gets; an array of `json`, `jsonb` or `hstore` elements, which `of:` cannot express, becomes a TODO. With `check_column_types` on, a scalar or `:json` declaration over an array column raises at class load, spelling the `array :tags, of: :string` declaration to write or `virtual: true`. (#97)
24
+ - **`permittable:generate` leaves a TODO for a json column whose default is an array.** `:json` accepts only an object, so `t.jsonb :labels, null: false, default: []` drafted `optional :labels, :json`, which then rejects exactly what every client sends. The column cannot say whether its elements are scalars or objects, so the draft is now a TODO naming both declarations, `array :labels, of: :string` or `array :labels do ... end`. A json column with an object default, or none, still drafts as `:json`. (#105)
25
+ - **Negated `accept_params` and `reject_params` fail when no rule covers the action, and refuse qualifiers.** The matcher defined no `does_not_match?`, so RSpec negated `matches?`, and `not_to accept_params(valid).for_action(:craete)` passed on a typo, as did `not_to accept_params(...)` on a controller with no contracts. `does_not_match?` now resolves the rule as `matches?` does, fails with the existing problem text when none resolves, and otherwise passes only on the opposite outcome. A negated `returning` or `with_violation` raises `ArgumentError` naming the positive form, and a reused matcher no longer prints a previous run's "has no contract covering" message. A negated assertion that passed on a typo will now fail. (#94)
26
+ - **`returning` on `reject_params` and `with_violation` on `accept_params` raise.** Both were documented as one matcher's qualifier and silently ignored on the other, so `accept_params(...).with_violation("name", :missing)` asserted only the bare outcome and described an assertion it never made. Each now raises `ArgumentError` at chain time, naming the matcher the qualifier belongs to. (#95)
27
+ - **`permit_param` holds `[n]` indexes to the field they index.** `permit_param("items[0].sku")` passed for a nested hash where the runtime, given `items: [{ sku: "A" }]`, answers `invalid_type`, and `not_to permit_param("items[0].sku")` failed. Each path segment now takes at most one `[n]` and only on an array field; a path that breaks this fails naming the field's shape (`:items is a nested hash, not an array`), and its negated form passes. Below a `:json` field the path stays opaque, indexes included. (#117)
28
+ - **`accept_params` and `reject_params` on a `Contract` run through `Contract#call`.** The matcher built a throwaway host and set its params directly, so `Contract#normalize_input` never ran: `accept_params([{ "id" => "1" }])` passed where `feed.call` raises `ArgumentError`, and `reject_params("garbage").with_violation("id", :missing)` passed too. A Contract subject now runs through `#call` itself, so the `ArgumentError` propagates, the `unknown:` override is the Contract's own, and a rejection is labelled with the controller `#call` reports, `"Permittable::Contract"`, rather than `nil`. (#116)
29
+ - **The matchers exempt the controller's configured CSRF parameter.** The runtime exempts the app's `request_forgery_protection_token` from `unknown: :error`, but the matcher's bare host had no such method and exempted nothing, so `reject_params(name: "x", csrf_token: "abc").with_violation("csrf_token", :unknown)` passed for a controller that serves that POST with a 200, and `not_to accept_params` of it passed too. For a subject that answers `request_forgery_protection_token`, the host now exempts that class-level name; a standalone Contract still exempts nothing. Route path parameters cannot be mirrored, so a payload `id` on a member action is reported unknown, and a per-instance token override is not seen; the README says both. (#146)
30
+ - **A `:json` field's `default:` now reaches the action as a frozen indifferent-access copy, with string keys, not as the Hash as authored.** A symbol-keyed authored default (`default: { theme: "dark" }`) now has String keys: `keys` and `to_h` give Strings, `== { theme: "dark" }` is false, and `**default` passes String keys, while `default[:theme]` and pattern matching on `{theme:}` still work. The same copy is what the export and `with_default` see. The change fixes a `validate:` that reads `p[:theme]`: a request's hash reaches `validate:` as an indifferent-access deep copy, but the authored `default:` and `example:` were checked as the raw Hash, so `default: { "theme" => "dark" }` raised at class load ("violates its own contract") although every request passed, and a `validate:` that edited its argument edited the host's own default. The value is now read the way a request's hash is read, through the same bounds checks and deep copy, and the published `example:` is a fresh frozen indifferent-access copy that `validate:` cannot reach. `with_default` reads a `:json` expectation the same way, so either key spelling matches. (#123)
31
+ - **A private `render_error` is delegated to.** `render_envelope` checked `respond_to?(:render_error)`, which is false for a private method, so an app that follows the Rails convention of private helpers on `ApplicationController` got the gem's inline `{success:, error:}` envelope instead of its own body, with no warning. A private `render_error` is now delegated to when it takes `message:`, `code:` and `status:` (by name or through `**kwargs`) and requires nothing else: no required positional argument and no required keyword outside `message`, `code`, `status` and `errors`. A private helper with another signature, such as `render_error(message, status)` or `render_error(message:, status:)`, is still left alone. So is a private one that cannot be inspected (only `respond_to?(:render_error, true)` answers it). Apps with such a helper now get their own body instead of the gem's envelope. (#150)
32
+
33
+ ### Fixed
34
+ - **The README's JSON Schema table lost its `finalize` row in 0.11.1.** #133 added the row that says a rule's `finalize` block is exported as `x-permittable-finalize: true`, on the rule's object (inside the `root:` wrapper) and on the operation. Resolving the conflict with #142's `sensitive: true` row in the 0.11.1 merges dropped it. It is restored as written. Documentation only. (#152)
35
+
36
+ ## 0.11.1 (2026-10-18)
37
+ <!-- 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 -->
38
+
39
+ 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.
40
+
41
+ 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.
42
+
43
+ ### Security
44
+ - **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)
45
+ - **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)
46
+ - **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)
47
+
48
+ ### Fixed
49
+ - **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)
50
+ - **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)
51
+ - **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)
52
+ - **`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)
53
+ - **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)
54
+ - **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)
55
+ - **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)
56
+ - **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)
57
+ - **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)
58
+ - **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)
59
+ - **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)
60
+ - **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)
61
+ - **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)
62
+ - **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)
63
+ - **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)
64
+ - **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)
65
+ - **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)
66
+ - **`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)
67
+ - **`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)
68
+ - **`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)
69
+ - **`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)
70
+ - **`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)
71
+ - **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)
72
+ - **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)
73
+ - **`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)
74
+ - **`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)
75
+ - **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)
76
+
77
+ ### Changed
78
+ - **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)
79
+ - **The README has a complete example that uses every option.** Documentation only; no behaviour changes. (#92)
80
+
81
+ ### Test suite
82
+ - **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)
83
+ - **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)
84
+
3
85
  ## 0.11.0 (2026-10-05)
4
86
  <!-- title: an audit ignore list, route-order-stable operationIds, and routes read the way Rails dispatches them -->
5
87