permittable 0.11.1 → 0.13.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: be564cf1f5aa7d54ad207d4a32ef3327a7a03346bd3945bdcdcc41eb6f96cca4
4
- data.tar.gz: 24c723ef714fc5c092ecd243c3391f8aecdc0399454c6af8cafeb0efff750e7c
3
+ metadata.gz: 7dc4908fbfa3e6b5e58441e97f3a1f73415a779447b06fd0d42b4a6fdc4154e4
4
+ data.tar.gz: 776776cac3cfd9288af6f591c96288acabb82707002017964a1ecee6b18dd9aa
5
5
  SHA512:
6
- metadata.gz: da85ce6eb68d7b4a2225dd35b8b43d5e8e3f2bb5ccd9ea03a877b25bba8f7f18f51ecc3e720b0f8a3d68f6e882ef47e955ac293ad513c61ec232a5a1b82c505a
7
- data.tar.gz: 8401cd45a001ddec34c642af47f2aa6cab961beba5632e952f5868e56f0add46635973934a83c7f385f05682fa5f9f2ad5ee1d5a14800c2f5085bbd6c894d14a
6
+ metadata.gz: 7f045ce704da2e70e4a1a85a4b7b8cdd2f5099733619779655885fc820b1fa03b6f4d99a7461e492d01147c15cc545fa268e46b11f13bfbd640cb334d3b98ff8
7
+ data.tar.gz: c82a67ca14285e04c35b218cd363f8280f2876e09faec983ded0d4aa81e30626d60e3fd67211ee0588e7f2b25a82da368ac97aac1f1ab505000256c1f5873e25
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.13.0 (2026-10-24)
4
+ <!-- title: the audit and the OpenAPI export follow mounted engines and Metal controllers, and a path variable is documented as one -->
5
+
6
+ Two changes to what the audit and the OpenAPI export read, and one to how the export documents path variables. `permittable:audit`, `permittable:openapi` and `permittable:generate` now see controllers built directly on `ActionController::Metal`. The audit and the export follow routes mounted from a Rails engine, with the mount's path in front of the engine's. A rootless contract field that a route fills from the URL is documented as the path parameter it is.
7
+
8
+ Minor rather than patch: there is no runtime change, `OpenAPI.rails_routes` callers now get more descriptors and a descriptor's `route:` may be an Array, but the audit and the OpenAPI document now see more, so `permittable:audit[strict]` can newly fail in an app that has an unguarded Metal controller or an unguarded route in a mounted engine, and an exported document changes for any app with a mounted engine, a Metal controller or a route-filled field.
9
+
10
+ ### Added
11
+ - **`OpenAPI.rails_routes(app, engines: false)` reads the app's own route set alone.** `engines:` is a new keyword and defaults to `true`, which follows mounted engines (see the first entry under **Changed**). (#144)
12
+
13
+ ### Changed
14
+ - **The audit and the OpenAPI export follow mounted Rails engines.** `OpenAPI.rails_routes` read only the app's own route set, so with `mount Billing::Engine => "/billing"` an unguarded `POST /billing/refunds` was never listed and `[strict]` exited 0, a covered `billing/invoices#create` was reported stale, and the export put billing operations under `x-permittable-controllers` instead of `paths`. `rails_routes(app, engines: true)` now follows each mounted engine, nested ones too, and puts the mount's path in front of the engine's, so a mount under `scope "(:locale)"`, at a dynamic `":tenant"`, or at `/` expands like any route. Routes reached through a mount carry `route: [mount, route]`, with one index per mount level (e.g. `[3, 0, 5]`), so `route:` may be an Array. Dispatch follows Rails: a mount ending in an optional segment lists no engine path Rails sends elsewhere, shadowing between app and engine routes follows Rails' single route order with each mount expanded in place, and a mount's `via:` drops the engine's other verbs. Rack mounts that are not engines are still skipped, a recursion guard stops cycles, and mounted gem engines appear in the audit's "outside the app root" section by default (while `Permittable.audit_ignore_outside_root` is on), without affecting `[strict]`. Callers that concatenate an app's and an engine's lists themselves should pass `engines: false`, which restores the old output. A parameter name used by both the mount and an engine route is not renamed: the path names it twice (`/t/{id}/{id}`) and the operation declares that one path parameter once. An engine route whose optional groups can all be absent (`post "(:locale(/:region))"`) is not documented as the mount's own path, which Rails answers with 404, and a lambda-constrained mount is still listed. (#144)
15
+ - **`permittable:audit`, `permittable:openapi` and `permittable:generate` read `ActionController::Metal` controllers.** Each task collected controllers from `ActionController::Base.descendants` and `ActionController::API.descendants` only, so a lean webhook controller built on `Metal` was invisible: its unguarded `POST /hooks` had no audit row and did not fail `[strict]`, and one that included Permittable had its contracts enforced at runtime but missing from the export. All three tasks now read `Audit.rails_controllers`, which returns every named `ActionController::Metal` descendant less the framework's own abstract `ActionController::Base` and `ActionController::API`. (#108)
16
+
17
+ ### Fixed
18
+ - **A rootless field a route fills from the URL is documented as the path parameter.** Rails merges the router's path parameters over the request's, so a rootless `required :id, :integer, in: 1..1000` on `resources :widgets, only: :update` is read from the URL, but the export gave the path parameter `{"type":"string"}` and put `id` in the request body's `required` list. The field now becomes the path parameter's schema, which stays required, and leaves the body's `properties` and `required`. Only a rootless contract does this, only for a scalar field, and only for a name the route's own path templates; an array, nested or `:json` field named like a variable stays in the body. With `unknown: :error`, every name the route templates stays in the body as an unconstrained, never-required property, so `{"id":7,"name":"x"}` is not documented as invalid. An action whose every field moved has no request body. A moved field loses `default:` and `nullable:`. A route that shares another's path pairs its variable names with the placed ones by position. (#136)
19
+
20
+ ## 0.12.0 (2026-10-21)
21
+ <!-- title: stricter numbers, dates and class-load checks, and matchers that stop passing for the wrong reason -->
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ ### Changed
28
+ - **`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)
29
+ - **`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)
30
+ - **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)
31
+ - **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)
32
+ - **`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)
33
+ - **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)
34
+ - **`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)
35
+ - **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)
36
+ - **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)
37
+ - **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)
38
+ - **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)
39
+ - **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)
40
+ - **`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)
41
+ - **`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)
42
+ - **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)
43
+ - **`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)
44
+ - **`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)
45
+ - **`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)
46
+ - **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)
47
+ - **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)
48
+ - **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)
49
+
50
+ ### Fixed
51
+ - **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)
52
+
3
53
  ## 0.11.1 (2026-10-18)
4
54
  <!-- 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
55
 
data/README.md CHANGED
@@ -608,7 +608,7 @@ Which options are legal depends on the field kind — anything else raises at cl
608
608
 
609
609
  | Option | Scalar | Array | Nested | Meaning |
610
610
  |---|:---:|:---:|:---:|---|
611
- | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request, so it may be empty when the class loads: a live list filled in an initializer or `after_initialize` is fine, and for the same reason a `default:` or `example:` is not checked against it at class load, though every other rule still is) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
611
+ | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (an `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works — or a subclass of one that keeps its `include?`, such as the `ActiveSupport::OrderedOptions` that `config_for` returns), or any other object answering `include?` (used as given, and read on every request, so it may be empty when the class loads: a live list filled in an initializer or `after_initialize` is fine, and for the same reason a `default:` or `example:` is not checked against it at class load, though every other rule still is) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
612
612
  | `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
613
613
  | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
614
614
  | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
@@ -675,10 +675,10 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
675
675
  |---|---|---|
676
676
  | `:string` | `String`, returned in the encoding it arrived in; `Numeric`/`true`/`false` are stringified | Arrays, hashes, a `String` whose bytes are invalid in its own encoding (`"caf\xC3"`) |
677
677
  | `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
678
- | `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
679
- | `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Rational` no decimal holds exactly (`Rational(1, 3)`) |
678
+ | `:float` | a real `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "`, and any number outside a Float's range, as a `Numeric` or a string alike (`BigDecimal("1e-400")`, `"1e400"`), a `Complex` (even `Complex(1, 0)`), a `Numeric` that cannot be converted |
679
+ | `:decimal` | a real `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Complex`, a `Rational` no decimal holds exactly (`Rational(1, 3)`), a `Numeric` that cannot be converted |
680
680
  | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
681
- | `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
681
+ | `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant — one with a time of day, or a midnight in another zone |
682
682
  | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
683
683
  | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
684
684
 
@@ -686,14 +686,14 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
686
686
 
687
687
  **Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
688
688
 
689
- **Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal` has no exponent limit, so `"1e400"` is fine there — but `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
689
+ **Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal`'s exponent range is far wider, so `"1e400"` is fine there, though not unlimited: a string past it (`"1e-99999999999999999999"`) underflows to zero or overflows to Infinity and is `invalid_type` by the same rule. A string whose plain form would run past a thousand digits and past its own length (`"1e4000000000000000000"`, 22 bytes) is `invalid_type` too, because formatting it to save, render or log it would exhaust memory; a number spelled out digit by digit is accepted however long it is. And `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
690
690
 
691
691
  **Numbers are spelled the way a client spells them.** `Integer()`, `Float()` and `BigDecimal()` all read underscore digit separators and surrounding whitespace — conveniences for a number literal in Ruby source — so `"1_8"` would be eighteen and `" 99 "` ninety-nine. A numeric string must instead be canonical: an optional sign, digits, an optional fraction and an optional exponent (`"-12"`, `"007"`, `".5"`, `"1.5e10"`). Anything else is `invalid_type`.
692
692
 
693
693
  Two more behaviours worth committing to memory:
694
694
 
695
695
  - **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
696
- - **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
696
+ - **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted. A zone the parser cannot honour — an offset of a day or more (`+25:00`), or an abbreviation it does not know — is `invalid_type` rather than read as UTC, and dates are read in the proleptic Gregorian calendar ISO 8601 uses, so `"1500-01-01"` is that day whatever the host has loaded.
697
697
 
698
698
  ### Free-form hashes (`:json`)
699
699
 
@@ -726,7 +726,7 @@ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{
726
726
 
727
727
  ### Absence, defaults, and partial updates
728
728
 
729
- `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`.
729
+ `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`. The same goes for a `""` **element** of a scalar `array ..., of:`: it is dropped before casting and before `length:` counts, because `collection_check_boxes` and `select multiple: true` always send a hidden `""` so that unchecking everything still sends the key. A `null` element, which no form sends, is still `invalid_type`.
730
730
 
731
731
  That single rule produces the behaviour you want from a `PATCH`:
732
732
 
@@ -790,7 +790,7 @@ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
790
790
 
791
791
  **Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
792
792
 
793
- **Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when the method can take it (it names `errors:`, takes `**kwargs`, or declares no keywords at all), so hosts documenting a three-keyword `(message:, code:, status:)` contract keep working. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
793
+ **Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when the method can take it (it names `errors:`, takes `**kwargs`, or declares no keywords at all), so hosts documenting a three-keyword `(message:, code:, status:)` contract keep working. A **private** `render_error`, the usual spelling on an `ApplicationController` so it never becomes a routable action, is delegated to as well when it takes `message:`, `code:` and `status:` and requires nothing else; a private one with some other signature (`render_error(message, status)`, or `render_error(message:, status:)`) is left alone. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
794
794
 
795
795
  ### Custom error messages (`message:`)
796
796
 
@@ -904,10 +904,14 @@ The setting is app-wide, not per-contract, because the error format of an API is
904
904
 
905
905
  Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
906
906
 
907
- Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
907
+ Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but every name the route templates stays in it as a property that takes any value and is never required, declared or not: `PATCH /users/1` with `{"id":1,"name":"x"}` is accepted, because the path parameter is exempt and the URL's `id` overwrites the body's. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
908
908
 
909
909
  All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
910
910
 
911
+ **Rails' `date_select`, `datetime_select` and `time_select`** submit one value as several keys, each named for its position: `starts_on(1i)` is the year, `(2i)` the month, `(3i)` the day, `(4i)` the hour, `(5i)` the minute and `(6i)` the second. A `:date` or `:datetime` field reads these parts as its own value whenever its own key is absent. `permitted_params` carries `"starts_on" => Date`, never the parts, and `unknown:` does not report the parts it read. The parts are assembled into the ISO 8601 string the field already accepts (`"2026-10-17"`, or `"2026-10-17T09:30:00"` with seconds `00` unless `(6i)` is sent), and that string goes through the field's cast and rules like any other value. A `:date` or `:datetime` needs `(1i)`–`(3i)`; a `:datetime`'s `(4i)`, `(5i)` and `(6i)` are optional. As in ActiveRecord, the field reads as if it were sent empty (absent, or an explicit null when it is `nullable:`) when any one of `(1i)`–`(3i)` is blank, which is what a select left on its `prompt:` or `include_blank:` sends. It is any one, not all of them, because Rails sends a part it discards through a hidden input: `date_select(:event, :on, discard_day: true, prompt: true)` always sends `on(3i)=1`, so a month and year picker nobody touched arrives as `""`, `""`, `"1"` and is still no date. Once the date is there, a blank or missing `(4i)`, `(5i)` or `(6i)` reads as `00`, as ActiveModel reads it, so `date_select` on a `:datetime` field (which sends the date alone) and `datetime_select(discard_hour: true)` give midnight, as they do in ActiveRecord: a `time_select` left on its prompts for an empty value sends the hidden date `1`, `1`, `1`, and gives `0001-01-01T00:00:00`, as it does in ActiveRecord. A part is a String of digits or a non-negative Integer (a JSON body cannot send the String a form does). Any other part, a missing `(1i)`–`(3i)` that has no blank part beside it (a blank part among those sent reads the field as sent empty first, whichever of the others is missing, as it does in ActiveRecord) or a part that is not plain digits (`"Oct"`, `"-1"`, `"1.5"`, a Float, a nested hash or array), is `invalid_type` on the field itself (`event.starts_on`). `time_select`'s `ignore_date: true` is not supported: it sends only `(4i)` and `(5i)`, a `:datetime` needs a date, and the field is `invalid_type`. If a request sends both `starts_on` and its parts, `starts_on` wins the value and the parts are left alone: they are ordinary undeclared keys, so `unknown: :error` flags them (`event.starts_on(1i)`), `:log` logs them and `:ignore` drops them. They are not hidden from `unknown:`, because a legacy action that still calls `permit(:starts_on)` hands them to ActiveRecord, which assigns them after the plain key. Only a declared `:date` or `:datetime` field reads parts (a `:date` has no `(4i)`); for any other name, they are ordinary undeclared keys. When monitor mode passes a rejected request through raw, the parts come back as they were sent.
912
+
913
+ The parts carry no time zone, so a datetime read from them is UTC, like any zoneless string ([see above](#types-and-strict-coercion)). ActiveRecord reads the same parts in `Time.zone`. So in an app with `config.time_zone = "Tokyo"`, a form showing 09:30 gives the contract 09:30 UTC, where strong parameters would have stored 00:30 UTC. Such an app can read the wall-clock time in its own zone with `transform: ->(t) { Time.zone.local(t.year, t.month, t.day, t.hour, t.min, t.sec) }`. Use that only where clients send zoneless times: a string with an explicit offset has already been converted to UTC, and the transform would shift it again.
914
+
911
915
  ### Reusing fields (`Permittable.fields` and `use`)
912
916
 
913
917
  A growing API produces two kinds of duplication: the `address` block three controllers want, and the `update` contract that is the `create` contract with nothing mandatory. A **field group** is a reusable field list — the same frozen data a contract's fields are, without the contract around them.
@@ -934,18 +938,20 @@ class UsersController < ApplicationController
934
938
  use UserFields
935
939
  end
936
940
 
937
- # PATCH: the same fields, nothing mandatory.
941
+ # PATCH: the same fields, nothing mandatory, no defaults.
938
942
  permit_params :update, root: :user, model: User do
939
943
  use UserFields, optional: true
940
944
  end
941
945
  end
942
946
  ```
943
947
 
948
+ The defaults go because a default is filled in for every request that omits the field. On a `PATCH` that would overwrite the column the client left alone: with `plan`'s `default: "free"` kept, `PATCH {"user": {"name": "Jo"}}` would hand the action `{"name" => "Jo", "plan" => "free"}` and downgrade every `"pro"` user who renamed themself. The exported schema of the update rule leaves them out too.
949
+
944
950
  `use` splices the group in **at the point of use**, in the group's own order, exactly as if the fields had been typed there — so the request-time behaviour, the [drift guard](#the-schema-drift-guard), `sensitive:` registration and the [exported schema](#exporting-openapi-docs-that-cannot-drift) are all identical to the inline spelling. It works at the top level of a contract, inside a nested or array block, and inside another group.
945
951
 
946
952
  | Option | Meaning |
947
953
  |---|---|
948
- | `optional: true` | Relax every spliced field. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields still hold. Types, bounds and `default:` are untouched, so `use UserFields, optional: true` is a complete `PATCH` contract |
954
+ | `optional: true` | Relax every spliced field: nothing is mandatory, and no `default:` is filled in. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields and defaults still hold. Types and bounds are untouched, so `use UserFields, optional: true` is a complete `PATCH` contract |
949
955
  | `only:` / `except:` | Select a subset, in the group's own order. Mutually exclusive |
950
956
 
951
957
  Because a group is built by the same builder a contract is, **every declaration is validated when the group is defined** — a typo fails once, at the group, instead of at each contract that uses it. Two more things fail at class load rather than silently: `only:`/`except:` naming a field the group doesn't declare (so a typo can't quietly drop a field), and a field declared twice. That last one makes overriding deliberate:
@@ -957,7 +963,7 @@ permit_params :create do
957
963
  end
958
964
  ```
959
965
 
960
- A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one definition instead of two that drift.
966
+ A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one set of fields instead of two that drift. Only the fields: `use` cannot carry a contract's `finalize`, so using a contract that declares one raises at class load rather than silently dropping its cross-field rule. To share both, declare the fields in a `Permittable.fields` group that the contract and the controller each `use`, and declare `finalize` in each; passing one proc to both as `finalize(&EndsAfterStart)` keeps the rule itself in one place.
961
967
 
962
968
  ### Output reshaping (`transform:` and `finalize`)
963
969
 
@@ -1038,6 +1044,8 @@ When enabled it compares **groups**, not exact types, so it fires on a genuine c
1038
1044
 
1039
1045
  Any column type **not** in that table — `json`, `jsonb`, `binary`, an adapter's own `inet` or `money` — is never checked. A contract has no faithful type for those, so whatever you improvised is left alone rather than guessed about.
1040
1046
 
1047
+ **A PostgreSQL array column is checked for its shape.** Rails reports `t.string :tags, array: true` as a `string` column, so a scalar `optional :tags, :string` sits in the right group and would pass — yet it rejects as `invalid_type` every array the column stores. A scalar or `:json` declaration over an array column fails at class load and names the fix, `array :tags, of: :string`.
1048
+
1041
1049
  **A Rails `enum` is compared by what clients send, not what the column stores.** An enum is submitted by name — `status: "shipped"` — so `optional :status, :string, in: Order.statuses.keys` is the right contract for an integer-backed enum, and a text declaration on any attribute in the model's `defined_enums` is not held to the column's group. It **must** carry that `in:`, though: assignment raises `ArgumentError` for a value the enum does not map, so without one `status: "bogus"` would pass the contract and become a 500 in the action. A text declaration with no `in:`, or with an `in:` listing anything the enum would refuse, fails at class load:
1042
1050
 
1043
1051
  ```
@@ -1201,7 +1209,7 @@ A scanned draft keeps the root its permit call names (a rootless `params.permit(
1201
1209
  The generator's one rule is **draft, don't guess** — everything it cannot know for sure stays visible instead of silently decided:
1202
1210
 
1203
1211
  - Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
1204
- - A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
1212
+ - A permitted key that isn't a column becomes `virtual: true` with a TODO; a PostgreSQL array column drafts as `array :tags, of: :string` with a `length:` TODO (an array of `json`/`jsonb` elements, which `of:` cannot express, is a TODO, and so is a `json`/`jsonb` column whose default is an array, since `:json` accepts only an object); a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
1205
1213
  - Drafted from the columns alone, `created_at`/`updated_at` and a primary key the database generates are left out: Rails or the database assigns them. A generated key is an auto-increment column (as the adapter reports it, even as one part of a composite key) or one with a default or default function, such as a uuid. A permit call that **lists** one keeps it, typed from its real column (`optional :created_at, :datetime`) and always `optional`, even though the column is NOT NULL — it is a column like any other, so the schema-drift guard stays on for it. A primary key the client must send is no such column: a natural key (`sku`, from `create_table :items, primary_key: :sku, id: :string`) or a part of a composite key that is not auto-increment is drafted like any other, `required` on `:create` when it is NOT NULL with no default.
1206
1214
  - **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
1207
1215
  - **Parentheses are optional.** A call is read with or without them (`permit :name, :email`, `expect user: [...]`), to the end of its statement, and a root spelled as a String (`require("user")`) is read like a Symbol.
@@ -1233,7 +1241,7 @@ No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.f
1233
1241
 
1234
1242
  A controller declaring `permit_params :create` looks adopted. If it also answers `PATCH`, that action is validating **nothing** — and until now nothing in the gem said so. [`permittable:generate`](#generating-draft-contracts-permittablegenerate) only notices controllers with no contract at all, and the [OpenAPI export](#exporting-openapi-docs-that-cannot-drift) documents what exists rather than what is missing.
1235
1243
 
1236
- The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one:
1244
+ The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one. It reads every controller a route can reach: `ActionController::Base` and `::API` subclasses, and bare `ActionController::Metal` ones such as a lean webhook endpoint. The export and the generator read the same list.
1237
1245
 
1238
1246
  ```sh
1239
1247
  bin/rails permittable:audit # the table plus a summary
@@ -1280,7 +1288,7 @@ A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found",
1280
1288
 
1281
1289
  Under `[strict]` the task aborts on these rows. Either route the catch-all GET-only, as above, or add `application#not_found` to the [ignore list](#ignoring-controllers-and-actions). Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
1282
1290
 
1283
- The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call, so audit concatenated route lists (an app's and an engine's) separately, or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
1291
+ The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call (a route reached through a mounted engine is `[mount, route]`; see below), so a caller that concatenates route lists itself should pass `engines: false` for each, and audit them separately or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
1284
1292
 
1285
1293
  Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails. It takes `ignore:` and `root:` too, which default to `Permittable.audit_ignore` and `Rails.root` (see below).
1286
1294
 
@@ -1304,7 +1312,9 @@ An entry is a controller path the way Rails routes it (the controller half of `b
1304
1312
 
1305
1313
  Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
1306
1314
 
1307
- **Controllers outside `Rails.root` are ignored by default.** These are engines and gems: ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
1315
+ **Mounted engines are part of the app.** `mount Billing::Engine => "/billing"` makes every route the engine draws a URL of the app, so `rails_routes` follows each mounted Rails engine (nested ones too) and puts the mount's path in front of the engine's: an unguarded `POST /billing/refunds` fails `[strict]`, and a covered action in the engine is not reported as stale. A mount at a dynamic `at: ":tenant"`, or under `scope "(:locale)"` at `/blog`, joins like any other path. A mount whose path ends in an optional segment (`scope "(:locale)"` over a mount at `/`, or `at: "/m(/:slug)"`) lists the engine's routes with the segment only, as Rails does: it matches the mount's own path first, the segment present before absent, so `/posts` is the locale `posts` asking for the engine's root, and the engine's `/posts` is reached as `/{locale}/posts`. A constrained segment (`locale: /en|fr/`) keeps both. A mount's `via:` limits the verbs: `mount Shop::Engine, at: "/g", via: :get` leaves out the engine's `POST` routes, which answer 404. An engine mounted twice is two routes to its actions. Routes are judged in Rails' order with the mount standing where it is drawn: an app route ahead of the mount answers before the engine is asked, an engine route that matches answers and hides an app route behind the mount, and a request the engine has no route for goes on to the next route. A constrained mount, like any constrained route, hides no route behind it. A Rack mount that is not an engine (Sidekiq::Web, a lambda) has no routes to read and is skipped. `Permittable::OpenAPI.rails_routes(app, engines: false)` reads the app's own route set alone. One limit: a parameter name used by both the mount and an engine route (`/{id}/.../{id}`) is not renamed, so the template names it twice (`/t/{id}/{id}`) and the operation declares that one path parameter once.
1316
+
1317
+ **Controllers outside `Rails.root` are ignored by default.** These are gems and engines installed as gems (an engine kept inside your app counts as yours): ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
1308
1318
 
1309
1319
  ```ruby
1310
1320
  Permittable.audit_ignore_outside_root = false
@@ -1359,7 +1369,7 @@ end
1359
1369
 
1360
1370
  Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`, cast by the field's type just as the contract's list is, so `within(%i[draft published])` repeats the declaration as written), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).
1361
1371
 
1362
- The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`.
1372
+ The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`. An `[n]` must index what the runtime indexes: an array field, once, since an array is one level deep. So `"address[0].zip"` on a nested `address` block fails, and its negated form passes, because the runtime refuses an Array there.
1363
1373
 
1364
1374
  It reads the contract, not the runtime mode. In enforce mode an undeclared key never reaches `permitted_params` — it is dropped from it, or the request is rejected under `unknown: :error` — so "not declared" means "not permitted" for code that reads `permitted_params`. The raw `params` still carries every key, as it always does. A rule in [monitor mode](#monitor-mode-roll-out-without-rejecting) is different: `permitted_params` hands back the raw params, so an undeclared `:admin` comes through it until the rule is switched to enforce. The matcher does not fail for that, because a monitor-mode rule is a rollout stage and the spec describes the contract being rolled out; the enforce switch is what makes the assertion hold at runtime.
1365
1375
 
@@ -1378,7 +1388,7 @@ expect(described_class).to reject_params(user: { name: "Jo", email: "nope" })
1378
1388
  .for_action(:create).with_violation("user.email", :format)
1379
1389
  ```
1380
1390
 
1381
- `returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional.
1391
+ `returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional. Each belongs to one matcher: `returning` to `accept_params` and `with_violation` to `reject_params`, and chaining either onto the other raises `ArgumentError` rather than being silently ignored.
1382
1392
 
1383
1393
  Failure messages name what actually happened:
1384
1394
 
@@ -1390,6 +1400,10 @@ expected UsersController to reject those params with user.age (inclusion), but t
1390
1400
 
1391
1401
  Both work on a controller class, a controller instance, or a [standalone `Contract`](#standalone-contracts-no-controller), and both read the **contract** rather than the rollout mode — a [monitor-mode](#monitor-mode-roll-out-without-rejecting) rule still `reject_params`, because the question is what the contract says, not what the deploy currently does with it.
1392
1402
 
1403
+ The negated forms follow the same rules as `not_to permit_param`: `not_to accept_params(...)` passes only when the contract rejects the payload, and `not_to reject_params(...)` only when it accepts it. Neither takes a qualifier — `not_to accept_params(...).returning(x)` would pass both on a rejection and on an acceptance returning anything else, so it raises and names the positive form — and both fail, rather than passing, when no rule covers the action or the subject declares no contracts.
1404
+
1405
+ The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`.
1406
+
1393
1407
  ### Standalone contracts (no controller)
1394
1408
 
1395
1409
  The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
@@ -1445,7 +1459,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1445
1459
 
1446
1460
  Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
1447
1461
 
1448
- **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1462
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string` unless the contract says more, because the route set doesn't say what an `:id` is and the exporter won't invent it: a rootless contract's *scalar* field of the same name is that parameter's schema (see [Unknown parameters](#unknown-parameters) for what that does to the request body), minus any `default:` and `nullable:`, since the URL always supplies a non-null value. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1463
+
1464
+ **Mounted engines are documented under their mount point.** The task follows each `mount Billing::Engine => "/billing"` into the engine's routes, so an engine's operations land in `paths` as `/billing/...` (a dynamic mount point becomes a `{tenant}` template). A Rack mount that is not an engine is skipped. See [Auditing coverage](#auditing-coverage-permittableaudit) for the details and the one limit on shared parameter names.
1449
1465
 
1450
1466
  **Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. Rails squeezes a run of slashes to one before it matches, so a variant an absent leading group leaves with two in a row is no URL: `get "(:locale)(/:theme)"` documents `/`, `/{locale}` and `/{locale}/{theme}`, never `//{theme}`, and `get "(:locale(/:region))"` documents no `/`, which Rails answers with a 404. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
1451
1467
 
@@ -1507,6 +1523,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1507
1523
  | nested block / `array` | `object` + `properties` / `array` + `items` |
1508
1524
  | `unknown: :error` | `additionalProperties: false`, at every nesting level |
1509
1525
  | `root:` | the wrapping object, itself required |
1526
+ | `finalize` | `x-permittable-finalize: true` on the rule's object (inside the `root:` wrapper) and on the operation; the cross-field checks themselves are opaque |
1510
1527
  | `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published, and so are those of an array that holds it at any depth, since they would carry its value, and so are its `in:` (list or Range) and a hand-written `format:` regexp, which would publish the secret itself. A field that declares either is flagged `x-permittable-custom-validation`, so the schema is looser than the server there; a `format:` preset such as `:email` is public and is still published |
1511
1528
 
1512
1529
  </details>
@@ -1572,9 +1589,10 @@ A bad contract is a programmer error, so it fails when the class loads — never
1572
1589
  - An unknown `normalize:` or `format:` preset, listing the presets
1573
1590
  - A `format:` that is neither a `Regexp` nor a preset name
1574
1591
  - `format:`, `length:`, or `normalize:` on a non-`:string` field
1575
- - `length:` that isn't a non-negative `Integer` or a `Range`; an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
1592
+ - `length:` that isn't a non-negative `Integer` or a `Range`, or a `Range` with a negative or fractional end (`..-1`, `-2..3`, `1.5..3.5`; a whole-number Float such as `1..10.0` is fine; leave an end off, or use `Float::INFINITY`, for no bound); an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
1576
1593
  - An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
1577
1594
  - A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
1595
+ - A `Time` `in:` member on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal it
1578
1596
  - `validate:` or `transform:` that isn't callable
1579
1597
  - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
1580
1598
  - A `default: nil` or `example: nil` on a field that isn't `nullable:`
@@ -142,6 +142,22 @@ module Permittable
142
142
  end
143
143
  end
144
144
 
145
+ # Every controller class a Rails route can reach, for the rake tasks:
146
+ # ActionController::Metal and everything below it. The tasks used to
147
+ # collect ActionController::Base and ::API descendants only, so a bare
148
+ # Metal controller — the usual shape of a lean webhook endpoint, and a
149
+ # host this gem supports — had no audit row, never counted against
150
+ # [strict], and its contracts were missing from the export. Base and API
151
+ # themselves are Metal descendants too; they are the framework's abstract
152
+ # bases, which no route names, and the old lists never held them.
153
+ # Anonymous classes cannot be routed to, so only named ones are kept.
154
+ def rails_controllers
155
+ return [] unless defined?(::ActionController::Metal)
156
+
157
+ frameworks = %w[ActionController::Base ActionController::API]
158
+ ::ActionController::Metal.descendants.uniq.select { |controller| controller.name && !frameworks.include?(controller.name) }
159
+ end
160
+
145
161
  # The root a controller's source must be inside to be audited:
146
162
  # Rails.root, unless Permittable.audit_ignore_outside_root is off — or nil
147
163
  # without Rails, where there is no app to be outside of.
@@ -1,7 +1,8 @@
1
1
  module Permittable
2
- # The request walker, pointed at an authored array `default:`/`example:`
3
- # at class load — the same permittable_check_array a request goes
4
- # through, so the two cannot drift apart. Two seams are overridden:
2
+ # The request walker, pointed at an authored array or `:json`
3
+ # `default:`/`example:` at class load — the same permittable_check_array
4
+ # and permittable_check_json a request goes through, so the two cannot
5
+ # drift apart. Two seams are overridden:
5
6
  #
6
7
  # * the field WHOSE default:/example: is being validated does not run
7
8
  # its own `transform:` — see permittable_transform below for exactly
@@ -26,6 +27,10 @@ module Permittable
26
27
  new.read_array(field, value, as_sent: as_sent)
27
28
  end
28
29
 
30
+ def self.read_json(field, value)
31
+ new.read_json(field, value)
32
+ end
33
+
29
34
  def self.summary(violations)
30
35
  new.summary(violations)
31
36
  end
@@ -40,6 +45,15 @@ module Permittable
40
45
  [read, violations]
41
46
  end
42
47
 
48
+ # [:ok, value as a request would get it] or [:error, code]. A request's
49
+ # hash reaches permittable_check_json indifferent, as every Hash inside
50
+ # the walker's params is, so the authored one is made so first, the way
51
+ # the walker reads a nested hash. permittable_check_json then hands
52
+ # `validate:` a deep copy, as it does a request's, and returns that copy.
53
+ def read_json(field, value)
54
+ permittable_check_json(field, ActiveSupport::HashWithIndifferentAccess.new(value))
55
+ end
56
+
43
57
  def summary(violations)
44
58
  permittable_violation_summary(violations)
45
59
  end
@@ -28,6 +28,15 @@ module Permittable
28
28
  date: :temporal, datetime: :temporal, time: :temporal, timestamp: :temporal, timestamptz: :temporal
29
29
  }.freeze
30
30
 
31
+ # The contract type an `array ..., of:` takes for a column element type,
32
+ # for the array-column hint. Only scalar element types: `of:` takes no
33
+ # other, so a jsonb[] column gets the hint without an `of:`.
34
+ ARRAY_ELEMENT_TYPES = {
35
+ string: :string, text: :string, citext: :string, uuid: :string,
36
+ integer: :integer, bigint: :integer, float: :float, decimal: :decimal, boolean: :boolean,
37
+ date: :date, datetime: :datetime, timestamp: :datetime, timestamptz: :datetime
38
+ }.freeze
39
+
31
40
  module_function
32
41
 
33
42
  # `types:` teaches the error message: a Symbol/String applies to every
@@ -60,6 +69,8 @@ module Permittable
60
69
  column = klass.columns_hash[field.to_s]
61
70
  return unless declared && column
62
71
 
72
+ ensure_not_array_column!(label, klass, field, declared, column)
73
+
63
74
  wanted = TYPE_GROUPS[declared.to_sym]
64
75
  enum = enum_attribute?(klass, field)
65
76
  # Checked before the column's type, which an enum's contract does not
@@ -92,6 +103,24 @@ module Permittable
92
103
  "(table: #{klass.table_name}). #{fix}"
93
104
  end
94
105
 
106
+ # A PostgreSQL array column reports its ELEMENT type as `type`, so
107
+ # `optional :tags, :string` over `t.string :tags, array: true` sits in
108
+ # the same group as the column and the group check passes — yet the
109
+ # field rejects as invalid_type every array the column stores. Only a
110
+ # scalar or :json field reaches this guard (an `array` field is
111
+ # implicitly virtual), and neither accepts an Array, so any declaration
112
+ # over an array column is the wrong shape.
113
+ def ensure_not_array_column!(label, klass, field, declared, column)
114
+ return unless column.respond_to?(:array?) && column.array?
115
+
116
+ element = ARRAY_ELEMENT_TYPES[column.type&.to_sym]
117
+ spelled = element ? "array :#{field}, of: :#{element}" : "array :#{field}"
118
+ raise ArgumentError,
119
+ "#{label}: '#{field}' is declared :#{declared} but the column is an array#{" (of :#{column.type})" if column.type} " \
120
+ "(table: #{klass.table_name}), so every array it stores would be rejected as invalid_type. " \
121
+ "Declare it `#{spelled}`, or declare the field virtual: true if it is not backed by this column."
122
+ end
123
+
95
124
  # `model:` is only duck-typed on column_names, hence the respond_to?.
96
125
  def enum_attribute?(klass, field)
97
126
  klass.respond_to?(:defined_enums) && klass.defined_enums.key?(field.to_s)
@@ -108,7 +108,10 @@ module Permittable
108
108
  # The contract's fields, which is also what makes a Contract usable as a
109
109
  # field group: `use SomeContract` inside a permit_params block splices
110
110
  # them in, so a webhook payload and a controller action can share one
111
- # definition instead of two that drift.
111
+ # set of fields instead of two that drift. Only the fields — `use`
112
+ # refuses a contract that declares a finalize, which no field carries.
113
+ # To share both, put the fields in a `Permittable.fields` group that
114
+ # each side uses, and declare finalize in each.
112
115
  def fields
113
116
  rule[:fields]
114
117
  end
@@ -3,7 +3,8 @@ module Permittable
3
3
  #
4
4
  # `:envelope` (the default) is the gem's original shape: the host's
5
5
  # #render_error when the controller defines one (e.g. concerns_on_rails'
6
- # Respondable), otherwise the identical inline JSON.
6
+ # Respondable; see host_render_error for a private one), otherwise the
7
+ # identical inline JSON.
7
8
  #
8
9
  # `:problem` renders RFC 9457 Problem Details — `application/problem+json`
9
10
  # with type/title/status/detail/instance members and the field violations as
@@ -44,8 +45,9 @@ module Permittable
44
45
  end
45
46
 
46
47
  def render_envelope(controller, message:, status:, code: nil, details: nil)
47
- if controller.respond_to?(:render_error)
48
- host = host_method(controller)
48
+ public_host = controller.respond_to?(:render_error)
49
+ host = public_host ? host_method(controller) : private_host_method(controller)
50
+ if public_host || host
49
51
  kwargs = { message: message, code: code, status: status }
50
52
  kwargs[:errors] = details if details && (host.nil? || takes_errors?(host))
51
53
  host ? host.call(**kwargs) : controller.render_error(**kwargs)
@@ -83,17 +85,50 @@ module Permittable
83
85
  !type.nil? && (type == "application/json" || type.end_with?("+json"))
84
86
  end
85
87
 
86
- # The Method for the host's render_error, or nil when the controller claims
87
- # to respond (respond_to? is true) yet has no such method to inspect: one
88
- # served by method_missing without respond_to_missing?. Master called it
89
- # straight through with errors: included, and nil keeps doing exactly that
90
- # rather than raising NameError out of the rescue_from handler.
88
+ # The Method for the host's public render_error, or nil when the controller
89
+ # claims to respond (respond_to? is true) yet has no such method to
90
+ # inspect: one served by method_missing without respond_to_missing?.
91
+ # Master called it straight through with errors: included, and nil keeps
92
+ # doing exactly that rather than raising NameError out of the rescue_from
93
+ # handler.
91
94
  def host_method(controller)
92
95
  controller.method(:render_error)
93
96
  rescue NameError
94
97
  nil
95
98
  end
96
99
 
100
+ # A private render_error is the usual spelling on an ApplicationController
101
+ # (a public method there is routable as an action in every subclass), so
102
+ # it is used too — but only when it is shaped like the documented keyword
103
+ # contract: a private `render_error(message, status)` helper written for
104
+ # something else was never consulted, and must not start turning every 422
105
+ # into a 500. Nil renders the inline shape.
106
+ def private_host_method(controller)
107
+ return nil unless controller.respond_to?(:render_error, true)
108
+
109
+ method = host_method(controller)
110
+ method if method && keyword_contract?(method.parameters)
111
+ end
112
+
113
+ # The keywords render_envelope may pass: message:, code: and status:
114
+ # always, errors: when the method takes it.
115
+ DELEGATED_KEYWORDS = %i[message code status errors].freeze
116
+
117
+ # Shaped like the documented contract: it takes message:, code: and
118
+ # status: (by name or through **), and requires nothing the gem does not
119
+ # pass — no positional argument, no keyword outside DELEGATED_KEYWORDS.
120
+ # `(message:, status:)` is a common private helper on an
121
+ # ApplicationController, and calling it with code: would raise.
122
+ def keyword_contract?(parameters)
123
+ return false if parameters.any? { |kind, _| kind == :req }
124
+
125
+ required = parameters.filter_map { |kind, name| name if kind == :keyreq }
126
+ return false unless (required - DELEGATED_KEYWORDS).empty?
127
+ return true if parameters.any? { |kind, _| kind == :keyrest }
128
+
129
+ (%i[message code status] - keyword_names(parameters)).empty?
130
+ end
131
+
97
132
  # A rejection always carries details, so passing errors: whenever there
98
133
  # are some passed it on every call — and a host documenting its contract
99
134
  # as `(message:, status:, code:)`, which the README promises keeps