permittable 0.15.0 → 0.16.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: 7e1419e11eb73f0b94274aa8f6a868e81913f64a754bfecde44885e83a825942
4
- data.tar.gz: 74a084502a3328e692fd522c719d16e8242fb2a12a9602e3da76c3e6076be426
3
+ metadata.gz: '008b53fb20bca2df208a1bb8a09f942207790d4471b3e7346745df1806858184'
4
+ data.tar.gz: 2a3e3b24d9339ec2336f31690916bc1c25ef8206de97d94243d11497525d709b
5
5
  SHA512:
6
- metadata.gz: de34efb031486908abdf6b26293108f83505dac300ce9a1977b88439c5677d058950a567bd79ead0aefe6f03605d7d312eede2215844393855beb098c99d2819
7
- data.tar.gz: 95420ccea0d5842aaea3877ed3343ab444c7bc9a505efdad76b1b0b23fae1ab7bb636ba367cb9e4ad69151ae35e9a2491c05d6746d6cd31b87bb41cb5744c1be
6
+ metadata.gz: 424948f6d0d1f8115985432a55b0d6e64b4955a4142d0f98b51407fe718bb642217ae3b89303e07d80dd56f475a404f3628bb62e5941a21601c7d6283d07d2da
7
+ data.tar.gz: 43aa1e3832649fc76fb382dfd0719ec9012ea4adf50e67224af9a939b6631ad7b2837e4c25c52a38b24d03973c40fa367f8dadf388ef5d7905b6f88da76e9ae4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.16.0 (2026-11-15)
4
+ <!-- title: date and boolean strings held to what the parser read, an audit that stops counting skipped contracts, and 422 rendered as an Integer -->
5
+
6
+ Four changes to what a request, an audit, an export or a spec is held to. A `:boolean` refuses a number that only equals 1 or 0, and a `:date` or `:datetime` String refuses any part the parser did not read (#181). `permittable:audit` reports an action whose `enforce_params_contract` callback was skipped as `no contract` (#184). The gem passes the Integer 422 or 400 to `render`, so Rails 7.1 with Rack 3.1 stops printing a deprecation warning (#183). `Permittable::OpenAPI.document` exports a literal `permit_params "*"` action, and `permittable:generate` drafts carry more TODO hints and can no longer break a comment across lines (#182).
7
+
8
+ Minor rather than patch: each entry can make a request, a boot, an audit gate or a spec that passes today fail. Before upgrading, read the entries that match what you use. A request can now be refused: a `:boolean` sent `1.0`, `0.0`, `BigDecimal("1")`, `Rational(1, 1)` or `Complex(1, 0)`, a `:date` or `:datetime` String with unread text (`"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`), and the localised or invisible-character forms that 0.15.1 read correctly (`"5. September 2026 um 10:00:00 MESZ"`, `"sam. 05 sept. 2026 10:00:00 CEST"`, `"2026-09-05‎"` with a left-to-right mark), all as `invalid_type` (#181). A boot can now fail: a `default:`, `example:` or `in:` on a `:boolean` field that holds such a number, and a `:date` or `:datetime` `example:` with unread text (#181). An audit gate can now fail: `permittable:audit[strict]` counts an `enforce: true` or monitor-mode action whose callback was skipped (#184). A spec can now fail: one that stubs `render` and expects `status: :unprocessable_entity` from the gem's own envelope now sees `422` (#183). No string that is still accepted is read as a different instant than on 0.15.1.
9
+
10
+ ### Fixed
11
+ - **A `:boolean` refuses a number that only equals 1 or 0.** `true`, `false`, `"true"`, `"false"`, `"1"`, `"0"`, `1` and `0` are accepted as before. `1.0`, `0.0`, `BigDecimal("1")`, `Rational(1, 1)` and `Complex(1, 0)` were accepted on 0.15.1, so a JSON `1.0` was read as `true`; they are now `invalid_type`. A `default:`, `example:` or `in:` on a `:boolean` field that holds one raises at class load (`:default for field :b violates its own contract (invalid_type)`); write it as `true`, `false`, `1` or `0`. (#181)
12
+ - **A `:date` or `:datetime` String with text the parser did not read is refused.** 0.15.1 accepted `"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`, `"2026-01-01 <script>"`, a NUL or zero-width character after the date, two conflicting offsets and `"2026-09-05 10:00 EST xyz"`; each is now `invalid_type`, and an authored `example:` in such a form raises at class load (`default:` and `in:` already did). Still read, as the same instant as 0.15.1: a browser `Date#toString` remark in brackets in any language (`"Sat Sep 05 2026 11:00:00 GMT+0300 (Москва, стандартное время)"`), a numeric offset followed by a zone abbreviation as a label, as Go's `time.String()` prints it (`"2026-09-05 10:00:00 +0800 CST"` is `02:00Z`), an RFC 9557 suffix (`"2026-09-05T10:00+01:00[Europe/Paris]"`), ActiveSupport `TimeWithZone#inspect` when the abbreviation and the offset agree, and one leading byte-order mark. Some other trailing junk is still read as on 0.15.1 (`"2026-09-05 22:00 am"`, a trailing `,` or `.`). (#181)
13
+ - **Known limit: strings 0.15.1 read correctly are now refused.** Localised joiner and weekday words (`"5. September 2026 um 10:00:00 MESZ"`, `"Sa 5. Sep 10:00:00 CEST 2026"`, `"sam. 05 sept. 2026 10:00:00 CEST"`), a left-to-right mark or other invisible character anywhere, and a byte-order mark anywhere but once at the start. Send ISO 8601. (#181)
14
+ - **A date String that 0.15.1 read as the wrong instant is refused.** A zone abbreviation followed by a different numeric offset (`"Sat, 05 Sep 2026 18:00:00 CST +08:00"` was `00:00Z` the next day), a spelled-out zone name with no brackets (`"... 10:00:00 AM Central European Summer Time"` was `16:00Z`) and `"EST5EDT"`. (#181)
15
+ - **Cost.** A plain ISO date or date-time costs the same as on 0.15.1. Other accepted shapes cost about 0.4 to 0.6 ms per value, three to four times 0.15.1, and a string with more than 16 parts is refused before any reparse. (#181)
16
+ - **`permittable:audit` no longer reports a skipped contract as covered.** An action whose `before_action` was removed with `skip_before_action :enforce_params_contract` (bare, `only:`, `except:`, `if:` or `unless:`; `ActionController::Base` and Metal; a subclass of a skipping parent) was covered in the audit, while a real request with an undeclared key got 200. An `enforce: true` or monitor-mode rule on such an action is now `no contract`, per action for `only:` and `except:`, with the note "request not checked before the action: skip_before_action :enforce_params_contract took its before_action off this action (checked only if the action reads permitted_params)", and `permittable:audit[strict]` counts it when the verb takes a body. A skip with `if:` or `unless:` is reported for every action it could reach, as "request may not be checked before the action: skip_before_action :enforce_params_contract is conditional (if:/unless:) and the audit cannot evaluate the condition (checked only if the action reads permitted_params)". This holds even when the action reads `permitted_params` and the request is validated there; to have such an action counted as covered, declare its rule lazily (neither `enforce: true` nor monitor mode, own or app-wide). Lazy rules are unchanged, and a `before_action :enforce_params_contract` declared again after the skip is covered again. The audit still cannot see a conditional skip made with `skip_callback`, a host's own `before_action :enforce_params_contract, only:` after a skip, or a `raise: false` skip on an ancestor that never included Permittable; see the README audit section. The matchers (`accept_params`, `reject_params`) do not see a skipped callback. (#184)
17
+ - **The status the gem renders is an Integer.** On Rails 7.1 with Rack 3.1 or later, every 422 the gem rendered printed `warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.` The gem now hands `render`, and a bare Metal controller's `status=`, the Integer 422 or 400; the response status line is unchanged on every Rails. `InvalidParameters#status` and the `status:` a host's `render_error` receives stay Symbols, so a `render_error` that forwards that Symbol to `render` still gets the warning on Rails 7.1 and can pass the Integer itself. A status that is neither a Symbol, a String nor an Integer is passed through; in the problem+json format it raised `NoMethodError` and rendered a 500 on 0.15.1, and now renders. (#183)
18
+ - **`Permittable::OpenAPI.document` exports a literal `permit_params "*"` action.** It raised `NoMethodError` for a controller that declared one. (#182)
19
+ - **A generated `# TODO` comment can no longer carry Ruby.** A column name containing a line break ended the comment, and the rest of the note became a line of Ruby in the drafted contract; the json-array and binary-column TODOs of 0.15.1 had this. Every drafted line now has its line breaks collapsed to a space. (#182)
20
+
21
+ ### Added
22
+ - **`permittable:generate` drafts carry new TODO hints** for a `:decimal` column's precision and scale (the contract does not bound precision), a column named like a Rails request key (`format`, `controller`, `action`, `authenticity_token`), and a json column that holds arrays. (#182)
23
+ - **README notes:** `permitted_params` is a `HashWithIndifferentAccess` in every mode; the cost of very many unknown keys under `unknown: :error`; a standalone `Contract` is fed the raw payload; a literal `"*"` action. (#182)
24
+
25
+ ### Changed
26
+ - **`skip_before_action` is overridden on controllers that register the gem's callback**, to record a skip of `enforce_params_contract`; other skips are passed through untouched. `Permittable::Audit.inert?(controller, rule)` (undocumented) is gone. `Audit::Entry#inert` holds the reason (`:no_callbacks`, `:skipped` or `:conditional`) where it held `true`; `Entry#inert?` still returns true or false. (#184)
27
+
28
+ ## 0.15.1 (2026-11-12)
29
+ <!-- title: a date String's hour and glued offset read or refused, and the exported body and descriptors list the routing keys, a renamed CSRF token and a route's format default -->
30
+
31
+ **Patch rather than minor.** Every string this release newly refuses was already misread by 0.15.0: each dropped an hour or an offset, or read midnight UTC by accident. The refused strings are `"2026-09-05 10 A.D."`, `"2026-09-05 10 a"`, `"2026-09-05 10 AD GMT+5"`, offsets that are not read whole or not honoured (`"EST+5"`, `"UTC+99"`, `"GMT+0560"`), and, for a `:date`, `"2026-09-05 GMT+5"`. The export changes are these: `rails_routes` descriptors now name a `format:` default; a contract field `format` leaves the body for such routes; a rootless multipart `unknown: :error` body gains a redundant `"format": {}`; and a rootless JSON `unknown: :error` body lists the routing keys and a renamed CSRF token as free properties.
32
+
33
+ ### Fixed
34
+ - **An hour before an era or a lone letter is refused on a date String.** `"2026-09-05 10 A.D."` and `"2026-09-05 10 a"` lost the hour on 0.15.0 and read as midnight; they are now refused as `invalid_type`, as `"2026-09-05 10 EST"` already was. (#179)
35
+ - **An offset glued to a zone name is read.** `"2026-09-05 GMT+5"` and `"2026-09-05 UTC+0530"` were dropped to midnight UTC on 0.15.0. A `:datetime` now reads them as midnight at that offset (`2026-09-04T19:00Z`, `2026-09-04T18:30Z`); a `:date` refuses them, since the instant is not midnight UTC. (#179)
36
+ - **The rootless `unknown: :error` JSON body lists the routing keys and a renamed CSRF token.** `controller`, `action`, `format` and the controller's `request_forgery_protection_token` are accepted by the runtime but failed the exported schema. They are now free, optional properties. (#180)
37
+ - **A route's `format:` default fills a contract field of that name.** The export left a field called `format` in the body and its `required` list, though the router fills it. `rails_routes` now names `format` in the descriptor's `defaults:`. (#180)
38
+
3
39
  ## 0.15.0 (2026-11-12)
4
40
  <!-- title: zone names and hours that Date._parse drops honoured or refused, an export that matches what the server stores, an audit that stops reporting rules that cannot run, matchers that redact what they print, and a column guard that knows aliases and store accessors -->
5
41
 
data/README.md CHANGED
@@ -536,7 +536,7 @@ request params
536
536
 
537
537
  Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
538
538
 
539
- In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
539
+ In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through. `permitted_params` is a `HashWithIndifferentAccess` in every mode, never an `ActionController::Parameters`, so `.permitted?` and `.require` are not available on it and `Model.create!(permitted_params)` does not raise `ForbiddenAttributesError`.
540
540
 
541
541
  A bare `ActionController::Metal` controller has no callbacks and no `rescue_from` until it includes them. `include AbstractController::Callbacks` and then `include ActionController::Rescue` (the order `ActionController::Base` uses, so a rejection also covers the `before_action`), before or after `include Permittable`. The rendering modules (`AbstractController::Rendering`, `ActionController::Rendering`, `ActionController::Renderers::All`) are optional: without `ActionController::Rendering` the gem writes the error response itself, with the same status, content type and JSON body. An `enforce: true` rule, or a rule declaring `mode: :monitor`, raises `ArgumentError` at class load when `AbstractController::Callbacks` is missing. The class-load check reads only the rule's own `mode:`, not the app-wide `Permittable.mode`, which an initializer usually sets after the controller loads. A rule with no `mode:` under an app-wide `Permittable.mode = :monitor` therefore loads, but its `before_action` is not there to run it: `permittable:audit` reports those actions as `no contract` (`contract cannot run`) instead of as monitored. An `enforce: true` rule that is not in monitor mode still raises at class load when `ActionController::Rescue` is missing or was included before `AbstractController::Callbacks`. Both modules have to be in by the time of `permit_params`. A check registered at that first `permit_params` runs after any callback declared between the include and it (see [Per-request locales](#custom-error-messages-message)), and with `include Permittable` first, a `skip_before_action :enforce_params_contract` goes after the first `permit_params`, since the callback does not exist before it. On a bare `ActionController::Metal` controller that includes `ActionController::Rescue` after `include Permittable`, the gem registers its handler at the first `permit_params` instead, so there it is a catch-all declared after that `permit_params` that answers first, and a re-declaration goes after it.
542
542
 
@@ -570,6 +570,8 @@ class ReportsController < ApiController
570
570
  end
571
571
  ```
572
572
 
573
+ A literal `"*"` in `permit_params` is only an action name: it matches an action called `"*"` and nothing else, and a rule with no actions is the catch-all. The `"*"` key is the OpenAPI export's catch-all marker for plain-Ruby hosts only.
574
+
573
575
  Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
574
576
 
575
577
  ### The field DSL
@@ -605,7 +607,7 @@ optional :avatar, :file, validate: ->(file) { file.size <= 2.megabytes && file.c
605
607
  array :attachments, of: :file, length: 0..5
606
608
  ```
607
609
 
608
- `:file` takes `required:`, `nullable:`, `validate:`, `transform:`, `desc:`, `sensitive:`, `virtual:` and `message:`. `in:`, `length:`, `format:`, `normalize:`, `default:` and `example:` are refused at class load. A form's empty file input sends `""`, which is absent as everywhere else. The [drift guard](#the-schema-drift-guard) does not look for a column behind a `:file` field (an attachment is not a column), and the OpenAPI export documents an operation with a file as `multipart/form-data`, its properties named the way Rails reads the parts (`user[avatar]`, `docs[]`, `user[address][scan]`). An array of hashes is sent with an index (`items[0][photo]`, `items[0][name]`, `items[1][photo]`, …), as Rails' `fields_for` renders it, and the document describes those leaves with anchored `patternProperties`. What a flat body cannot carry, a container's own `required`, `minItems`/`maxItems`, `nullable`, `description` and custom-validation marker, is kept in the body's `x-permittable-form-groups`, keyed by the container's bracket name (`user[items]`), with `[]` standing for the index of a container inside an element (`user[items][][info]`). Send a nullable container's null as an empty part named by that bracket name, without `[]` (`user[docs]=""`; `user[docs][]=""` is an empty array). Under `unknown: :error` the multipart body sets `additionalProperties: false`, which refuses an undeclared part at any depth (`user[address][bogus]`, `user[items][0][bogus]`), and it lists the parts the server still takes: a nullable container's null as `{"const": ""}`, a `:json` field's keys (`user[extra][any]`) and, for a `root:` rule, any part outside the root, or for a rootless one, the form keys the runtime exempts (`authenticity_token`, `_method`, `utf8`, `commit`, `button`). A renamed CSRF token (`request_forgery_protection_token`) and an image submit's `x`/`y` cannot be known when the document is built, so they are not listed. The children of an optional hash are required only when the hash is sent (the group entry says the hash is optional), and `patternProperties` cannot mark a leaf of an array element required.
610
+ `:file` takes `required:`, `nullable:`, `validate:`, `transform:`, `desc:`, `sensitive:`, `virtual:` and `message:`. `in:`, `length:`, `format:`, `normalize:`, `default:` and `example:` are refused at class load. A form's empty file input sends `""`, which is absent as everywhere else. The [drift guard](#the-schema-drift-guard) does not look for a column behind a `:file` field (an attachment is not a column), and the OpenAPI export documents an operation with a file as `multipart/form-data`, its properties named the way Rails reads the parts (`user[avatar]`, `docs[]`, `user[address][scan]`). An array of hashes is sent with an index (`items[0][photo]`, `items[0][name]`, `items[1][photo]`, …), as Rails' `fields_for` renders it, and the document describes those leaves with anchored `patternProperties`. What a flat body cannot carry, a container's own `required`, `minItems`/`maxItems`, `nullable`, `description` and custom-validation marker, is kept in the body's `x-permittable-form-groups`, keyed by the container's bracket name (`user[items]`), with `[]` standing for the index of a container inside an element (`user[items][][info]`). Send a nullable container's null as an empty part named by that bracket name, without `[]` (`user[docs]=""`; `user[docs][]=""` is an empty array). Under `unknown: :error` the multipart body sets `additionalProperties: false`, which refuses an undeclared part at any depth (`user[address][bogus]`, `user[items][0][bogus]`), and it lists the parts the server still takes: a nullable container's null as `{"const": ""}`, a `:json` field's keys (`user[extra][any]`) and, for a `root:` rule, any part outside the root, or for a rootless one, the keys the runtime exempts: Rails' routing keys (`controller`, `action`, `format`), the form keys (`authenticity_token`, `_method`, `utf8`, `commit`, `button`) and the CSRF token name the controller renamed with `request_forgery_protection_token`. An image submit's `x`/`y` and a per-instance override of the token cannot be known when the document is built, so they are not listed. The children of an optional hash are required only when the hash is sent (the group entry says the hash is optional), and `patternProperties` cannot mark a leaf of an array element required.
609
611
 
610
612
  An ActiveStorage direct upload (`form.file_field :avatar, direct_upload: true`) sends the file from the browser straight to the storage service, and the form then submits only the blob's signed id, a String, which a `:file` field refuses as `invalid_type`. Declare such a field `:string` and attach the signed id (`record.avatar.attach(signed_id)`):
611
613
 
@@ -694,13 +696,13 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
694
696
  | `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
695
697
  | `: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 |
696
698
  | `: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 |
697
- | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
699
+ | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, the Integers `1`/`0` | `"yes"`, `"on"`, `2`, and a number that merely equals 1 or 0 (`1.0`, which is how JSON can send it) |
698
700
  | `: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"`); a string with a time or zone only if it is exactly midnight UTC (`"2026-09-05T00:00:00Z"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant, as an object or a string — one with a time of day, or a midnight in another zone |
699
701
  | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time (an ISO date with an offset or `Z` and no time, `"2026-09-05-05:00"`, is midnight at that offset) | Unparseable strings, any string without a complete date (`"10:30"`), any other date followed by a bare offset and no time of day (`"Sep 5 2026 -05:00"`), an offset or an hour the parser would drop (`"12:00 2026-09-05 -05:00"`, `"2026-09-05 10 -05:00"`, `"2026-09-05T10"`), any word after a date that is not a zone Ruby knows, a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`), and an ISO date followed by `T` and no time (`"2026-09-05T"`) |
700
702
  | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
701
703
  | `:file` | An upload object (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passed through uncast | A `String` (including a form's file field sent as text), numbers, hashes, arrays |
702
704
 
703
- **Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant.
705
+ **Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset glued to a zone name with no time is midnight at that offset where Ruby honours it (`"2026-09-05 GMT+5"` is `2026-09-04T19:00:00Z`) and refused where it does not (`"2026-09-05 UTC+99"`, `"2026-09-05 EST+5"`). An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"2026-09-05 10 A.D."`, `"2026-09-05 10 a"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant. Likewise any part of the string the parser read nothing from is refused, not dropped: junk after, before or between dates (`"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`, `"2026-01-01 <script>"`), and control or zero-width characters (bar one leading byte-order mark, which a CSV or text file saved as UTF-8 starts with, and which is dropped). What is let through repeats something that was read: a remark in brackets (`"(Central Daylight Time)"`, `"(Москва, стандартное время)"`, in any language, with commas, `&` or a nested bracket but no digits), an RFC 9557 suffix (`"[Europe/London]"`, `"[GMT+01:00]"`, the offset governs), Go's `m=+0.1`, and an offset or zone name that agrees with the one read (`"EDT -04:00"`), and a zone abbreviation after a numeric offset, which only labels it (`"2026-09-05 10:00:00 +0800 CST"` is `02:00:00Z`, as Go and `date` print it). Two zones in the other order (`"EDT -0700"`, `"CST +08:00"`: the abbreviation is what is read) or two different offsets are refused.
704
706
 
705
707
  **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.
706
708
 
@@ -810,7 +812,7 @@ rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters
810
812
 
811
813
  Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
812
814
 
813
- **`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down.
815
+ **`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down. The check itself lists one violation per undeclared key, so its cost grows with the key count (tens of seconds at a few hundred thousand keys, growing linearly); cap the request body size or key count in front of the app.
814
816
 
815
817
  **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.
816
818
 
@@ -928,7 +930,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
928
930
 
929
931
  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.
930
932
 
931
- 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 the form keys above (`authenticity_token`, `_method`, `utf8`, `commit`, `button`) stay in a rootless contract's JSON body as free, optional properties, as in its multipart body, because the runtime accepts them (a `root:` contract's body is unchanged: they sit outside the root; a name the contract declares keeps its own schema; `Contract#json_schema` describes the contract alone and lists none), and 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. A name the route fills from `defaults:` (`post "w", to: "widgets#create", defaults: { kind: "x" }`, on the route, its `scope` or the `mount` it is reached through) is no body field either, since the router overwrites the body's value: the export leaves it out of the body, `required` list included, and under `unknown: :error` keeps it as that same free property, but declares no path parameter for it, because no URL carries it. `rails_routes` names such keys in each descriptor's `defaults:`, apart from `controller`, `action` and `format`. **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.
933
+ 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 the keys the runtime exempts (`controller`, `action`, `format`, `authenticity_token`, `_method`, `utf8`, `commit`, `button`, and the controller's renamed CSRF token, if any) stay in a rootless contract's JSON body as free, optional properties, as in its multipart body, because the runtime accepts them (a `root:` contract's body is unchanged: they sit outside the root; a name the contract declares keeps its own schema; `Contract#json_schema` describes the contract alone and lists none), and 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. A name the route fills from `defaults:` (`post "w", to: "widgets#create", defaults: { kind: "x" }`, on the route, its `scope` or the `mount` it is reached through) is no body field either, since the router overwrites the body's value: the export leaves it out of the body, `required` list included, and under `unknown: :error` keeps it as that same free property, but declares no path parameter for it, because no URL carries it. `rails_routes` names such keys in each descriptor's `defaults:`, apart from `controller` and `action`; a `format:` default (`defaults: { format: "json" }`) is named like any other, so a contract field called `format` leaves the body and its `required` list for such a route. **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.
932
934
 
933
935
  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.
934
936
 
@@ -1303,6 +1305,8 @@ Three things it tells you that nothing else does:
1303
1305
  - **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
1304
1306
  - **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
1305
1307
 
1308
+ A contract that validates from the `before_action` (an `enforce: true` rule, or one in monitor mode) but whose callback was removed with a plain `skip_before_action :enforce_params_contract` reads `no contract — request not checked before the action: ...`, and counts against `[strict]` if it takes a body. `enforce: true` promises the check runs before the action whatever the action does, and the skip took that away. The note says the request is checked only if the action reads `permitted_params`: one that does is still validated there (rejected, or a monitor event), so the row can be a gap that is not one in practice. The audit does not read the action's code. To have such an action count as covered, declare its rule as a lazy one, neither `enforce: true` nor monitor mode (its own `mode:` or an app-wide `Permittable.mode = :monitor`, which makes every rule a monitor one): it validates when the action reads `permitted_params`, skip or not, and the audit reads it as covered. A subclass that skips what its parent declared is reported the same way, and so is a child's own `permit_params` under a parent that skipped it, since the callback is registered once per class tree and is not put back. A skip with `only:` or `except:` is read per action, the way Rails compares them (Symbols or Strings, one name or an Array): the actions it names read as above, the ones it spares stay covered. Skips in a row add up, and a subclass's skip never reaches its parent. A skip with `if:` or `unless:` cannot be evaluated without running your code, so every action it could reach reads `no contract — request may not be checked before the action: ... is conditional`, whether or not the condition holds at request time; an `only:` skip that also has `if:` is reported as skipped for the actions it names. Declaring `before_action :enforce_params_contract` again after a skip puts the check back, and the audit reads it as covered. The audit learns of a skip from the `skip_before_action` call itself (the gem wraps it on the controller once it registers its callback), so three things stay invisible: a skip made with `raise: false` on an ancestor that never included `Permittable` (it has nothing to skip itself, Rails rewrites the callback of the subclass that registered it, and nothing is recorded), a conditional skip made with `skip_callback(:process_action, :before, :enforce_params_contract, if: ...)` directly, and a `before_action :enforce_params_contract, only: ...` the app declares itself. All leave the action reported as covered; a skip made with `skip_callback` and no condition removes the callback and is reported. Check those with a request spec.
1309
+
1306
1310
  A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
1307
1311
 
1308
1312
  A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
@@ -1430,7 +1434,7 @@ Both work on a controller class, a controller instance, or a [standalone `Contra
1430
1434
 
1431
1435
  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.
1432
1436
 
1433
- 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`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the payload as given, so a Symbol value there is still `invalid_type`. A failure message that echoes params (the returned hash, a `returning` expectation) redacts them like a request log: the names of `sensitive:` fields, plus the app's `config.filter_parameters` when a Rails application is booted. Matching is by substring, like Rails, so `sensitive: true` on `pin` also redacts `shipping`; when a `returning` mismatch is hidden by that, the message adds `differs at: pin, shipping`, names only. Under a `root:` contract the hash is filtered as the request logs it, under its root, so a scoped filter such as `"payment.number"` applies; the note stops at a redacted key and never names anything below it.
1437
+ 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`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the raw payload as given, with no deep munge and no Symbol-to-String conversion of values (a Symbol value there is still `invalid_type`), because `Contract#call` is fed by app code, not by a request. A failure message that echoes params (the returned hash, a `returning` expectation) redacts them like a request log: the names of `sensitive:` fields, plus the app's `config.filter_parameters` when a Rails application is booted. Matching is by substring, like Rails, so `sensitive: true` on `pin` also redacts `shipping`; when a `returning` mismatch is hidden by that, the message adds `differs at: pin, shipping`, names only. Under a `root:` contract the hash is filtered as the request logs it, under its root, so a scoped filter such as `"payment.number"` applies; the note stops at a redacted key and never names anything below it. They do not see a skipped `enforce_params_contract` callback either: `reject_params` still passes for an action whose real request is not checked before the action runs, because its callback was removed with `skip_before_action`. `permittable:audit` is what reports that.
1434
1438
 
1435
1439
  ### Standalone contracts (no controller)
1436
1440
 
@@ -1487,7 +1491,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1487
1491
 
1488
1492
  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.
1489
1493
 
1490
- **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.
1494
+ **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"` and `"12345\n"` at runtime (the trailing newline too, not only in the export), 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.
1491
1495
 
1492
1496
  **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.
1493
1497
 
@@ -1521,6 +1525,11 @@ Where they legitimately differ, the spec names the reason and asserts the **dire
1521
1525
  - **`null` as absence.** The runtime reads `{"age": null}` as `{}` ([absence](#absence-defaults-and-partial-updates)); JSON Schema cannot express that, so `type: integer` rejects a null the server would accept and ignore. A [`nullable:`](#explicit-nulls-nullable) field is not this case — there the null is a value, the exported `type` widens to say so, and the two agree.
1522
1526
  - **Padding that normalizes away.** `normalize:` runs before the checks, so under `normalize: :squish` and `length: 3..10` the server accepts `" abcdefghij "` — ten characters once squished — while the docs reject its fourteen.
1523
1527
 
1528
+ Two more go the same way but sit outside that spec's JSON-only harness (it has no multipart or Rails-parsing step), so they are documented here and not asserted there:
1529
+
1530
+ - **Rails' `rows[][name]` naming.** The server accepts Rails' unindexed form naming in a multipart body; the export describes only the indexed `rows[0][name]` form.
1531
+ - **A dropped array element.** Rails' parameter parsing removes a `null` element before the contract runs, and the contract drops a blank one from a non-string array (`""` in an `:integer` array), while the schema's `items` refuses both. A standalone `Contract` still refuses `["a", null]`.
1532
+
1524
1533
  Seven cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:
1525
1534
 
1526
1535
  - **Bounds JSON Schema has no keyword for.** A `:json` field's `max_depth:` is enforced by the server but cannot be written as a JSON Schema keyword, so the published document is **looser** there and an over-nested payload still earns a 422. The bound is not dropped — it is exported as `x-permittable-max-depth` — so a generator or linter that wants it can read it.
@@ -43,14 +43,24 @@ module Permittable
43
43
  outside_root: "Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):"
44
44
  }.freeze
45
45
 
46
+ # What an entry's `inert` reason reads as in the table.
47
+ INERT_NOTES = {
48
+ no_callbacks: "contract cannot run: controller lacks AbstractController::Callbacks (no before_action)",
49
+ skipped: "request not checked before the action: skip_before_action :enforce_params_contract took its " \
50
+ "before_action off this action (checked only if the action reads permitted_params)",
51
+ conditional: "request may not be checked before the action: skip_before_action :enforce_params_contract is " \
52
+ "conditional (if:/unless:) and the audit cannot evaluate the condition " \
53
+ "(checked only if the action reads permitted_params)"
54
+ }.freeze
55
+
46
56
  # One routed action, paired with the rule a request for it would resolve
47
57
  # through (nil when nothing covers it — including when the controller
48
58
  # never included the concern, which is exactly the case worth finding).
49
59
  #
50
60
  # `missing_action` marks a route to an action the controller does not
51
61
  # define — see Audit.missing_action?. `inert` marks one whose rule exists
52
- # but cannot run, so `rule` is nil and the action is uncovered — see
53
- # Audit.inert?. `route` identifies the route the
62
+ # but cannot run, so `rule` is nil and the action is uncovered: the reason,
63
+ # :no_callbacks, :skipped or :conditional — see Audit.inert_reason. `route` identifies the route the
54
64
  # path came from (nil for a hand-built descriptor, which is then its own
55
65
  # route); see `summary`. `ignored` is why the entry is left out of the
56
66
  # counts — a key of IGNORE_REASONS — or nil when it is audited.
@@ -69,7 +79,7 @@ module Permittable
69
79
  end
70
80
 
71
81
  def inert?
72
- inert == true
82
+ !inert.nil?
73
83
  end
74
84
 
75
85
  # The mode this action would actually run in, rule-level declaration
@@ -118,32 +128,74 @@ module Permittable
118
128
  routes.select { |route| route[:controller].to_s == key }.map do |route|
119
129
  action = route[:action].to_s
120
130
  rule = rule_for(controller, action)
121
- inert = inert?(controller, rule)
131
+ inert = inert_reason(controller, rule, action)
122
132
  Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
123
133
  rule: (rule unless inert), missing_action: missing[action], route: route[:route],
124
134
  ignored: configured_ignore(key, action, ignore) || outside, inert: inert)
125
135
  end
126
136
  end
127
137
 
128
- # Whether `rule` is declared on `controller` but can never run there: the
129
- # gem never registered its before_action on it — a bare ActionController::
130
- # Metal controller without AbstractController::Callbacks at its
131
- # `permit_params`, which asks the flag the gem sets when it registers the
132
- # callback (a callbacks module included afterwards does not undo that) —
133
- # while the rule validates from that callback: an `enforce: true` rule, or
134
- # one in monitor mode by its own mode: or the app-wide Permittable.mode.
135
- # permit_params raises for this at class load, but reads only the rule's own
136
- # mode, since the app-wide one is often set after the class loads; the
137
- # effective mode is judged here. Such an action is audited as uncovered
138
- # rather than as the monitored one it claims to be.
139
- def inert?(controller, rule)
140
- return false unless rule && defined?(::ActionController::Metal) && controller.is_a?(Class) &&
141
- controller < ::ActionController::Metal
142
-
143
- (rule[:enforce] || (rule[:mode] || Permittable.mode) == :monitor) &&
138
+ # Why `rule` is declared on `controller` but may not run for `action`, or
139
+ # nil when it does: the gem's before_action is not in the controller's
140
+ # callback chain, or is skipped for that action, while the rule validates
141
+ # from that callback (an `enforce: true` rule, or one in monitor mode by
142
+ # its own mode: or the app-wide Permittable.mode). One of
143
+ # - :no_callbacks: a bare ActionController::Metal controller without
144
+ # AbstractController::Callbacks at its `permit_params`, where the gem
145
+ # never registered it (a callbacks module included afterwards does not
146
+ # undo that);
147
+ # - :skipped: `skip_before_action :enforce_params_contract` took it out of
148
+ # this class's chain and its subclasses' (permit_params does not register
149
+ # it a second time), or its only:/except: name this action;
150
+ # - :conditional: a skip with if:/unless: that could reach this action,
151
+ # whose condition the audit does not evaluate, since that is host code.
152
+ # permit_params raises for the first at class load, but reads only the
153
+ # rule's own mode, since the app-wide one is often set after the class
154
+ # loads; the effective mode is judged here. Such an action is audited as
155
+ # uncovered rather than as the monitored or enforced one it claims to be.
156
+ #
157
+ # The conditions of a skip are read from the record Permittable::SkipRecorder
158
+ # keeps of the `skip_before_action` call, and apply only while the callback
159
+ # in the chain is the one that call left. A skip made with `skip_callback`
160
+ # directly, or a `before_action :enforce_params_contract` of the host's own
161
+ # with conditions, is not seen unless it removed the callback outright.
162
+ def inert_reason(controller, rule, action)
163
+ return unless rule && (rule[:enforce] || (rule[:mode] || Permittable.mode) == :monitor)
164
+ return :no_callbacks if never_registered?(controller)
165
+ return unless controller.respond_to?(:_process_action_callbacks)
166
+
167
+ callback = controller._process_action_callbacks.find { |c| c.kind == :before && c.filter == :enforce_params_contract }
168
+ callback ? skip_reason(controller, callback, action) : :skipped
169
+ end
170
+
171
+ def skip_reason(controller, callback, action)
172
+ held = controller.permittable_skips if controller.respond_to?(:permittable_skips)
173
+ return unless held && held[:callback].equal?(callback)
174
+
175
+ effects = held[:skips].map { |skip| skip_effect(skip, action) }
176
+ effects.include?(:skipped) ? :skipped : effects.compact.first
177
+ end
178
+
179
+ # What one skip does to `action`, as Rails composes it: each skip adds its
180
+ # only: and except: to the callback as conditions that must all hold, so
181
+ # the action is skipped when it is in only: or outside except:. An if: or
182
+ # unless: makes it :conditional wherever only:/except: do not skip it.
183
+ def skip_effect(skip, action)
184
+ named = skip[:only]&.include?(action) || (skip[:except] && !skip[:except].include?(action))
185
+ return :skipped if named || (skip[:only].nil? && skip[:except].nil? && !skip[:conditional])
186
+
187
+ :conditional if skip[:conditional]
188
+ end
189
+
190
+ def never_registered?(controller)
191
+ defined?(::ActionController::Metal) && controller.is_a?(Class) && controller < ::ActionController::Metal &&
144
192
  !(controller.respond_to?(:permittable_callback_registered) && controller.permittable_callback_registered)
145
193
  end
146
194
 
195
+ # module_function made public singleton copies of these; they are this
196
+ # module's own steps, not API.
197
+ private_class_method :inert_reason, :skip_reason, :skip_effect, :never_registered?
198
+
147
199
  # Why `controller` — or one of its actions — is left out of the audit and
148
200
  # of permittable:generate: :configured (Permittable.audit_ignore names the
149
201
  # controller, or "controller#action"), :outside_root, or nil. The
@@ -490,8 +542,7 @@ module Permittable
490
542
  def inert_note(entry)
491
543
  return unless entry.inert?
492
544
 
493
- ["contract cannot run: controller lacks AbstractController::Callbacks (no before_action)",
494
- ("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
545
+ [INERT_NOTES.fetch(entry.inert), ("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
495
546
  end
496
547
 
497
548
  # The one wording for a route Rails would 404, covered or not.
@@ -72,6 +72,9 @@ module Permittable
72
72
  # response_body=. `render json:` calls `to_json` on a Hash, so the bytes
73
73
  # are the same. A controller with `render` always takes the first path.
74
74
  def render_json(controller, payload, status:, content_type: nil)
75
+ # The number, not the symbol: Rack >= 3.1 deprecates :unprocessable_entity
76
+ # (:unprocessable_content there) and Rails 7.1 passes it to Rack as is.
77
+ status = status_code(status) || status
75
78
  if !controller.respond_to?(:render) && controller.respond_to?(:response_body=)
76
79
  controller.status = status
77
80
  controller.content_type = content_type if content_type
@@ -192,6 +195,8 @@ module Permittable
192
195
  def status_code(status)
193
196
  return status if status.is_a?(Integer)
194
197
 
198
+ return nil unless status.respond_to?(:to_sym)
199
+
195
200
  symbol = status.to_sym
196
201
  STATUS_CODES[symbol] ||
197
202
  (defined?(Rack::Utils) && Rack::Utils::SYMBOL_TO_STATUS_CODE[symbol])
@@ -43,7 +43,8 @@ module Permittable
43
43
  # lists the column. They ride on the column, set by in_rule, so the scan
44
44
  # path hands them to column_line without its own methods changing.
45
45
  DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
46
- :unread, :rule, :listed, :assigned, :array, :array_default, keyword_init: true) do
46
+ :unread, :rule, :listed, :assigned, :array, :array_default, :precision, :scale,
47
+ keyword_init: true) do
47
48
  def in_rule(rule, listed: false)
48
49
  self.class.new(**to_h, rule: rule, listed: listed)
49
50
  end
@@ -556,6 +557,8 @@ module Permittable
556
557
  default_function: column.respond_to?(:default_function) && column.default_function,
557
558
  array: column.respond_to?(:array?) && column.array? == true,
558
559
  array_default: array_default?(column.default),
560
+ precision: column.respond_to?(:precision) && column.precision,
561
+ scale: column.respond_to?(:scale) && column.scale,
559
562
  sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
560
563
  **model_facts(model, column))
561
564
  end
@@ -1159,9 +1162,30 @@ module Permittable
1159
1162
  todos << "TODO: #{column.name} #{SCANNED_SENSITIVE.fetch(column.sensitive)}" if column.sensitive
1160
1163
  todos << enum_todo(column) if column.enum_integers
1161
1164
  todos << unread_todo(column) if column.unread
1165
+ todos.concat(hint_todos(column))
1166
+ end
1167
+
1168
+ # Hints about what a contract cannot say for a column, so a reviewer
1169
+ # sees them before the draft is enforced. Comments only.
1170
+ def hint_todos(column)
1171
+ todos = []
1172
+ if column.type == :decimal && column.precision
1173
+ todos << "TODO: decimal(#{column.precision},#{column.scale || 0}): the contract does not bound precision; " \
1174
+ "a Postgres app may 500 on overflow"
1175
+ end
1176
+ if RAILS_REQUEST_KEYS.include?(column.name.to_s)
1177
+ todos << "TODO: #{column.name} is also a Rails request key — if this contract is rootless (root: false) " \
1178
+ "the router's value collides with it"
1179
+ end
1180
+ if JSON_TYPES.include?(column.type)
1181
+ todos << "TODO: #{column.name} (#{column.type}): a :json field accepts an object only, not an array"
1182
+ end
1162
1183
  todos
1163
1184
  end
1164
1185
 
1186
+ RAILS_REQUEST_KEYS = %w[controller action format authenticity_token].freeze
1187
+ JSON_TYPES = %i[json jsonb].freeze
1188
+
1165
1189
  SCANNED_SENSITIVE = {
1166
1190
  locking: "is the optimistic-locking column — kept because the permit call lists it: an edit form that " \
1167
1191
  "round-trips it is how Rails detects a stale update; never give it a default:",
@@ -1320,9 +1344,22 @@ module Permittable
1320
1344
  return nil if rules.empty?
1321
1345
 
1322
1346
  bodies = rules.map do |actions, body|
1323
- "#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
1347
+ "#{one_line(signature(root: root, model: model, actions: actions))}\n" \
1348
+ "#{body.map { |line| " #{one_line(line)}\n" }.join}end\n"
1324
1349
  end
1325
1350
  HEADER + bodies.join("\n#{UPDATE_NOTE}")
1326
1351
  end
1352
+
1353
+ # Every drafted line passes through here, the one place text from the
1354
+ # database or the app (a column name in a TODO) meets a comment. A line
1355
+ # break in it would end that comment and leave the rest as live Ruby for
1356
+ # whoever pastes the draft. Ruby's lexer ends a comment at "\n" only (a
1357
+ # lone "\r", "\v", "\f", U+0085, U+2028/9 and NUL stay inside it), but an
1358
+ # editor or terminal shows "\r" as a break too, so it goes as well. A name
1359
+ # inside a quoted literal is already escaped by `inspect`, with no raw
1360
+ # break left.
1361
+ def one_line(line)
1362
+ line.gsub(/\r\n|[\r\n]/, " ")
1363
+ end
1327
1364
  end
1328
1365
  end
@@ -197,32 +197,44 @@ module Permittable
197
197
  # required field (missing).
198
198
  def request_body_for(controller, action)
199
199
  rule = controller.permit_rule_for(action)
200
- rule && rule_request_body(rule)
200
+ rule && rule_request_body(rule, controller)
201
201
  end
202
202
 
203
- def rule_request_body(rule)
203
+ def rule_request_body(rule, controller)
204
204
  media_type = body_media_type(rule)
205
205
  schema = JsonSchema.rule(rule)
206
- schema = form_schema(schema, rule) if media_type == "multipart/form-data"
207
- allow_form_keys!(schema) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
206
+ exempt = exempt_keys(controller)
207
+ schema = form_schema(schema, rule, exempt) if media_type == "multipart/form-data"
208
+ allow_form_keys!(schema, exempt) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
208
209
  {
209
210
  "required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
210
211
  "content" => { media_type => { "schema" => schema } }
211
212
  }
212
213
  end
213
214
 
214
- # Under `unknown: :error` the runtime still exempts Rails' form keys
215
- # (FORM_KEYS) at the top of a rootless contract, so a JSON body carrying
216
- # `authenticity_token` or `_method` (a form's fields sent as JSON, a record
217
- # echoed back) is accepted, and the body schema must not forbid it. They
215
+ # The top-level keys the runtime exempts under `unknown: :error`: the
216
+ # constant list it subtracts (UNCHECKED_TOP_LEVEL_KEYS) plus the CSRF
217
+ # parameter the controller renamed, which it reads off the controller too
218
+ # (permittable_configured_csrf_key). Per-instance overrides and an image
219
+ # submit's x/y pair cannot be known here.
220
+ def exempt_keys(controller)
221
+ token = controller.request_forgery_protection_token if controller.respond_to?(:request_forgery_protection_token)
222
+ (Permittable::UNCHECKED_TOP_LEVEL_KEYS + [token&.to_s]).compact.uniq
223
+ end
224
+
225
+ # Under `unknown: :error` the runtime still exempts Rails' routing and form
226
+ # keys (`exempt`, see exempt_keys) at the top of a rootless contract, so a
227
+ # JSON body carrying `authenticity_token`, `format` or `_method` (a form's
228
+ # fields sent as JSON, a record echoed back) is accepted, and the body
229
+ # schema must not forbid it. They
218
230
  # stay in as free, never-required properties, as the multipart body lists
219
231
  # them (see form_schema). A name the contract declares keeps its own
220
232
  # schema. A `root:` rule needs none of this: the keys sit outside the root,
221
233
  # in a wrapper that is permissive already. The plain JsonSchema.rule and
222
234
  # Contract#json_schema describe the contract, not an HTTP body, and a
223
235
  # standalone Contract exempts no form key, so they do not list them.
224
- def allow_form_keys!(schema)
225
- Permittable::FORM_KEYS.each { |key| schema["properties"][key] ||= {} }
236
+ def allow_form_keys!(schema, exempt)
237
+ exempt.each { |key| schema["properties"][key] ||= {} }
226
238
  end
227
239
 
228
240
  # A multipart body is not one JSON object: each part is a form field, and
@@ -243,15 +255,15 @@ module Permittable
243
255
  # nullable container's null (`user[docs]=""`), the keys of a :json
244
256
  # field (`user[extra][any]`), and, for a `root:` rule, any part outside
245
257
  # the root, which the runtime never reads; a rootless rule instead gets
246
- # the form keys (FORM_KEYS) the runtime exempts at the top level.
247
- def form_schema(json_schema, rule)
258
+ # the keys (`exempt`, see exempt_keys) the runtime exempts at the top level.
259
+ def form_schema(json_schema, rule, exempt)
248
260
  root = rule[:root] && rule[:root].to_s
249
261
  form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
250
262
  flatten_form(json_schema, [], true, form, skip_group: root)
251
263
  if form[:strict] && root
252
264
  form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
253
265
  elsif form[:strict]
254
- form[:patterns]["^(?:#{Permittable::FORM_KEYS.map { |key| form_escape(key) }.join('|')})$"] = {}
266
+ form[:patterns]["^(?:#{exempt.map { |key| form_escape(key) }.join('|')})$"] = {}
255
267
  end
256
268
  schema = { "type" => "object", "properties" => form[:properties] }
257
269
  schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
@@ -390,24 +402,23 @@ module Permittable
390
402
  end
391
403
 
392
404
  def operation_for(controller, action)
393
- rule = if action == "*"
394
- controller.permittable_contracts.reverse_each.find { |r| r[:actions].empty? }
395
- else
396
- controller.permit_rule_for(action)
397
- end
405
+ # "*" is the catch-all's marker on a plain host; a rule that names "*"
406
+ # literally is an ordinary action and is found through permit_rule_for.
407
+ rule = (action == "*" && controller.permittable_contracts.reverse_each.find { |r| r[:actions].empty? }) ||
408
+ controller.permit_rule_for(action)
398
409
  operation = {}
399
410
  key = controller_key(controller)
400
411
  operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
401
412
  # dup: the stored desc is frozen, and a caller editing the generated
402
413
  # document in place must neither raise nor reach the contract.
403
414
  operation["description"] = rule[:desc].dup if rule[:desc]
404
- operation["requestBody"] = rule_request_body(rule)
415
+ operation["requestBody"] = rule_request_body(rule, controller)
405
416
  operation["responses"] = responses_for(rule)
406
417
  # The docs must not promise a 422 the server doesn't yet send. Only
407
418
  # the rule's own declaration is contract data — the app-wide
408
419
  # Permittable.mode is runtime configuration the export can't see.
409
420
  operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
410
- operation["x-permittable-catch-all"] = true if action == "*"
421
+ operation["x-permittable-catch-all"] = true if action == "*" && rule[:actions].empty?
411
422
  # `finalize` is flagged on the rule's object schema too (see
412
423
  # JsonSchema.rule), but that sits inside the requestBody, and an
413
424
  # operation can end up with no body (every field a path variable) while
@@ -588,7 +599,7 @@ module Permittable
588
599
  else
589
600
  free.each do |path, verb, template, defaults|
590
601
  holder = (paths[path] ||= {})
591
- placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
602
+ placed = with_path_parameters(operation, path, controller.permit_rule_for(action), controller, [template || path, defaults])
592
603
  holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
593
604
  slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
594
605
  end
@@ -707,6 +718,9 @@ module Permittable
707
718
  # otherwise: a rootless contract field named like one is that parameter's
708
719
  # schema (path_fields), and leaves the body schema.
709
720
  #
721
+ # `route` is `[own, defaults]`; `controller` supplies the keys the body
722
+ # exempts (see exempt_keys).
723
+ #
710
724
  # `own` is the route's own spelling of the template, which differs
711
725
  # from `path` when collapse_shapes placed the route under another
712
726
  # route's spelling: the same shape, so the names pair up by position.
@@ -717,7 +731,8 @@ module Permittable
717
731
  # not carry them and they are no path parameter. A name the path
718
732
  # templates is that parameter instead: `get "o(/:id)", defaults:
719
733
  # { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
720
- def with_path_parameters(operation, path, rule, own = path, defaults = [])
734
+ def with_path_parameters(operation, path, rule, controller, route)
735
+ own, defaults = route
721
736
  # A name the path templates twice (a mount and an engine route both
722
737
  # name `:id`) is declared once: OpenAPI forbids two parameters with
723
738
  # one name and location.
@@ -736,7 +751,7 @@ module Permittable
736
751
  operation.each_with_object({}) do |(key, value), out|
737
752
  if key == "requestBody"
738
753
  out["parameters"] = parameters unless parameters.empty?
739
- value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
754
+ value = body_without(rule, controller, fields.values + default_fields(rule, supplied), own_names + supplied)
740
755
  end
741
756
  out[key] = value unless value.nil?
742
757
  end
@@ -797,11 +812,11 @@ module Permittable
797
812
  # nothing in the body can make the request wrong. A name the route's
798
813
  # `defaults:` fill is treated the same. A rooted contract
799
814
  # needs none of this: its wrapper object is permissive already.
800
- def body_without(rule, moved, templated)
815
+ def body_without(rule, controller, moved, templated)
801
816
  rest = rule[:fields] - moved
802
817
  return if rest.empty? && !moved.empty?
803
818
 
804
- body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
819
+ body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest), controller)
805
820
  return body unless rule[:unknown] == :error && !rule[:root]
806
821
 
807
822
  properties = body["content"].values.first["schema"]["properties"]
@@ -1028,13 +1043,15 @@ module Permittable
1028
1043
  # The names a route fills from its `defaults:` (`scope`, `resources` and
1029
1044
  # `mount` take them too), as strings. The router puts them into
1030
1045
  # path_parameters beside the URL's own, so the client cannot set them;
1031
- # `controller`, `action` and `format` are the router's own keys and
1032
- # are left out, as the runtime leaves them out of `unknown:`. A
1046
+ # `controller` and `action` are the router's own keys and are left
1047
+ # out, as the runtime leaves them out of `unknown:`. `format` is left in:
1048
+ # a `defaults: { format: ... }` lands in path_parameters like any other
1049
+ # default, so a contract field of that name is filled by it. A
1033
1050
  # hand-built route that cannot say fills nothing.
1034
1051
  def route_defaults(route)
1035
1052
  return [] unless route.respond_to?(:defaults)
1036
1053
 
1037
- route.defaults.keys.map(&:to_s) - %w[controller action format]
1054
+ route.defaults.keys.map(&:to_s) - %w[controller action]
1038
1055
  end
1039
1056
 
1040
1057
  # The Prefix of each variant of a mount's path under each of `prefixes`,
@@ -669,6 +669,10 @@ module Permittable
669
669
  "expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
670
670
  end
671
671
 
672
+ # The root a scoped filter_parameters entry needs comes from the subject,
673
+ # which a matcher only learns in matches?. Before any run there is none to
674
+ # resolve, so a description read then prints through the shallow filter
675
+ # (RSpec builds a one-liner's name after the run, so it is not affected).
672
676
  def description
673
677
  label = @expect_accepted ? "accept those params" : "reject those params"
674
678
  label += action_label
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.15.0".freeze
2
+ VERSION = "0.16.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -642,8 +642,56 @@ module Permittable
642
642
  end
643
643
  end
644
644
 
645
+ # Remembers what `skip_before_action :enforce_params_contract` took out of
646
+ # the callback chain, for Audit. A skip with only:/except:/if:/unless: leaves
647
+ # the callback in the chain behind conditions Rails keeps private, so the
648
+ # only public place to read them is the call itself. Prepended to the
649
+ # controller's singleton class when the gem registers its callback, so it
650
+ # wins whatever the include order of the callbacks module and this one.
651
+ # It calls super with the arguments it was given and returns what super
652
+ # returns; it only looks.
653
+ module SkipRecorder
654
+ ruby2_keywords def skip_before_action(*args, &)
655
+ # A copy: Rails deletes only:/except: from a positional Hash and adds :if to it.
656
+ options = args.last.is_a?(Hash) ? args.last.dup : {}
657
+ return super unless args.any? { |name| name.to_s == "enforce_params_contract" }
658
+
659
+ # Rails rebuilds the callback of every class already below this one too,
660
+ # each with its own new object, so each gets the record.
661
+ targets = [self] + descendants
662
+ before = targets.map(&:permittable_hook_callback)
663
+ result = super
664
+ targets.zip(before) { |target, was| target.permittable_record_skip(was, options) }
665
+ result
666
+ end
667
+
668
+ def permittable_hook_callback
669
+ _process_action_callbacks.find { |callback| callback.kind == :before && callback.filter == :enforce_params_contract }
670
+ end
671
+
672
+ # One state per class, replaced rather than changed, so a subclass's skip
673
+ # never reaches its parent. It holds the callback now in the chain and
674
+ # every skip that produced it: a skip is carried forward from the state
675
+ # only when it was made on that very callback, so a callback declared
676
+ # again afterwards (a new object) starts clean. only:/except: are kept as
677
+ # Rails compares them, action names as Strings.
678
+ def permittable_record_skip(before, options)
679
+ after = permittable_hook_callback
680
+ return if before.nil? || before.equal?(after)
681
+
682
+ held = permittable_skips
683
+ skips = held && held[:callback].equal?(before) ? held[:skips] : []
684
+ skip = { only: (Array(options[:only]).to_set(&:to_s) if options[:only]),
685
+ except: (Array(options[:except]).to_set(&:to_s) if options[:except]),
686
+ conditional: options.key?(:if) || options.key?(:unless) }.freeze
687
+ self.permittable_skips = { callback: after, skips: (skips + [skip]).freeze }.freeze
688
+ end
689
+ end
690
+
645
691
  included do
646
692
  class_attribute :permittable_contracts, instance_accessor: false, default: []
693
+ # The skips of enforce_params_contract SkipRecorder saw (see Audit).
694
+ class_attribute :permittable_skips, instance_accessor: false, default: nil
647
695
  # Whether the gem itself ever registered its rescue_from / before_action
648
696
  # for this class (a subclass inherits the answer). Asking the callback
649
697
  # chain instead would bring back a check the app took out with
@@ -1006,9 +1054,11 @@ module Permittable
1006
1054
  result.exponent.abs > [DECIMAL_DIGIT_ALLOWANCE, source.length].max
1007
1055
  end
1008
1056
 
1057
+ # eql?, not ==: 1.0, BigDecimal("1"), Rational(1, 1) and Complex(1, 0) are
1058
+ # == 1, and a JSON `1.0` arrives as a Float, but only the Integer is listed.
1009
1059
  def cast_boolean(value)
1010
- return [:ok, true] if TRUE_VALUES.include?(value)
1011
- return [:ok, false] if FALSE_VALUES.include?(value)
1060
+ return [:ok, true] if TRUE_VALUES.any? { |true_value| true_value.eql?(value) }
1061
+ return [:ok, false] if FALSE_VALUES.any? { |false_value| false_value.eql?(value) }
1012
1062
 
1013
1063
  [:error, "invalid_type"]
1014
1064
  end
@@ -1033,6 +1083,7 @@ module Permittable
1033
1083
  date ? [:ok, date] : [:error, "invalid_type"]
1034
1084
  when Date then [:ok, value]
1035
1085
  when String
1086
+ value = value.delete_prefix(BOM) if value.start_with?(BOM)
1036
1087
  # A bare ISO date ("2026-09-05", the common case) carries no zone, T
1037
1088
  # or offset, so it skips every check for one.
1038
1089
  plain = PLAIN_ISO_DATE.match?(value)
@@ -1048,6 +1099,7 @@ module Permittable
1048
1099
  if !plain && (dropped = dropped_tail(value, found))
1049
1100
  return date_at_midnight(*dropped)
1050
1101
  end
1102
+ return [:error, "invalid_type"] if !plain && unread_text?(value, found)
1051
1103
 
1052
1104
  # Built from the components rather than re-running Date.parse, which
1053
1105
  # would parse the same string a second time — and Date.parse is the
@@ -1091,9 +1143,8 @@ module Permittable
1091
1143
 
1092
1144
  # The Date of an instant read from a String when it is midnight UTC.
1093
1145
  def date_at_midnight(status, instant)
1094
- return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
1095
-
1096
- [:ok, Date.new(instant.year, instant.month, instant.day)]
1146
+ date = date_of_instant(instant) if status == :ok
1147
+ date ? [:ok, date] : [:error, "invalid_type"]
1097
1148
  end
1098
1149
 
1099
1150
  # The date a Time or DateTime IS, or nil: its UTC date when it is
@@ -1144,6 +1195,7 @@ module Permittable
1144
1195
  when Time, DateTime then [:ok, value.to_time.getutc]
1145
1196
  when Date then [:ok, Time.utc(value.year, value.month, value.day)]
1146
1197
  when String
1198
+ value = value.delete_prefix(BOM) if value.start_with?(BOM)
1147
1199
  # Same rule as :date — the DATE part must be named in full, or it is
1148
1200
  # taken from today ("10:30" meant today at 10:30). An absent TIME part
1149
1201
  # is fine and means midnight, which is the documented reading of a
@@ -1178,6 +1230,7 @@ module Permittable
1178
1230
 
1179
1231
  dropped = dropped_tail(value, found)
1180
1232
  return dropped if dropped
1233
+ return [:error, "invalid_type"] if unread_text?(value, found)
1181
1234
 
1182
1235
  [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1183
1236
  rescue ArgumentError, RangeError
@@ -1252,9 +1305,9 @@ module Permittable
1252
1305
 
1253
1306
  # Trailing words, set apart by a space: a zone name ("EST", "Eastern
1254
1307
  # Standard Time", "W. Europe Standard Time") or a day or era word. The
1255
- # first word has two letters or more, or one and a ".", as a lone letter
1256
- # is a military zone or the ISO "T".
1257
- ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
1308
+ # first word may be one letter, which is a military zone or the ISO "T"
1309
+ # and so no zone here, but still stands where an hour was lost.
1310
+ ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)?[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
1258
1311
 
1259
1312
  # An era word at the start of such a tail: the parser reads it with the
1260
1313
  # date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
@@ -1287,14 +1340,21 @@ module Permittable
1287
1340
  # without it names the same date (and time). Only a String that ends in a
1288
1341
  # signed number or a word is parsed again, so a plain "2026-09-05" is not.
1289
1342
  def dropped_tail(value, found)
1343
+ # "at" joins a date to its time, and Ruby reads it as a zone (or drops it) when none follows.
1344
+ return [:error, "invalid_type"] if /\sat\s*\z/i.match?(value)
1290
1345
  return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
1291
1346
  return if found.key?(:zone)
1292
1347
 
1293
1348
  if (tail = SIGNED_TAIL.match(value))
1294
- return unless tail[1] || found.key?(:hour)
1349
+ # An offset glued to a zone name with no time ("2026-09-05 GMT+5") is
1350
+ # read below as midnight at that offset; one glued to a date is a
1351
+ # part of it ("2026-09-05" ends in "-05").
1352
+ named = !tail[1] && !found.key?(:hour) && /\s[[:alpha:]]+\z/.match(tail.pre_match)
1353
+ return unless tail[1] || found.key?(:hour) || named
1295
1354
 
1296
1355
  rest = Date._parse(tail.pre_match)
1297
1356
  return unless same_date?(rest, found)
1357
+ return glued_zone(named, tail[0].strip, found) if named
1298
1358
 
1299
1359
  git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
1300
1360
  git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
@@ -1305,40 +1365,129 @@ module Permittable
1305
1365
  pre += era[0]
1306
1366
  text = era.post_match
1307
1367
  end
1308
- return if text.empty?
1368
+ # The era is part of the date, so the hour a lost one hid is looked
1369
+ # for before it ("2026-09-05 10 A.D.", "2026-09-05 10 AD EST").
1370
+ head = era ? Date._parse(tail.pre_match) : nil
1371
+ return same_time?(head, found) ? nil : [:error, "invalid_type"] if text.empty?
1309
1372
 
1310
1373
  rest = Date._parse(pre)
1311
1374
  return unless same_date?(rest, found)
1312
1375
 
1313
1376
  zone = Date._parse("00:00 #{text}")
1314
1377
  return unless zone.key?(:zone) # a day name
1378
+ # The hour the tail made the parser lose ("2026-09-05 10 EST").
1379
+ return [:error, "invalid_type"] unless same_time?(head || rest, found)
1315
1380
  # A single letter is a military zone, which is no zone here: alone it
1316
1381
  # is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
1317
1382
  # name Ruby did not know ("W. Europe") is dropped, so it is refused
1318
1383
  # like any other word it does not know.
1319
1384
  return text.match?(/\A[[:alpha:]]\.?\z/) ? nil : [:error, "invalid_type"] if zone[:zone].length == 1
1320
- # The hour the tail made the parser lose ("2026-09-05 10 EST").
1321
- return [:error, "invalid_type"] unless same_time?(rest, found)
1322
1385
 
1323
1386
  instant_in_zone(pre, text)
1324
1387
  end
1325
1388
  end
1326
1389
 
1390
+ # A signed offset after a zone name ("GMT+5", "UTC+0530") is that zone to
1391
+ # Ruby when it reads it next to a time, so the String is midnight at it.
1392
+ # A name it does not read whole ("EST+5") or honour ("UTC+99") is refused.
1393
+ # What comes before the zone is held to the rules of any date String: no
1394
+ # hour a lost tail hid ("2026-09-05 10 AD GMT+5"), no second zone, and the
1395
+ # offset is hours ("+5", "+05") or hhmm with minutes a minute ("+0530").
1396
+ def glued_zone(word, offset, found)
1397
+ zone = "#{word[0].strip}#{offset}"
1398
+ pre = Date._parse(word.pre_match)
1399
+ return [:error, "invalid_type"] unless offset.match?(GLUED_OFFSET) && Date._parse("00:00 #{zone}")[:zone] == zone
1400
+ return [:error, "invalid_type"] unless same_time?(pre, found) && !pre.key?(:zone) && dropped_tail(word.pre_match, pre).nil?
1401
+
1402
+ instant_in_zone(word.pre_match, zone)
1403
+ end
1404
+
1327
1405
  # The wall clock `date` names, as it is read in the zone `text` names:
1328
1406
  # [:ok, instant], or an error when Ruby cannot honour the zone.
1329
1407
  def instant_in_zone(date, text)
1330
1408
  zone = Date._parse("00:00 #{text}")
1331
1409
  return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
1410
+ # Ruby reads a zone name and drops what follows it ("EST xyz" is EST).
1411
+ return [:error, "invalid_type"] unless zone[:zone].casecmp?(text.split.join(" "))
1412
+ return [:error, "invalid_type"] if unread_text?(date, Date._parse(date))
1332
1413
 
1333
1414
  [:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
1334
1415
  end
1335
1416
 
1417
+ # Text Date._parse read nothing from, which the parse then leaves out of
1418
+ # the result without a word: "2026-01-01xyz", a second date after the
1419
+ # first ("2026-01-15\n2099-12-31"), "<script>", and any control or
1420
+ # zero-width character (the parser takes it for a space). Found by
1421
+ # leaving each word and number out in turn: if the parse is unchanged,
1422
+ # that part of the String meant nothing to it.
1423
+ #
1424
+ # A part may stay unread only when it says nothing new: the T of an ISO
1425
+ # date-time, an ordinal or era word, "at", a lone letter after a date, an am
1426
+ # or pm the hour already says, or an offset or zone name that repeats the
1427
+ # one read ("EDT -04:00", "+0000 UTC"; "-0400 PST" disagrees, so is
1428
+ # refused). Remarks are set aside: a bracketed name ("(Central Daylight
1429
+ # Time)", "(heure d'été de l'Est)"), an RFC 9557 zone id
1430
+ # ("[Europe/London]", "[u-ca=japanese]": the offset governs) and Go's "m=+0.1".
1431
+ def unread_text?(value, found)
1432
+ return false if ISO_DATE_TIME.match?(value) || PLAIN_ISO_DATE.match?(value) # all of it is read; skips the parses below
1433
+ return true if value.match?(INVISIBLE)
1434
+
1435
+ text = value.gsub(REMARK, " ")
1436
+ # A remark the parser read (a "(Sat)") is part of the parse: look at it too.
1437
+ text = value if text != value && Date._parse(text) != found
1438
+ # "(GMT+02:00)" after "GMT+0200": a remark that repeats the offset read.
1439
+ text = text.gsub(OFFSET_REMARK) do
1440
+ Date._parse("00:00 GMT#{Regexp.last_match(1)}")[:offset] == found[:offset] ? " " : Regexp.last_match(0)
1441
+ end
1442
+ text = text.gsub(%r{[^-+',./:@[:alnum:]\[\]]+}, " ")
1443
+ return true if text.scan(PART).size > MAX_PARTS # each part below costs a parse
1444
+
1445
+ text.scan(PART) do
1446
+ part = Regexp.last_match
1447
+ next if part[0].match?(part.pre_match.match?(/\d\z/) ? GLUED_NOISE : LONE_WORD)
1448
+ next if part[0].casecmp?("at") && found.key?(:hour) && part.post_match.match?(/\d/) # joins a date to its time
1449
+ # "10:30 am", "12 pm" and "22:00 pm" are what the hour already says.
1450
+ next if { "am" => found[:hour].to_i < 12, "pm" => found[:hour].to_i >= 12 }[part[0].downcase]
1451
+ next unless Date._parse("#{part.pre_match} #{part.post_match}") == found # the parse needed this part
1452
+
1453
+ # What it left unread may still repeat the zone read, or label it.
1454
+ zone = Date._parse("00:00 #{part[0]}")[:offset] if found[:offset]
1455
+ next if zone && (zone == found[:offset] || offset_label?(found, part))
1456
+
1457
+ return true
1458
+ end
1459
+ false
1460
+ end
1461
+
1462
+ # "+0800 CST": a zone abbreviation right after the numeric offset read is a
1463
+ # label for it (Go and `date` print one), even where Ruby's table maps it to
1464
+ # another offset. The other order ("CST +08:00") reads the abbreviation, so no.
1465
+ def offset_label?(found, part)
1466
+ found[:zone].match?(/\A[+-]/) && part[0].match?(/\A[[:alpha:]]+\z/) && part.pre_match.rstrip.end_with?(found[:zone])
1467
+ end
1468
+
1336
1469
  def same_time?(rest, found)
1337
1470
  TIME_PARTS.all? { |part| rest[part] == found[part] }
1338
1471
  end
1339
1472
 
1340
1473
  TIME_PARTS = %i[hour min sec sec_fraction].freeze
1341
- private_constant :TIME_PARTS
1474
+ GLUED_OFFSET = /\A[+-](?:\d{1,2}|\d{2}[0-5]\d)\z/
1475
+ ISO_DATE_TIME = /\A\d{4}-?\d\d-?\d\d[Tt ]\d\d:?\d\d(?::?\d\d(?:[.,]\d+)?)?(?:[Zz]| ?[+-]\d\d(?::?\d\d)?)?\z/
1476
+ # One leading U+FEFF is a UTF-8 signature (a CSV or text file saved with one), not text.
1477
+ BOM = "\uFEFF".freeze
1478
+ INVISIBLE = /[\p{Cc}\p{Cf}&&[^\s]]/
1479
+ REMARK_TEXT = /[\p{L}\p{M} .'’,&-]/
1480
+ REMARK = %r{\((?:#{REMARK_TEXT}|\(#{REMARK_TEXT}*\))*\)|(?:\[!?[\w/+=:-]+\])+\z|\sm=[+-]\d+(?:\.\d+)?\z}
1481
+ # No date format in use has more parts than this: the longest read in the
1482
+ # specs and corpora has 8, e.g. "September 5, 2026 at 10:00:00 AM GMT+2".
1483
+ MAX_PARTS = 16
1484
+ PART = /[[:alpha:]]+|[+-]?\d[^[:alpha:]\s]*/
1485
+ GLUED_NOISE = /\A(?:t|st|nd|rd|th)\z/i
1486
+ # No /i: Onigmo then lets [[:alpha:]] match a ligature's folding ("st", "ss", "ff") once it has met non-ASCII text.
1487
+ LONE_WORD = /\A(?:[[:alpha:]]|[Aa][Dd]|[Cc][Ee])\z/
1488
+ OFFSET_REMARK = /\((?:GMT|UTC)([+-]\d{1,2}(?::?\d{2})?)\)/
1489
+ private_constant :TIME_PARTS, :GLUED_OFFSET, :ISO_DATE_TIME, :BOM, :INVISIBLE, :REMARK_TEXT, :REMARK, :MAX_PARTS, :PART,
1490
+ :GLUED_NOISE, :LONE_WORD, :OFFSET_REMARK
1342
1491
 
1343
1492
  def same_date?(rest, found)
1344
1493
  complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
@@ -2443,6 +2592,7 @@ module Permittable
2443
2592
  # order: this check runs before any callback declared after the include,
2444
2593
  # so a locale switch that must cover it is a `prepend_around_action`.
2445
2594
  before_action :enforce_params_contract
2595
+ singleton_class.prepend(SkipRecorder) unless singleton_class < SkipRecorder
2446
2596
  self.permittable_callback_registered = true
2447
2597
  end
2448
2598
 
@@ -2453,7 +2603,7 @@ module Permittable
2453
2603
  # Only the rule's OWN declared mode is read, never Permittable.mode: that
2454
2604
  # is usually set by an initializer, and an app must not stop booting over
2455
2605
  # it. The effective mode can only add a requirement, so an action it makes
2456
- # monitored without a before_action is left to Audit.inert?, which reports
2606
+ # monitored without a before_action is left to Audit.inert_reason, which reports
2457
2607
  # it as not covered.
2458
2608
  def permittable_ensure_runnable!(rule)
2459
2609
  return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-11 00:00:00.000000000 Z
11
+ date: 2026-11-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport