permittable 0.8.0 → 0.10.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: 9eb3f4de06deb2a19c6a24b870e6f4d718350b67d733feb6d3ba423f27b6cdc9
4
- data.tar.gz: a1a231373f6d9bab90e1e14ff372c3ab22c163783f3beafb9c4e23e0e46b3d18
3
+ metadata.gz: e135a1605d2d98e7c3a3952e2f9dc47394a656249f52d22b7018ed5dc15b23cb
4
+ data.tar.gz: f3748c6014a1f74099719fc8c42af289910c9f94d36687837cbe409fd67538a1
5
5
  SHA512:
6
- metadata.gz: f69d441339746c659b6dc64589f77671f22fc13c701116389bbd916ce533ea80a737387605314679b47363c0de784f805aed75c83d7b835b8857aeac305167d2
7
- data.tar.gz: 9c64b6950b2149ce8aed60113c07bff435119c33090db0883a50464e31609fa52ceefe848ae52f0e1a3ea34a312669a7022e771f648f3e20ad12ef96fd4c9023
6
+ metadata.gz: 12881e40e20e390c7eb93963a5ca89aa402ed58d79eb0bfa7383d7a2ea6c5b93f6d61d1b35c94ed27b3b75774ddc75256e1d176f9cea5470d06200f64aaecb7e
7
+ data.tar.gz: 312bd2b824147af2821dc318c0a66a250d65bd7626d80574e91a0bfb5cc06159a0e22428e30b6d463bfe57ece1fe72c56b4ca8b513b7cdbde1419eb637cd0533
data/CHANGELOG.md CHANGED
@@ -1,5 +1,152 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.10.0 (2026-09-30)
4
+ <!-- title: canonical numeric strings, the app's own CSRF param, and defaults that agree with requests -->
5
+
6
+ Ten fixes and no new surface. Three change what an existing contract does. A numeric string must now be spelled canonically: `Integer()`, `Float()` and `BigDecimal()` all read underscore separators and surrounding whitespace, so `"1_8"` was eighteen and `" 99 "` was ninety-nine, and both are now `invalid_type`. An array `default:` now runs its sub-fields' `transform:` when the contract loads, so a request that omits the field and one that sends the default's value hand the action the same thing. And an app that renamed its CSRF parameter with `config.action_controller.request_forgery_protection_token` stops failing every form POST under `unknown: :error`. Alongside them: a `sensitive:` field no longer publishes its own `default:`/`example:` in the exported schema, a `message:` shared through `use` can no longer be rewritten from one contract into another, `permittable:generate` stops drafting an enum accessor the model does not answer to and stops hiding a real error behind an empty draft, and three pieces of process-wide state — the error-response schemas, the scalar JSON Schema entries and the `sensitive:` sink list — can no longer be corrupted by a caller or a concurrent class load.
7
+
8
+ Minor rather than patch: the API is untouched, but two fixes are visible from outside. A client that pads a number with whitespace or underscores now gets a 422 where it used to get the number, and an array `default:` whose sub-fields declare `transform:` is now handed out transformed — that `transform:` is app code, and it now runs when the contract loads. Read the first and third entries under **Fixed** before upgrading if either applies to your contracts.
9
+
10
+ ### Fixed
11
+ - **`:integer`, `:float` and `:decimal` read `"1_8"` as eighteen and `" 99 "` as ninety-nine.** The casts delegate to `Integer()`, `Float()` and `BigDecimal()`, which all accept underscore digit separators and surrounding whitespace — a convenience for a number literal in Ruby source, not for a request body, and the same kind of leniency the NaN/Infinity and underflow checks already close off through other doors. A numeric string is now checked against a canonical form before anything parses it: an optional sign, digits, an optional fraction and an optional exponent — `"-12"`, `"007"`, `".5"`, `"1.5e10"`, and `"1e400"` for `:decimal` all still cast — and anything else is `invalid_type`. `normalize:` is a `:string`-only option, so there is no in-contract way to keep the old whitespace tolerance; a client that pads a number has a formatting bug upstream, which is now reported rather than repaired.
12
+ - **A CSRF parameter renamed with `request_forgery_protection_token` failed every form POST under `unknown: :error`.** The top-level exemption hard-coded Rails' default `authenticity_token`, so an app that renamed it got `{ param: "csrf_token", code: "unknown" }` on every ordinary form submission — exactly the failure the exemption exists to prevent, spelled with the app's own key. The controller's configured token name is now read fresh on each request (it can vary per controller, and Rails may not have finished initializing when the gem loads) and exempted alongside the fixed keys. A plain params duck and a standalone `Contract` have no such setting and are unchanged, and so is monitor mode's raw pass-through.
13
+ - **An array `default:` skipped its sub-fields' `transform:`, so an omitted field and an explicitly-sent identical value diverged.** An authored array default is walked by the same code a request goes through, but that walk suppressed `transform:` on *every* field it visited rather than only the array's own. With `array :line_items, default: [{ "price" => "10.00" }] do optional :price, :decimal, transform: ->(v) { v * 100 } end`, a request omitting `line_items` got `price: 10` and one sending `[{ "price" => "10.00" }]` got `price: 1000`, and the exported OpenAPI `default` documented a value the server never produced. A sub-field's own `transform:` now runs over the default when the contract loads, so the stored value is what an equivalent request yields. The array field's **own** `transform:` still never runs over its default, exactly as `default:` documents, and the `with_default` matcher reads its expected array through the same walk, so it stays in agreement.
14
+ - **A field's `message:` was never deep-frozen, so two contracts sharing it through `use` could corrupt each other.** `default:`, `example:` and `in:` are deep-copied and deep-frozen on declaration; `message:` was frozen only at the top level in its Hash form and not at all as a String. `Permittable.fields` splices a group's field hashes into each contract by reference, so `msg << " NOW"` on one contract's violation message rewrote the message every other contract using that group would render, for the life of the process. Both forms now go through the same copy-then-freeze as the other authored values.
15
+ - **A `sensitive:` field's `default:`/`example:` were exported in the clear.** The field was marked `writeOnly` and registered for log redaction, and then the exported JSON Schema and OpenAPI document — the one output meant to leave the app, for client generators or a public docs endpoint — carried the real value under `default` and `examples`. Both keys are now omitted for a sensitive field, a child inheriting the cascade included; `description` is still exported, being authored prose rather than the value.
16
+ - **`permittable:generate` drafted `Model.statuses.keys` for an accessor the model does not have.** The generator only checked that the pluralised enum name was a valid identifier, so a renamed or excluded accessor produced a draft that raised `NoMethodError` the moment it loaded — while the schema-drift guard's own error messages already made the safer choice. The draft now also checks `respond_to?` and otherwise spells the list as `Model.defined_enums["status"].keys`, which is always callable.
17
+ - **`permittable:generate` swallowed every error while reading a model's columns and drafted an empty contract instead.** The rescue was a blanket `StandardError`, so a broken custom type adapter, or any plain bug, quietly produced a degraded draft with nothing to say why. It is now scoped to `ActiveRecord::ActiveRecordError`, the same rule `ColumnGuard.schema_reachable?` follows: a genuinely unreachable schema (no database yet, table not migrated) still degrades to "no columns", and anything else propagates.
18
+ - **`OpenAPI.document` handed out its error-response schemas by reference, so a caller editing its own document rewrote them for every document generated afterward.** `ERROR_SCHEMA` and `PROBLEM_SCHEMA` are frozen, but Ruby's `freeze` is shallow: assigning into a nested level of a generated document raised nothing and permanently changed the constant. Each document now gets a deep copy. The JSON Schema exporter had the same shape of exposure — a `SCALAR_SCHEMAS` entry was shallow-copied, so every `:decimal` schema shared one live `type` Array with the constant — and now deep-copies too. Nothing inside the gem mutated either in place; this closes the door on a caller doing so.
19
+ - **`sensitive:` sink callbacks were iterated without the lock they are appended under.** `Permittable.on_sensitive_parameter` appends to the sink list under `@registry_mutex`; a contract declaring a `sensitive:` field iterated that same list without it. The GVL hides this on MRI, but on JRuby or TruffleRuby a contract class-loading while a sink is installed is a concurrent mutation during iteration — a raise, or a sink call silently skipped, which is a parameter name that never reaches `config.filter_parameters`. The list is now snapshotted under the mutex and the callbacks run against the snapshot, outside it, so a sink that calls back into the gem cannot deadlock.
20
+
21
+ ## 0.9.0 (2026-09-27)
22
+ <!-- title: params.expect drafting, audit coverage, field groups, and a rewritten pattern translator -->
23
+
24
+ The adoption path gets four new pieces. `permittable:generate` now reads Rails 8 `params.expect` calls, not just `permit`, and drafts contracts a model would actually accept — the right root from `model_name.param_key`, split create/update rules so a NOT NULL column doesn't reject a partial update, correctly-typed Rails enums, and no STI or optimistic-locking column handed to clients. `permittable:audit` crosses the frozen contract registry with the app's own route set to report what's covered, what's only partially covered, and which uncovered action accepts a request body with nothing checking it — with a `[strict]` mode meant to run in CI. `Permittable.fields`/`use` gives reusable, composable field groups, so an `address` block three controllers want, or an `update` contract that is `create` with nothing mandatory, stops being copy-paste. And the `accept_params`/`reject_params` RSpec matchers extend `permit_param` from "is this declared" to "what does this payload actually do." Alongside them: `Permittable.error_format = :problem` for RFC 9457 `application/problem+json`, named `format:` presets (`:email`, `:uuid`, `:url`, `:slug`, `:hostname`) that export a real JSON Schema `format` keyword, and `Permittable.check_column_types`, which extends the schema-drift guard from "does the column exist" to "does its type agree" — Rails enums included.
25
+
26
+ Underneath, three whole classes of `in:` were wrong in ways that made request handling and its own documentation disagree with each other: `in:` list members are now cast with the field's own type before comparison (a `:string` field's `in: %i[draft published]` used to reject every request), an authored `default:`/`example:` is now stored the way a request would actually produce it rather than as typed, and the Ruby→ECMA-262 pattern translator was rewritten as one tokenizer pass, because `format:`'s two most-used presets (`:email`, `:url`) exported patterns Ajv couldn't even compile. A client-controlled key name could forge a log line; it's now escaped. Several encoding crashes on non-UTF-8 or invalid-encoding input — reachable through a standalone `Contract#call` with nothing in front of it — are `invalid_type` violations instead of 500s. This release also folds in three small, freshly-found gaps: an `array` field's `required:` + `default:` combination loaded silently and behaved as `optional` (the scalar and `:json` kinds already refused it), `accept_params`/`reject_params` disagreed with a standalone `Contract`'s own `unknown:` strictness on routing-shaped keys, and `permittable:generate` could draft a field from a `permit`/`expect` call that only ever existed inside a log string.
27
+
28
+ Minor rather than patch: mostly new surface, but a few fixes change behaviour for an existing contract. A `:string` field's `in:` list of Symbols now matches correctly where it used to reject every request; an authored `default:`/`example:` is now handed out cast rather than as typed; and an `array` field combining `required:` and `default:` — previously silently treated as optional — now fails at class load. Read the `in:`, `default:`, and `array` entries under **Fixed** before upgrading if any apply to your contracts.
29
+
30
+ ### Added
31
+ - **A CI matrix over the whole supported range.** The suite now runs against activesupport 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 across the supported Rubies, via one pinned gemfile per line in [`gemfiles/`](gemfiles/README.md) (no new dev dependency — plain `BUNDLE_GEMFILE`). The matrix is an explicit include list rather than a cross product, so it doubles as the answer to "which combinations are actually supported?". Variant lockfiles are deliberately not committed: each run resolves the newest patch of its line, so a regression in a supported version fails CI instead of being frozen out by a stale lock.
32
+ - **A `runtime-deps` CI job that proves activesupport is the only runtime dependency.** The spec suite can't: it bundles actionpack and activerecord to exercise the integration and schema-drift paths. This job installs the *built gem* with nothing but its declared dependencies, asserts actionpack/activerecord/rails are genuinely absent, and then exercises every controller-free surface — standalone contracts (casting, defaults, `finalize`, 400/422 semantics, nested plain hashes), the concern on a plain params duck, `unknown: :error` with no logger to warn through, `sensitive:` registration with no Railtie, monitor mode, instrumentation, JSON Schema, OpenAPI assembly, and the generator. Every `respond_to?`/`defined?` guard in the gem is a promise; a missing one now fails CI.
33
+ - **`spec/schema_conformance_spec.rb` — the "docs cannot drift" claim is now tested rather than argued.** Every other spec checks one side or the other; this one checks that the two **agree**, walking canonical JSON payloads through both a contract and its own exported schema and comparing the verdicts. It covers scalars and bounds, enums, exclusive and endless ranges, exact lengths, formats, arrays of scalars and of hashes, nested hashes under `unknown: :error`, rooted contracts, `nullable:` fields, and opaque `:json` fields with bounds. `spec/support/tiny_json_schema.rb` is a deliberately small validator covering exactly the keywords the exporter emits and nothing else — a measuring instrument, not a dependency — and one example asserts the exporter emits no keyword the validator silently ignores, so the two cannot fall out of step. The spec was mutation-tested: dropping `maxItems`, `additionalProperties: false`, the required-string `minLength: 1`, or the `root:` required wrapper each makes it fail.
34
+ - **Every place the schema and the runtime differ is now labelled, and its direction asserted.** Two go the safe way — the server accepts what the docs reject, so a client following the docs is merely conservative: non-canonical encodings (`"30"` for an `:integer`, `1` for a `:string`, because form and query payloads are all strings), and an explicit `null` read as absence on a field that is not `nullable:`. The ones that go the other way are now stated plainly instead of being left to be discovered, starting with a `:json` field's `max_depth:`: a bound JSON Schema has no keyword for, so the published document is **looser** than the server there and an over-nested payload still earns a 422. The bound is exported as `x-permittable-max-depth` rather than dropped, and the spec asserts that extension is present. (The rest of the looser list — and a third safe case — are under **Fixed**.) A new divergence in either direction fails the suite rather than shipping quietly.
35
+ - **`permittable:generate` now reads Rails 8 `params.expect` calls, not just `params.permit`.** The generator is the adoption on-ramp, and it was blind to the syntax Rails 8 apps actually use — a modern controller has no permit calls to scan, so the draft fell back to columns alone and lost everything the app already knew about its own params (the `root:`, the permitted key list, which keys aren't columns). `params.expect(user: [:name, tag_names: [], address: [:city], line_items: [[:sku]]])` is now scanned into the same `Scan`, merged with any permit calls in the same controller.
36
+ - **Arrays of hashes are drafted without a TODO when the source says so.** `expect` distinguishes what `permit` cannot: `key: [:a]` is a nested hash, `key: [[:a]]` is an array of hashes. A scanned `[[...]]` therefore drafts as `array :key do ... end` with no "this may be an array of hashes" TODO, and `Scan` carries the new `nested_arrays` member alongside `nested`.
37
+ - **Route params are not mistaken for fields.** In `params.expect(:id, user: [:name])` the `:id` is a routing key, not body input, so it stays visible in a TODO rather than being drafted as a contract field — as does a second envelope (`params.expect(user: [...], address: [...])`), which belongs under a different `root:` than one rooted contract can express. A rootless `params.expect(:q, :page)` still drafts as scalars, since there is no envelope to be a sibling of.
38
+ - **`Permittable::Audit` and `bin/rails permittable:audit` — coverage, including what is *not* covered.** A controller declaring `permit_params :create` looks adopted; if it also answers PATCH, that action validates nothing, and nothing in the gem said so — `permittable:generate` only notices controllers with no contract at all, and the OpenAPI exporter documents what exists rather than what is missing. The audit is the fourth reader of the frozen registry and crosses it with the **route set**, so a half-covered controller is as visible as an uncovered one. It reports, per routed action, the rule a request would actually resolve through, its **effective** mode (a rule's own `mode:` first, then the app-wide `Permittable.mode` — an audit runs inside the app, so unlike the exporter it can resolve this), whether a `model:` guards its columns, and whether an uncovered action **accepts a request body**, which is the difference between a gap in a table and untrusted input reaching an action unchecked. `bin/rails "permittable:audit[strict]"` exits 1 on that count, which makes the task a CI gate: no new unguarded write endpoint. It also lists contracts declared for actions no route reaches — a renamed or deleted action that left its contract behind. Controllers that never included `Permittable` are audited too, since those are the ones worth finding. Plain Ruby over the registry plus `{ controller:, action:, verb:, path: }` route descriptors (the shape `OpenAPI.rails_routes` already produces), so `Permittable::Audit.entries` is unit-testable without Rails.
39
+ - **`Permittable.error_format = :problem` — RFC 9457 problem details.** For a public API the standard shape for an error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), and the gem rendered only its own envelope (`ErrorEnvelope`'s own comment named this as the change it was waiting for). One app-wide setting now renders `application/problem+json` with `type` / `title` / `status` / `detail` / `instance` members and the field violations as the `errors` extension member — the identical `{ param:, code: }` entries (plus `message:` when the field declares one) the default envelope puts in `details`, so nothing about violation reporting changes, only the wrapper. `title` describes the problem **type** rather than the instance, so a missing `root:` reads "Malformed request" (400) and a field violation "Invalid parameters" (422); `status` is numeric, resolved without needing Rack for the statuses this gem raises; `instance` is the request path, omitted rather than guessed when the host cannot name one. `Permittable.problem_base_uri` gives each problem type a real URI, and until it is set `type` is RFC 9457's own default of `"about:blank"`. Choosing `:problem` deliberately **opts out of `render_error` delegation** — a host envelope and a problem document are two answers to the same question, and the explicit setting is the one honoured. The setting is app-wide rather than per-contract because the error format of an API is a property of the API.
40
+ - **Exported OpenAPI follows the configured error format.** With `:problem` set, the shared response components describe the problem schema under `application/problem+json` instead of the envelope under `application/json`. Unlike a rule's monitor mode — which the exporter reads only from contract data, never from runtime configuration — the error format has no per-contract declaration to read, and an export runs inside the app that made the setting, so reading it is what keeps the documented response shape from drifting from the rendered one.
41
+ Contracts that don't opt in are byte-for-byte unaffected: the default format is `:envelope` and the exported envelope schema is unchanged (the golden fixture still matches).
42
+ - **`format:` presets — `:email`, `:uuid`, `:url`, `:slug`, `:hostname`.** The regexps every app writes by hand, named once, mirroring how `normalize:` already works. `:email` is deliberately `URI::MailTo::EMAIL_REGEXP` *itself* — the regexp Rails apps already paste into their contracts — so adopting the preset cannot change which addresses an endpoint accepts. The rest avoid flags and Ruby-only constructs so they translate to ECMA-262 and export as real patterns. A preset name is resolved to its `Regexp` at class load, so request-time matching stays a plain `Regexp#match?` and an authored `default:`/`example:` is checked against the resolved pattern like any other; an unknown preset fails at class load listing the presets, and a `format:` that is neither a `Regexp` nor a preset name now fails too (previously a String was silently accepted and behaved as `String#match?`, which is not what anyone meant).
43
+ - **Presets export the JSON Schema `format` keyword**, which a hand-written Regexp cannot: `"format": "email"` / `"uuid"` / `"uri"` / `"hostname"`, alongside the `pattern` that still does the asserting (in draft 2020-12 `format` is an annotation unless a validator opts in). A preset's pattern is authored by this gem rather than by the app, so it skips the deliberately over-eager untranslatable-construct scan — it has to: the RFC-derived `:email` pattern contains `*+` inside a character class, which that scan reads as a possessive quantifier, so the most common format in Rails would otherwise have published no pattern at all. App-authored regexps keep the conservative treatment unchanged.
44
+ - **The RSpec matcher speaks both spellings**: `matching(:email)` asserts the preset by name, `matching(/re/)` the Regexp itself, and a mismatch says which of the two the contract declares.
45
+ - **`Permittable.check_column_types` — the drift guard can now check types, not just existence.** A column dropped by a migration already failed the deploy; a column **retyped** by one did not, so a contract could go on declaring `:datetime` long after the column became a string. Enabling the setting adds that half: `'placed_at' is declared :string but the column is :datetime`, with the same three fixes named as the missing-column error.
46
+ **It is off by default on purpose.** Every cross-type declaration has some legitimate use — a `:string` contract on a `date` column that lets ActiveRecord do the casting, a `:boolean` contract on a legacy integer column — and breaking those apps on an upgrade would cost more than the drift it catches.
47
+ When enabled it compares type **groups** rather than exact types, so it fires on a genuine cross-family mismatch and stays quiet otherwise. `boolean` is grouped with the numerics, because a boolean stored as an integer `0`/`1` is a real legacy pattern and ActiveRecord casts cleanly between them; the temporal types are one group, because a `:date` contract on a `datetime` column is a narrowing rather than drift. Any column type the gem has no faithful contract type for — `json`, `jsonb`, `binary`, an adapter's own `inet` or `money` — is **never** checked, so whatever an app improvised for those is left alone rather than guessed about. `virtual: true` opts out as before, and a missing column still reports as missing.
48
+ **A Rails `enum` is checked against what clients send, not what the column stores.** An enum is submitted by name, so `optional :status, :string, in: Order.statuses.keys` passes over an integer column. The `in:` is required, and every value it lists must be one the enum accepts on assignment: a name, or a stored value that is a String (so a string-backed enum's `"p"` for `pro:` counts). Without that `in:`, `status: "bogus"` would pass the contract and then raise `ArgumentError` in the action, so a text declaration without one, or with a value the enum would refuse, fails at class load and names the `in:` to add. String-backed enums follow the same rule. Any other type on an enum is still held to the column's group, and the error suggests the enum contract rather than `virtual: true`. The attribute API is **not** given the same treatment: `attribute :starts_at, :datetime` over a string column is still compared against the column.
49
+
50
+ ### Changed
51
+ - **The compatibility range is now tested rather than asserted, and narrowed to what passes.** CI ran one combination — the newest of everything — while the gemspec advertised `activesupport >= 5.0, < 9`. Testing the range surfaced two real problems. On **activesupport 5.0 and 5.1 a contract cannot be declared at all**: the registry is a `class_attribute ... default: []`, and `default:` arrived in Rails 5.2, so `permit_params` died on `NoMethodError: undefined method '+' for nil`. And on **activesupport ≤ 7.0.8.4, `require "permittable"` itself raised** `NameError: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger`, because concurrent-ruby 1.3.5 stopped requiring `logger` for them. The floor is now **`>= 6.1`** — the oldest line the full suite is run against — and the load failure is fixed with one stdlib `require "logger"` ahead of `require "active_support"`, so the gem loads whatever the host's own boot order.
52
+ - **The dev bundle pins `json < 3`, and the suite now makes Rails parse a real JSON body.** activesupport 8.1.3.1, which the lockfile is on, calls `JSON.parse(source, opts)` positionally, and json 3 rejects that, so with this pair **Rails cannot parse any `application/json` request body** ([#58](https://github.com/VSN2015/permittable/issues/58)). activesupport 8.1.4 fixes it (rails/rails#58601). The 8.1 compatibility gemfile has no lockfile, so CI already runs it on 8.1.4 with current json, unpinned. A Dependabot bump had brought json 3 into the lockfile without a failing spec, because no spec sent raw JSON through ActionDispatch. The spec harness's `json:` option no longer hands Rails a pre-parsed Hash, so every JSON-body spec now goes through Rails' real parser, and `spec/json_body_parsing_spec.rb` pins that path down. The gemspec is unchanged. This is a Rails bug, not a permittable one, but a host app whose bundle resolves json 3 has it too:
53
+ - **On activesupport 8.1.0 – 8.1.3.1:** upgrade to 8.1.4, the first release with the fix, or pin `gem "json", "< 3"` until you can.
54
+ - **On activesupport 6.1 – 8.0:** json 3 breaks decoding differently. It rejects the `quirks_mode:` option those lines pass (`ArgumentError: unknown keyword: quirks_mode`). No release of those lines fixes it, and upgrading to 8.1.4 doesn't help them, so pin `gem "json", "< 3"`. Those lines don't depend on json themselves, so this only bites when something else in the bundle brings in json 3.
55
+
56
+ ### Fixed
57
+ - **`unknown: :error` rejected routed and wrapped requests on a rootless contract.** The top-level unknown-key check exempted only the fixed router and form keys, but a real Rails request carries two more the client never sent as body fields. The route's path parameters are merged into `params`, so `PATCH /users/1` failed with `{ param: "id", code: "unknown" }` — while the exported OpenAPI documented that same `id` as a path parameter and the body as `additionalProperties: false`, so the docs and the server disagreed. And ParamsWrapper, on by default for JSON, copies the body under the controller's wrapper key, so every well-formed JSON request to a rootless contract failed with `{ param: "user", code: "unknown" }`. In the controller path the check now also exempts the request's path parameters (a contract that declares `id` still has it validated). And when Rails actually made the wrapper copy, a rootless contract no longer sees that key at all. A scalar or array field that shares the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than a false `invalid_type` against Rails' copy of the whole body. The exception is a rootless contract that declares the wrapper key as a hash container (a nested block or `:json`): it reads the copy on purpose, as it always could, so the copy is kept and validated there. Whether Rails made the copy is read before ParamsWrapper runs, fresh on every request, because afterwards the key's presence no longer distinguishes Rails' copy from a client that sent `user` itself. That one is still validated if declared and `unknown` if not, whether the wrapper name is a String or a Symbol (`wrap_parameters :user`). A genuine extra key is still `unknown`, a standalone `Permittable::Contract` still exempts nothing, and monitor mode's raw pass-through is unchanged: like the form keys, these change only what is checked, not the hash handed back, which a legacy action may still read `params[:id]` or `params[:user]` from.
58
+ - **Exported operationIds were not unique, so the document was invalid.** OpenAPI requires every `operationId` to be unique across the document, and it was not in two ways. One operation was placed at every slot its routes reach. `resources :users` draws two routes to `update`, one PATCH and one PUT, and a `match ..., via: [:patch, :put]` route answers both verbs. Either way, the export emitted `users_update` twice. And the id is the controller path with `/` folded to `_`, so `admin/users#index` and `admin_users#index` both became `admin_users_index`. Client generators name a method after the id, so in practice a duplicate is two methods with one name. Ids are now unique across the whole document, `x-permittable-controllers` included, since it feeds the same generators. **Only colliding ids change.** The naming scheme is untouched, and every operation's natural id is reserved before any is renamed. So an id that only one operation carries keeps its name, and so does every method generated from it, even when a suffix elsewhere would spell it. Within a collision, a routed operation keeps the plain id over an unrouted one; after that, the first in controller, action and route order keeps it. Within one operation PATCH comes before PUT, whichever comes first in route order (`match ..., via: [:put, :patch]` lists PUT first), so `resources :users` gives `users_update` on PATCH and `users_update_put` on PUT. Between different operations only the order decides. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free (`users_update_put`, `webhooks_receive_post`). Otherwise it gets the next free number (`posts_create_2` for a second POST, `users_update_2` for a second PATCH, `admin_users_index_2`). A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself. The golden fixture now covers the PUT half of the pair, and the README documents the scheme.
59
+ - **An optional route segment exported an invalid OpenAPI path.** `OpenAPI.rails_routes` stripped only the trailing `(.:format)` group, so every other optional group kept its parentheses: `scope "(:locale)"` exported `(/{locale})/posts` and `get "archive(/:year(/:month))"` exported `/archive(/{year}(/{month}))`. Parentheses are not valid in an OpenAPI path template, so **any app with a locale scope got a document that failed validation**, and the optional variable was declared `required: true` on a path where it could be left out. Each optional group is now expanded into the concrete paths it stands for — `/posts` and `/{locale}/posts`; `/archive`, `/archive/{year}` and `/archive/{year}/{month}`, nested groups recursively — each its own descriptor, so each path's parameters are genuinely required there. Where two expansions are the same URL under different variable names (`x(/:a)(/:b)` with one segment is always `:a`), only the variant Rails would actually match is kept, since OpenAPI forbids templates that differ only in their names. The variants of one route share one operation, so their operationIds are unique only once combined with the operationId dedupe (#52), which numbers colliding ids in route order; variants are emitted without each optional segment first, so `/posts` keeps `posts_create` and `/{locale}/posts` gets `posts_create_2`. **Descriptors gain a `route:` key**, the index of the route each came from, and **`Audit::Entry` gains a `route` member**, so an exact-equality comparison of descriptors or entries needs updating. `permittable:audit` lists each variant as its own row, because both are real URLs, but its summary collapses entries by controller, action, route index and verb, so one unguarded locale-scoped POST is not reported as two (when rows and routed actions differ, the line reads `N routed actions in M rows`). `post "/users"` and `post "/admin/users"` to the same action still count as two, and a hand-built descriptor with no `route:` counts as its own route. The index is a position within one `rails_routes` call, so lists concatenated from separate calls (an app plus an engine) collapse wherever the same controller#action sits at the same index in both; audit them separately, or give them distinct `route:` values.
60
+ - **The audit could not see a `via: :all` route, so `[strict]` passed over an unguarded write endpoint.** `match "hooks", to: "webhooks#receive", via: :all` leaves the route's verb empty rather than listing the verbs it answers, and `OpenAPI.rails_routes` skipped empty verbs — so the controller vanished from the audit even though it accepts POST, PUT and PATCH bodies with nothing checking them. An empty verb is now expanded into GET, POST, PUT, PATCH and DELETE, so the action is audited once per verb and its body-carrying verbs count toward the strict gate. The OpenAPI export shares the function, so a contract on such an action is now documented under each verb too.
61
+ **Upgrading can turn a green `[strict]` gate red.** The usual catch-all 404 route, `match "*path", to: "application#not_found", via: :all`, is now audited, and its POST, PUT and PATCH rows read `no contract — ACCEPTS A BODY`. Nothing new is exposed: the route took those bodies before, and the audit could not see it. The fix is to stop sending bodies to a controller action that only 404s. Route only GET to it. Rails answers HEAD from a GET route. If the other verbs need your 404 too, send them to a plain Rack endpoint, which never parses the body and which the audit does not list:
62
+ ```ruby
63
+ match "*path", to: "application#not_found", via: :get
64
+ match "*path", to: ->(_env) { [404, { "content-type" => "text/plain" }, ["Not Found"]] }, via: :all
65
+ ```
66
+ The `config.exceptions_app = routes` pattern shows the same rows for the same reason, e.g. `match "/404", to: "errors#not_found", via: :all`. The same fix applies, and there no Rack endpoint is needed: on 6.1 and later, `ShowExceptions` re-dispatches the error request as a GET, so `via: :get` is all those routes ever receive. Under `[strict]` the task aborts on these rows, so accepting them is not an option. The only choices today are to route the catch-all GET-only, as above, or to run the audit without `[strict]` until the audit ignore list ([#69](https://github.com/VSN2015/permittable/issues/69)) lands. **Do not** declare a contract on the catch-all to silence the gate. Under monitor mode a contract validates eagerly in the `before_action`, so every stray request body gets parsed, and a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also document a fake `/{path}` endpoint under five verbs.
67
+ - **`[strict]` failed on actions Rails would 404.** `resources :posts` routes all seven actions whether or not the controller defines them, so a controller with only `index` and `show` still reported `POST /posts create no contract — ACCEPTS A BODY` and failed the gate for an endpoint that cannot receive a request. The audit now asks Rails's own action resolver (`method_for_action`, the check behind `AbstractController::ActionNotFound`). So an action still counts when it is inherited, handled by `action_missing`, or rendered implicitly from a template, because Rails runs each of those with the body parsed. 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. Only an action none of these answers reads `action not found`. It is counted once as `missing_actions` and in no other summary bucket, so a contract left on a deleted action does not read as coverage, and it is left out of `uncovered_with_body`. That contract is listed with the stale contracts instead: it is exactly the renamed or deleted action the list is for. It is labelled rather than dropped. `Audit::Entry` gains the `missing_action` member (`missing_action?`). `Audit.summary` now always includes a `missing_actions` key, and `uncovered` no longer counts actions Rails would 404. Code that compares the whole hash for exact equality needs updating. A controller that cannot list its actions is assumed to define them all, which keeps the old behaviour. So is one whose resolver raises, because for a gate a false alarm is better than a missed endpoint.
68
+ - **`permittable:generate` drafted a Rails 8 scaffold's route param as a body field.** A scaffold's `set_post` calls `Post.find(params.expect(:id))`, separately from `params.expect(post: [...])`, and each call was read on its own. The rootless `:id` call was then drafted as `optional :id` inside the `post` envelope. The same-call spelling `expect(:id, post: [...])` was already sent to a TODO. The scan now picks the root from every call before it merges any fields, so once an envelope wins, the keys of the rootless `expect`/`permit` calls become TODOs rather than fields. A single-key `expect` lookup of `:id` or a `*_id` key is a route param and is never drafted as a field. A single-key `params.permit(:group_id)` is mass assignment, not a lookup, and is still drafted. So a model-less controller whose only call is `Post.find(params.expect(:id))` now gets no draft, where it used to get a rootless `optional :id` contract. A file with only rootless calls is a filter contract and still drafts them as fields.
69
+ - **Mixed envelopes were merged into the first one.** `result.root ||= root` put every later `permit` call's fields under whichever root came first, so an index action's `params.permit(:page, :per_page)` or `params.require(:search).permit(:q)` ended up in the `post` contract. Only calls for the chosen root now become fields, and a key the chosen root drafts is never also a TODO. The root is chosen in this order:
70
+ - **With a model known**, the model's envelope always wins if any call uses it, however few fields it has. This holds even for `require(:post).permit(*PERMITTED)`, which has none to count.
71
+ - **With no model known**, among the envelopes, the one with **more** distinct parsed fields wins; `*PERMITTED` counts for nothing. A tie goes to the first in source order, whether that is a `permit` call or an `expect` call. So `require(:search).permit(:q)` above `expect(post: [:title, :body])` no longer takes the root. With only one field each, the first in the source still wins. Such an envelope beats the rootless calls, as any envelope did before, when it has at least one parsed field. An envelope with no parsed field, such as `expect(search: FILTERS)`, beats only rootless calls with no parsed field either. Beside `params.permit(:title, :body)` it loses, and the draft is the rootless title/body contract it was before.
72
+ - **With a model that no envelope matches**, the rootless calls, taken together, compete too. The candidate with the most parsed fields wins, an envelope wins a tie with the rootless calls, and remaining ties go to source order.
73
+
74
+ Every envelope of a multi-envelope `expect(post: [...], comment: [...])` call counts. An envelope spelled with a constant, `expect(post: PERMITTED_PARAMS)`, is read as the `post` envelope with fields the scanner cannot parse.
75
+ - **Keys kept out of the contract were reported as unparsable.** A losing envelope and a rootless key beside an envelope both parse fine, but their TODOs read `could not parse from the permit call`. They now say why they were left out:
76
+ - a losing envelope: `belongs to another envelope (search): params.require(:search).permit(:q)`. A losing `permit` call is quoted as the call itself, on one line. A losing `expect` envelope is quoted as the argument the source spells it with, `search: [:q]` or `post: PERMITTED`;
77
+ - a route param, meaning a single-key `expect` lookup or a bare key spelled beside the envelope in the same `expect` call: `route or query param, not a body field: :id`;
78
+ - an array or hash spelled beside the envelope in that same call, or a key of a rootless call once an envelope won (a filter or a whole body, the scan cannot tell which): `outside the post envelope, so not in this contract: :page`.
79
+
80
+ `Scan` keeps these apart from `unparsed`, in three new members: `other_envelopes` (root => spellings), `route_params` and `rootless`.
81
+ - **A draft with no parsable fields raised when pasted.** A controller whose calls the scanner cannot read, such as `params.require(:post).permit(*PERMITTED)`, still counted as found. It produced a contract made only of TODO lines, and the model's columns were ignored. Loading it raised `ArgumentError: a contract must declare at least one field`. The same happened when the only scanned key was a column with no contract type, such as `binary`, which drafts only a TODO. Whenever no scanned line would declare a field, the draft now falls back to the column-derived fields and keeps the scan's TODOs underneath them. It leaves out any column the TODOs say is not drafted: a route param, or a key permitted in shapes that accept different input. The scan's rootlessness is kept only when the rootless calls carried a body field, so a rootless `params.permit(*KEYS)` controller gets a rootless draft. Drafting that one under the model's root would answer 400 to every request once enforced. A scan whose only readable call is a route-param lookup gets the model's root, for example `Post.find(params.expect(:id))` beside a `permit(policy(@post).permitted_attributes)` that the scanner skips. When the columns cannot declare a field either (none, or only unsupported types), `for_controller` tries the next candidate root, so a model's envelope that permits only a `binary` column does not stop the file's other envelope, or its rootless calls, from being drafted. Only when no candidate can be drafted is the result `nil`, the same as for no knowledge at all.
82
+ - **A key permitted in two shapes was declared twice.** `permit(:tags, :address)` in one action and `permit(tags: [], address: [:city])` in another drafted both `optional :tags` and `array :tags`, and loading the draft raised `field :tags is declared twice`. Each key is now drafted once. A nested hash and an array of hashes merge into the array of hashes, keeping the sub-keys of both. An array of scalars and a hash shape accept different input, so neither is drafted, and a TODO asks for the shape the actions share. Otherwise the richer shape wins over a scalar, because it is the shape at least one action is known to accept. A TODO names each shape and what was drafted (`tags is permitted as both a scalar and an array — drafted as the array`), and `Scan` has a new `conflicts` member for it. An empty nested list, such as `meta: [[ ]]` or `meta: [ , ]`, is kept as a TODO rather than drafted as a `do end` block the DSL rejects.
83
+ - **`params.expect(tag_names: [ ])` with a space was read as an envelope with no fields.** It drafted `root: :tag_names` and declared nothing. The envelope pattern's lookahead now skips whitespace itself, so the spaced spelling reads as the array of scalars it is, like `tag_names: []` already did.
84
+ - **A string-keyed scanned name that is not a bare symbol made the draft invalid Ruby.** `permit("2fa")` drafted `optional :2fa`, which is a SyntaxError. Scanned names are now written with `Symbol#inspect` (`optional :"2fa"`).
85
+ - **`permittable:generate` drafted contracts the model itself would have rejected.** Each of these was invisible in monitor mode and a wall of 4xxs the day the draft was enforced:
86
+ - **A namespaced model got the wrong root.** The fallback `root:` demodulized the class name, so `Blog::Post` drafted `root: :post` while Rails forms submit — and `params.require` reads — `blog_post`, and every submit was a 400 for a missing root. The root now comes from `model_name.param_key`, which is the key the form helpers use; a model without `model_name` keeps the old derivation.
87
+ - **Partial updates were rejected.** One rule covered `:create, :update`, and NOT NULL columns without a default became `required` in both, so a PATCH editing only `body` failed with `post.title missing`. NOT NULL is a fact about the row, not about each request. When anything is required the draft is now two rules: `:create` as before, and `:update` with the same fields all optional — the same partial-update reasoning that already kept database defaults out of `default:`. With nothing required it stays one `:create, :update` rule, and scan-based drafts split the same way, since their required fields come from the same columns. A default the model declares — `attribute :plan, default: "free"`, or `enum :status, {...}, default: :pending` — never reaches the schema but fills the field on create all the same, so it too keeps a NOT NULL column optional; it is shown as declared — `# model default: "pending"` (the enum key), `1.5` for a decimal — without casting it through the attribute type, and a `Proc` default is named rather than called.
88
+ - **Enum columns drafted as `:integer`**, rejecting the `"shipped"` every form sends. A Rails `enum` now drafts as `:string, in: Model.statuses.keys` — the model's own accessor rather than today's keys inlined, so adding a value cannot leave the contract behind — and a database default is shown as its enum key (`"pending"`, not `0`). Rails also assigns an integer-backed enum its stored integer (`status: 1` from a JSON client), which a `:string` field turns into a rejected `"1"`; the line carries a TODO saying how to admit it rather than guessing that clients send it. An enum whose name is not a method identifier (`first-status`) is reached as `Model.defined_enums["first-status"]`, so the draft still loads.
89
+ - **The STI inheritance column and `lock_version` were drafted as client-writable fields.** Mass-assigning `type` changes which class the record loads as, which is a privilege-escalation shape, not a field. Drafted from columns alone, both are now omitted from the fields and named in a TODO explaining why — including where to declare `lock_version` if the app's forms round-trip it for stale-update detection, worded for the rules actually drafted. When the controller's own permit call lists one, it stays a field with a TODO instead: omitting a `lock_version` the app sends would switch stale-update detection off the day the draft is enforced. Each counts only when the model uses it — a `type` column with `self.inheritance_column = nil`, or `lock_version` with `self.lock_optimistically = false`, is an ordinary column and drafts as one. A model left with no field to draft (only `type` and `lock_version`, or nothing else with a contract type) drafts nothing, as a model with no columns does, rather than a rule that raises `a contract must declare at least one field` when pasted.
90
+ - **Column names that are not symbol literals produced a draft that did not parse.** `first-name`, `2fa_enabled` and `Email Address` were emitted as `:first-name` and friends; names are now emitted with `Symbol#inspect` (`:"first-name"`), so the draft is valid Ruby whatever the schema.
91
+ - **`not_to permit_param` could pass on a param the contract permits.** The matcher had no `does_not_match?`, so RSpec negated `matches?` — and every qualifier became an escape hatch. With `:admin` declared optional and boolean, `not_to permit_param(:admin).for_action(:create).required` passed (it is declared, just not required), as did `.as(:string)` (it is declared, just not a string): a false positive in what is typically a security assertion. A negated qualifier is ambiguous by construction, so it now **raises**, naming the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). That covers every narrowing chain — `as`, `as_array`, `required`, `optional`, `within`, `matching`, `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. The plain negated form means "the contract does not declare it"; it reads the contract rather than the runtime mode, so under a monitor-mode rule an undeclared key still comes through `permitted_params` (which hands back the raw params there) until the rule enforces.
92
+ - **`not_to permit_param(...).for_action(:craete)` passed silently.** A mistyped action resolves to no rule, a rule that doesn't exist declares nothing, and so the negated form passed for any param at all — while the positive form failed loudly. It now fails too, saying the controller has no contract covering `#craete`; likewise for a subject that declares no contracts. `for_action` resolves exactly as a request would, so a controller with a catch-all rule (declared with no actions, or inherited) still resolves a typo to that rule, and the assertion is checked against it.
93
+ - **`not_to permit_param` passed on keys the contract lets through.** A path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`) was reported as undeclared, although `:json` accepts any nested key; the negated form now fails, and the positive form passes (refusing qualifiers it cannot check, since nothing about the nested key is declared). A path deeper than the field's `max_depth:` is still not permitted, counting each key and `[n]` step the way the runtime does. Paths in the runtime's own violation form, `"line_items[0].sku"`, now resolve instead of passing negated as undeclared. A path repeating the `root:` (`"user.email"` under `root: :user`) passed negated because paths are relative to the root; when the rest of the path resolves, both forms now fail with `paths are relative to root: :user, so write permit_param(:email)`.
94
+ - **A malformed path crashed the matcher.** `permit_param("")` raised `NoMethodError`, and `"a."` was silently read as `"a"`; an empty path or empty segment now raises an `ArgumentError` naming the path.
95
+ - **`as_array(of: :string)` on an array of hashes printed `but it is of: :`.** It now says `:line_items is an array of hashes`, and `as(:string)` on one no longer suggests `as_array(of: ...)`, which cannot apply to it. On a field that is not an array at all, the redundant empty `of:` line is gone; the `expected an array field` line already says it.
96
+ - **A `:decimal` bounded by BigDecimals exported an invalid schema.** `in: BigDecimal("0.01")..BigDecimal("999.99")` — the natural way to bound a price — published `"minimum": "0.01", "maximum": "999.99"`, because range bounds went through the same re-encoding as an authored `default:`, which renders a BigDecimal as its precision-safe string. The metaschema requires `minimum`/`maximum` to be numbers, so the whole document failed validation. Bounds are now JSON numbers: an Integer when the bound is one, otherwise a Float. Where the nearest double would let through a value the server refuses (possible only past 15 significant digits — never a price), the bound moves **inward**, a minimum up and a maximum down, one double at a time until the field's own cast and comparison accept the most extreme value the docs admit. That check matters because the right distance depends on the type: a `:decimal` compares exactly, while a `:float` compares through BigDecimal's lossy reading of a Float and needs a few doubles more. So the published range is always exact as written and can only be narrower than the enforced one, on either type — a caveat worth stating plainly: a client that parses the published JSON with ordinary double-precision floats, rather than reading the digits as sent, can still round a value across the boundary. That is inherent to how a double reads any JSON number and is not something this exporter can fix from the document side.
97
+ **An INTEGRAL bound is exact at any magnitude**, `10**400` included: a JSON number literal has no size limit, so `optional :x, :float, in: (10**400..)` now publishes `minimum: 10**400` outright, matching master, instead of vanishing. (It briefly did vanish in an earlier draft of this fix: the inward-nudge above routes a candidate bound through `to_f` to ask the field's own cast whether it is honoured, which itself overflows a bound this large to `Infinity`, walking the bound to `Infinity` and dropping it — looser than master, not a labelled divergence.) A genuinely **fractional** bound past `Float::MAX` (`BigDecimal("1e400") + BigDecimal("0.5")`) has no such escape — there is no arbitrary-precision JSON number this exporter emits without going through `Float` — and stays omitted, same as an infinite bound.
98
+ An infinite bound (`Float::INFINITY`, `BigDecimal("Infinity")`) means no bound and is omitted, as is a NaN one; neither is a JSON number. `default:`/`example:` keep their string encoding, which `:decimal`'s type permits.
99
+ - **Most of the places the docs are looser than the server were unlabelled.** Only `max_depth:` was listed. Now the README, the exporter's own comment and `spec/schema_conformance_spec.rb` also name the other five — `LOOSER` in the spec, six keys in all — the spec labels each, asserts its direction, and checks that the rule is still visible on the diverging field's own schema, so none can pass as agreement:
100
+ - **`normalize:` runs before the checks.** With `length: 3..10, normalize: :squish`, `" "` passed the docs and was `missing` on the server, and `" a "` passed them and failed `length`. The step was not exported at all; it is now `x-permittable-normalize` — the preset's name, or `true` for a custom proc. (It also runs the safe way — `" abcdefghij "` is too long for the docs and fine once squished — which is listed with the safe cases.)
101
+ - **A bounded `:decimal` sent as a string** skips `minimum`/`maximum`, which constrain only numbers, so `"5000"` passed the docs for a bound the server enforces.
102
+ - **Strings whose validity is a `format`.** `"abc"` or `"NaN"` for a `:decimal`, and `"2026-02-30"` for a `:date` or `:datetime`, pass the docs — `format` is an annotation in draft 2020-12 — and fail the cast.
103
+ - **A `validate:` proc** is opaque app code, so the schema can only flag it — `x-permittable-custom-validation` — never enforce what it checks.
104
+ - **A Range of non-numbers**, such as `in: "a".."m"`, has no `minimum`/`maximum` equivalent and rides along as `x-permittable-range` instead.
105
+
106
+ An untranslatable `format:` regexp (`x-permittable-pattern`) is a seventh case in the same spirit — a value it would refuse still passes the docs — but it is **not** one of the spec's six: [#66](https://github.com/VSN2015/permittable/pull/66), reworking the Ruby → ECMA-262 `pattern` translation itself, owns adding its conformance case, so this PR only lists it as a known gap rather than asserting a direction for code it does not touch.
107
+
108
+ No runtime behaviour changes to the request/response path — this is what the schema *says*, not what the server enforces.
109
+ - **Exported `pattern`s that Ajv cannot compile.** Ajv builds every pattern as `new RegExp(pattern, "u")`, and Unicode mode rejects much of what Ruby accepts. The everyday `/\A\d{3}\-\d{4}\z/` exported as `^\d{3}\-\d{4}$` — `Invalid escape` — and so did the gem's own `:email` and `:url` presets, whose sources contain `\#` (#47): the two most-used presets published a pattern the most common JSON Schema validator throws on. Redundant identity escapes (`\-` outside a class, `\#`, `\ `, `\_`) are now written bare, which means the same thing and compiles, and a bare `{` or `}` that Ruby reads as a literal is escaped. Constructs with no faithful ECMA-262 spelling now export as `x-permittable-pattern` instead of a broken or silently different `pattern`: atomic `(?>…)`, comment `(?#…)` and `(?'name'…)` groups, `{,3}` (a literal in ECMA-262 without the flag), `{3}?` (optional in Ruby, exact in ECMA-262), `&&` class intersection (two ampersands there — including straight after a range hyphen, where `[$-&&%]` matches nothing in Ruby and `"%"` in ECMA-262), nested classes, octal escapes, `\e`, `\x7`, `\g<name>`, stacked quantifiers and quantified lookarounds.
110
+ - **`\s` and `.` exported as rules looser than the server's.** Ruby's `\s` is ASCII-only; ECMA-262's also matches NBSP, U+2028, U+FEFF and every other Unicode space, so `/\A[^@\s]+@[^@\s]+\z/` documented `"jo\u00a0x@example.com"` as valid while the server rejected it. `\s` now exports as `[ \t\n\v\f\r]` (spliced into a class: `[^@ \t\n\v\f\r]`) and `\S` as its complement; In a class, `\S` is carried only where it can be written exactly: alone it exports as `[^ \t\n\v\f\r]` (`[\S]`), and beside `\s` — the `[\s\S]` any-character idiom — the whole class exports as `[\s\S]` (or negated, `[^\s\S]`) even with other members alongside the pair, since `\s` and `\S` together already cover every character whatever either escape means. `\S` without `\s` is refused whenever anything else shares the class, since there is no way to splice its complement into a class the way `\s` splices in. Ruby's `.` stops only at `\n` where ECMA-262's also stops at `\r`, U+2028 and U+2029, so it exports as `[^\n]`. The golden fixture's email pattern changes accordingly.
111
+ - **Backreferences exported as rules looser than the server's.** A reference to a group that took no part fails in Ruby and matches the empty string in ECMA-262, so `/\A(a)?\1b\z/` rejected `"b"` at runtime while its exported pattern accepted it — and `\12` is a backreference or an octal escape depending on how many groups precede it. Backreferences now export as `x-permittable-pattern`.
112
+ - **`\b` and `\B` exported as rules looser than the server's.** Ruby's word boundary counts a non-ASCII letter as a word character (although its `\w` does not), while Unicode mode's is ASCII-only. So `/\Aa\b/` rejected `"a\u00e9"` at runtime while `^a\b` accepted it, and `\B` diverged the other way. Outside a class both now export as `x-permittable-pattern`; `[\b]`, a backspace in both dialects, still translates.
113
+ - **A literal `-` right after a completed range was refused instead of translated — a regression against master.** The range-hyphen guard treated "the previous class member was a range" the same as "the previous member was a class escape (`\d`/`\w`/`\s`)," and refused both, on the theory that `[a-c-e]` means a different range in Unicode mode. It does not: both dialects read a hyphen right after a range's own end as an ordinary member, so `[a-c-e]` is the set `{a, b, c, -, e}` — rejecting `"d"` — in Ruby and in Unicode mode alike. So a common `format:` like `/\A[a-zA-Z0-9-_]+\z/` published no `pattern` at all, where `origin/master` exported one correctly. That hyphen is now carried as a literal member, and may itself reopen a further range (`[a-z--x]`, Ruby's own reading of a second hyphen). The class-escape refusal stays for the other half of the guard, but only as a backstop: Ruby itself raises `RegexpError` building `[\d-z]` or `[a-\d]` on either side of the hyphen, so that source can never reach the translator as a real Regexp, and the two dialects were never observed to disagree on it.
114
+ - **An escaped backslash before `A` or `z` corrupted the exported pattern.** Anchors were rewritten with a plain `gsub`, so `/\A\\A\z/` (a literal `\A`) exported as `^\^$`. Translation is now one tokenizer pass that reads each escape pair as a unit, and a whitelist: every token is either rewritten into something both engines read identically, or the whole regexp exports as `x-permittable-pattern`. The same pass reads a character class as a unit, so presets no longer need to skip the untranslatable-construct scan — `:email`'s `*+` inside a class is two literals, not a possessive quantifier. A new spec compiles every exported pattern — every preset, a table of app-style regexps, the golden fixture — under the `u` flag with Node, and checks each accepts exactly what Ruby accepts on edge-case samples (skipped, with a message, where `node` is not on `PATH`).
115
+ - **An array's `validate:` ran over the `nil`s left by elements that failed to cast, so a 422 became a 500.** `array :ids, of: :integer, validate: ->(a) { a.sum < 100 }` sent `["x", 2]` raised `TypeError: nil can't be coerced into Integer` from inside the app's own validator. `transform:` was already skipped when an element violated. `validate:` now follows the same rule, and the request reports only the element's `invalid_type`. An array of hashes gets the same treatment, since a failed sub-field is missing from the element `validate:` would have received. An undeclared key inside an element (`unknown: :error`) removes nothing from the element, so it does not stop `validate:` from running. `transform:` is unchanged: it still runs only when nothing violated, `validate:` included.
116
+ **The trade-off: an array-level violation is no longer reported alongside element violations.** `[1, "x", 1]` against a uniqueness validator used to report `ids[1]` *and* the duplicate. Now it reports only `ids[1]`, so the client fixes that, resends, and only then learns about the duplicate. That second round trip is the cost of never running app code over `nil`s it did not agree to handle.
117
+ - **`:integer` given a NaN or Infinity Float raised `FloatDomainError`.** `Float#truncate` raises on a non-finite value, and the cast rescued only `ArgumentError`. This was reachable through a standalone `Contract#call`, which exists to report bad input as a violation, and through JSON parsed with `allow_nan`. A non-finite Float is now `invalid_type`, the rule `:float` and `:decimal` already followed.
118
+ - **A String that was not valid in its encoding crashed `normalize:` and `format:`, and a String in another encoding was misread or crashed.** `"caf\xC3"` raised `ArgumentError: invalid byte sequence in UTF-8` from `:squish` and from `Regexp#match?` (`Encoding::CompatibilityError` from `:strip` and `:email`). With no normalizer or format, a bare `:string` passed it to the app unchanged. A *valid* String in another encoding was misread or crashed too: `Integer()` on UTF-16 `"12"` raised `Encoding::CompatibilityError`, `:decimal` read the same String byte by byte and returned `1`, and a UTF-16 or binary String with non-ASCII text raised in `format:` and in `:squish`. In a controller Rails' params builder normally refuses invalid UTF-8 first, but a standalone `Contract#call` on a webhook payload has nothing in front of it, and a controller using `skip_parameter_encoding` or `param_encoding` receives binary or other-encoding Strings on purpose.
119
+ Other encodings are now **inspected, never converted**. A String whose bytes are not valid in its own encoding is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. A `:string` value is otherwise handed back exactly as it arrived, in its own encoding, so a `skip_parameter_encoding` controller behaves as it did before this release, except that its former crashes are now violations. The number, boolean and date types parse a UTF-8 copy of the text: UTF-16 `"12"` is `12`, and text with no UTF-8 reading (a byte Windows-1252 leaves undefined) is `invalid_type`. A normalizer that cannot handle a String's encoding (`:squish` on UTF-16) leaves the value as it is, and a `format:` pattern that cannot be applied to it (non-ASCII pattern, UTF-16 or binary bytes) reports `format`. Neither ever raises for such a String; on UTF-8 or ASCII-only text an app's own `normalize:` proc that raises still raises. The check sits in the one cast every scalar value goes through, so it covers `of:` elements, sub-fields of arrays of hashes and authored `default:`s. The codes are the existing `invalid_type` and `format`, so the `code` enumeration in the exported error schema is unchanged. A `:json` field's contents are deliberately not examined. The gem runs no string operation inside them, and walking every leaf on every request would add the cost the opaque type exists to avoid.
120
+ - **An undeclared key that was not valid UTF-8 made the 422 itself fail to render.** Under `unknown: :error` the client's key was copied raw into the violation's `param`, so `"caf\xC3"` made `to_json` raise `JSON::GeneratorError`, and a UTF-16 key raised `Encoding::CompatibilityError` while its path was being built, under `unknown: :log` too. An undeclared key is now converted to UTF-8 for reporting (binary read as UTF-8, other encodings via `encode`), and whatever still cannot be read is replaced with U+FFFD, so `param`, the exception message and the log line are always valid UTF-8, nested keys (`user.x\uFFFD`) included. Only undeclared keys are converted; the declared keys a request walks through are the contract's own names and cost nothing extra.
121
+ - **A client-sent key could forge a log entry.** The `unknown: :log` warn line, the monitor-mode warn line and `InvalidParameters#message` all interpolate the names of the keys a request sent, raw — and 0.8.0's prose bounds capped how *many* and how *long*, but escaped nothing. A key of `"evil\nE, [2026-09-23] ERROR -- : forged admin login"` therefore wrote a second, entirely fake `ERROR` line into the log. A name containing an unsafe character is now printed quoted and escaped: `"evil\nE, [2026-09-23] ERROR -- : forged admin login"`, with `\n`/`\r`/`\t` short and anything else as `\uXXXX` (`\u{XXXXX}` beyond the BMP). Unsafe means, by Unicode property: every control character (`Cc` — C0, DEL and C1, which holds NEL and the 8-bit terminal CSI); the line and paragraph separators (`Zl`, `Zp`); every format character (`Cf` — the bidi embeddings, overrides and isolates, which can visually reorder a line and hide where a quoted name ends, and the zero-width and marker characters such as U+200B, U+200E/U+200F, U+061C and U+FEFF, which make two names print identically); and every space other than U+0020 (`Zs`), so `x,<NBSP>and 49990 more` cannot pass for a separator. A name that only *looks* like prose structure is quoted too, its characters printed as they are: one containing any Unicode `Quotation_Mark` (`"`, the fullwidth `"`, curly quotes, guillemets, and the CJK corner brackets `「`/`」`, real quotation marks in Japanese and Chinese text that an earlier, narrower `Pi`/`Pf`-only check missed); one containing the list separator `, ` or a fullwidth, small, ideographic or small-form-ideographic comma (`,` `﹐` `、` `﹑`), so it cannot pass for two names; and one beginning `and N more` in any letter case (`And`/`AND` reads identically once rendered), so two keys `b` and `and 49990 more` — or `And 49990 more` — cannot fake the overflow count. That last check still only catches the literal word: it defeats case and punctuation lookalikes, not a homoglyph substitution such as Cyrillic `а` (U+0430) for Latin `a`, and no general Unicode confusable-detection is attempted here — nobody should rely on this guard as a complete defense against a determined lookalike. Inside a quoted name the quote and backslash are escaped. Only the client-sent name is ever escaped: a field's `message:` (or its I18n copy) is the developer's text and is printed as is, so a YAML `|` message ending in a newline does not quote the names it follows. A name is judged by the part the 120-character truncation leaves visible, so a control character past the cut quotes nothing. A quoted name is cut only when it does not fit the limit on its own, between whole escapes and with the `...` outside the closing quote; a quoted name that fits stays whole and its suffix is cut instead — to nothing, if need be, in which case the `...` marking the cut may run up to three characters past the limit. Only such names change: an ordinary name — non-ASCII, backslashes and a bare comma included — prints as before, now always as UTF-8. A key in a legacy encoding is transcoded character by character: what maps is converted (a Windows-1252 `é` prints as `é`, a Latin-1 `0x85` as its NEL escape), and only a byte that has no mapping — Windows-1252 leaves `0x81`, `0x8D`, `0x8F`, `0x90` and `0x9D` undefined — prints as `\xNN`, as do bytes that are not valid UTF-8. The prose list is always joined from UTF-8 items, so names in mixed encodings no longer raise `Encoding::CompatibilityError` there (joining a nested key's path is covered by #61). Only a bounded prefix of each name is read, so a 1 MB name costs no more than a short one. **`InvalidParameters#message` is also what API clients read** — the envelope's `message` and the problem+json `detail` — so a client that sent such a key sees the escaped rendering there too. The machine-readable channels — `details`, the problem+json `errors`, and the instrumentation payload — are data, not prose: an unknown key that is not valid UTF-8 is reported there as `Coercion.reportable_text` reads it (scrubbed to U+FFFD, per #61), so they stay JSON-safe rather than as legible as this escaping can make it, and prose (the log line and the exception message alike) reads the raw key itself, so a client sees the finer `\xNN` transcoding in the message even though `details` shows the plainer, scrubbed form for the same violation.
122
+ - **`in:` members are now cast with the field's own type, at class load.** The runtime compared the *cast* request value against the members *as authored*, so `in: %i[draft published]` on a `:string` field compared `"draft"` with `:draft` and answered `inclusion` to **every** request — while the exported schema, which stringifies Symbols, advertised `"enum": ["draft", "published"]`, the very values the server refused. `in: %w[1 2 3]` on an `:integer` field rejected every value the same way. Each member of a **list** — an `Array`, `Set`, `Enumerator` (`Lazy` included, forced once rather than cast per request), or a `Hash` read as its keys, which is what `Hash#include?` always asked about — now goes through the same cast a request value does: a Symbol is read as its String, and `normalize:` is not applied, since it rewrites what a client sent rather than what the contract says. The contract stores the cast members frozen and deduplicated; a `Set` or a `Hash`'s keys are stored as a `Set`, so membership stays O(1) per request. The Rails enum idiom `in: Post.statuses` therefore keeps working, and satisfies `check_column_types`' enum rule, which reads the stored keys. Request-time matching, the exported `enum` (which for `%w[1 2 3]` on an `:integer` is now `[1, 2, 3]`, not `["1", "2", "3"]`), the column guard and the RSpec matcher all read that one list; `within` reads its own argument with the same two functions, and compares lists as sets, so `within(%i[draft published])` can repeat the declaration as written. A `nil` member is dropped on a `nullable:` field, where an explicit null is accepted before `in:` is consulted. A `Time` or `DateTime` member of a `:date` field is read as its date when it is exactly midnight UTC — the one instant ActiveSupport ever found equal to a date.
123
+ - **Any other object answering `include?` is still used exactly as given**, Enumerable or not — an app's own case-insensitive allowlist, or a DB-backed registry, is never enumerated at class load nor replaced by an exact-match copy. This includes a `Hash`/`Array`/`Set` **subclass that overrides `include?`**: `case allowed; when Hash ...` matches with `===`, which for a Class is `is_a?`, so a subclass first matched its ancestor's branch and had its override silently discarded, read for its raw keys/elements instead — inverting which values it actually accepted, with no error at class load. Only a plain `Array`, `Set`, `Hash`, or `Enumerator` (an unoverridden `include?`) is now read as a list; `ActiveSupport::HashWithIndifferentAccess` is kept as one anyway, since its own override only canonicalises the argument before the same key lookup, and it is what a Rails enum's own reader (`Post.statuses`) actually returns. Any other override is not cast, and is exported as `x-permittable-custom-validation` rather than an `enum` it cannot list.
124
+ - **Every exported `:date`/`:datetime` member is one the server accepts.** A member written as a String is published as written, and a `Time`/`DateTime`/`TimeWithZone` member is published with as many fractional-second digits as it has (up to nine). Re-encoding printed whole seconds, so `in: ["2026-09-05T10:00:00.25Z"]` published `"2026-09-05T10:00:00Z"`, a value the server refused. The same encoding applies to an exported `:datetime` `default:`/`example:`. A spec sends every exported member back through the contract.
125
+ - **`in:` can no longer be a `String`.** The only check was `respond_to?(:include?)`, which a `String` passes — and `String#include?` is a substring test, so `in: "free pro"` accepted `"e"`, `"fr"` and `"ee p"` as plans. A `String` now fails at class load, as anything that is neither a `Range` nor answers `include?` already did.
126
+ - **An `in:` `Range` the field's values cannot be compared with fails at class load.** `in: "1".."5"` on an `:integer`, or `in: 1..5` on a `:string`, made `cover?` answer `false` for every value. A Range is deliberately **not** cast — casting would change what it means (`0..Float::INFINITY` on a `:float` and `1.5..3` on an `:integer` are real bounds no cast accepts, and a `:decimal`'s `0..100` would export its `minimum` as the string `"0.0"`) — so it is kept exactly as written, and refused only when its endpoints cannot be compared with a value of the field's type, asked the way `cover?` itself asks.
127
+
128
+ **A list is now a snapshot taken at class load.** Until now an `in:` Array was read live, so a constant mutated after the class loaded — `PLANS << "gold"` in an initializer — was seen by later requests. It is now cast and frozen once, so that mutation is not seen. Declare the full list before the contract loads, or pass an object of your own answering `include?`, which is still read on every request.
129
+
130
+ The cast only **loosens** what a request is judged against: no request a contract accepted before is refused now. These declarations used to boot and now fail at class load, each because something in it could never match — though a list's other, valid members did match before, so a contract such as `in: [1, 2, "three"]` was partly working, not wholly broken:
131
+ - a `String` `in:` — it matched substrings; write it as a list, `in: %w[free pro]`;
132
+ - a list member the field's type cannot cast (`"three"` in `in: [1, 2, "three"]` on an `:integer`), including `nil` on a field that isn't `nullable:` (the error names `nullable: true`), and a `Time`/`DateTime` member of a `:date` field that is not exactly midnight UTC;
133
+ - `in: [nil]` on a `nullable:` field, which lists nothing once the `nil` is dropped (an explicit null needs no `in:`);
134
+ - a `Range` whose endpoints the field's values cannot be compared with (`in: "1".."5"` on an `:integer`) — this one did reject every value.
135
+
136
+ One exported-docs change for lists that already worked: the `enum` now carries the field's own encoding, so `in: [1.5]` on a `:decimal` exports `"1.5"` (the same precision-safe string its `default:` already exports) and `in: [1, 2]` on a `:float` exports `1.0, 2.0`.
137
+ - **An authored `default:` was handed out in its authored form, not the type it declares.** It was validated by casting, and the cast was then thrown away: `optional :age, :integer, default: "18"` gave every request omitting `age` the String `"18"` — while a request *sending* `"18"` got `18` — and `:boolean, default: "false"` handed the action a **truthy** `"false"`. `:decimal, default: 1.5` stayed a Float, `:date, default: "2026-01-05"` a String, `of: :integer, default: ["1", "2"]` an array of Strings. The exported JSON Schema published the same lie: `"type": "integer", "default": "18"`. A default is now stored as the contract reads it — normalized and cast — and so is a documentation `example:`, which goes through the same check; the schema's `default`/`examples` follow.
138
+ An array's default is now read by **the request walker itself**, so it is stored exactly as a request sending it would read it. The hand-rolled check it replaces looked one level into a block array's elements and got the rest wrong: a nested hash or array inside an element stayed uncast, `""` on a nullable sub-field stayed `""` where a request gets `nil`, a sub-field's own `default:` was not filled in, and keys the block does not declare were kept (they are now dropped, as `unknown: :ignore` drops them). The array's own `validate:` now runs over its authored default too, as it already did for a scalar's — so a default that `validate:` refuses fails at class load.
139
+ **A field declaring `transform:` is the one exception to all of the above, on both scalars and arrays.** `transform:` never runs on a default, neither at class load nor on the way out — that part was already true — but casting the default here was itself a regression this PR introduced and then had to take back: the README has always told authors to write such a default already in the shape `transform:` would produce (`transform: ->(v) { v.to_i }, default: 25` on a `:string` field, so the app gets the Integer `25` either way), and casting it against the field's own *declared* type (`:string`) silently broke that promise — `default: 25` came out as the String `"25"`, a Date default on a `:datetime` field came out as a Time, an Integer array default came out stringified. A field with `transform:` now stores its default exactly **as authored** — still validated against the field's own contract at class load, like any default, but neither cast nor walked — while a field with none continues to get the cast behavior above. Class-load errors for an array's authored value name the path and code the walker reports (`:default for array :items violates its own contract: items[0].sku (missing)`).
140
+ Four visible knock-ons.
141
+ - The `with_default` matcher reads its argument the same way before comparing — cast for a field with no `transform:`, bare for one that has it, matching how the default is now stored either way — so both the declaration's spelling (`with_default("18")`, `with_default("2026-01-05")`, `with_default([{ sku: "a" }])`, or `with_default(25)` against a `transform:` field's own `default: 25`) and the stored one pass, and a failure shows what the spec wrote. It also copies its argument before normalizing, the same reason the contract itself copies an authored String before normalizing it — a mutating `normalize:` proc (`->(v) { v.strip! || v }`) could otherwise rewrite the spec's own literal instead of a copy.
142
+ - A `:decimal` default/example is now a BigDecimal, which the exporter wrote as a JSON string — so `default: 1.5`, exported as the number `1.5` until now, would have become `"1.5"`. A BigDecimal default/example is exported as a JSON **number** whenever a Float holds it exactly, and as a string only when a Float would lose precision; that also turns an authored `example: BigDecimal("19.99")` from `"19.99"` into `19.99`. `enum`/`minimum`/`maximum` keep their String encoding — **except** a `:decimal` field's own `in:` enum, which follows the same numeric-vs-string rule as its `default:`/`example:` now: otherwise a numerically-exported default was no longer a member of its own enum's exported list, which schema linters reject. That numeric-export rule is scoped to a `:decimal` field's own default/example/enum and never recurses into a `:json` field's contents, which stay opaque: a BigDecimal found inside a `:json` default is still exported as a String, exactly as before, rather than reinterpreted as a number (silently changing an untyped value) or coerced through `Float` — which would crash `JSON.generate` on a non-finite one, `BigDecimal("Infinity")` included.
143
+ - `cast_string` renders a BigDecimal with `to_s("F")` rather than the generic `to_s` every other Numeric gets, so a BigDecimal `default:`/`example:` on a plain `:string` field publishes as `"1.5"`, not the scientific `"0.15e1"` `BigDecimal#to_s` gives by default. (A Rails host rarely notices: `active_support/core_ext/big_decimal/conversions`, pulled in by `active_record`, patches that default away — the same way it masked the 0.8.0 `TimeWithZone` regression. A standalone host has no such patch, and `permittable` promises to work in one.)
144
+ - **A default could still raise `FrozenError` when the action edited it.** Defaults are deep-frozen contract data, and only a top-level String was copied on the way out, so `permitted_params[:tags].first << "x"` on an `of: :string` default — or any edit to a String inside a `:json` default — raised. Each request now gets a deep copy, so every value in the result is the app's own to mutate.
145
+ - **The result shared the request's own Strings, so editing it edited `params`.** A `:string` value was passed through by reference: `permitted_params[:name] << "!"`, a `transform: ->(v) { v.strip! || v }` or a `normalize: ->(v) { v.strip! || v }` rewrote the caller's Hash or `ActionController::Parameters` — contradicting the promise that the request's `params` is never touched. The walker now copies each String once, as it reads it and before `normalize:` sees it (scalar values and `of:` elements alike) — cheaply, since `String#dup` shares a long String's buffer copy-on-write. A `:json` value is deep-copied too, but only once it is within its `length:`/`max_depth:` bounds and before `validate:`/`transform:` see it, so a rejected megabyte payload is still refused without being copied. Monitor mode is deliberately unchanged: it hands back the raw params exactly as the pre-contract app — and `params.permit` — would, references included.
146
+ - **A `required:` array with a `default:` loaded silently, and behaved as `optional`.** `validate_scalar_opts!` and `validate_json_opts!` both raise `ArgumentError` for `required: true` alongside `default:` — a contradiction, since a default only ever fills an *absent* value and a required field can never be absent without already violating — but `array` had no equivalent guard. `array :tags, of: :string, required: true, default: ["x"]` loaded, and at request time the walker checks `default:` before `required:`, so the key was silently optional: an omitted `tags` filled in `["x"]` rather than reporting `missing`. The exported JSON Schema compounded it — `required` is built from `field[:required]` alone, with no awareness of `default:` — so the published schema listed `tags` as required while the server accepted a request omitting it, a real client/server disagreement rather than a cosmetic one. `array` now raises the same `ArgumentError` the scalar and `:json` kinds already do. **Breaking** for a contract that already declares both: it will fail to load, but was already silently treating the field as optional, so no request that used to succeed will start failing — the declaration is now honest about behaviour it already had.
147
+ - **`accept_params`/`reject_params` disagreed with a standalone `Contract`'s own `unknown:` strictness.** `ParamsBehaviourMatcher#run` replays a rule against a fresh, generic throwaway host (`Class.new { include Permittable }`) rather than `@subject`'s own class. A `Permittable::Contract` builds its internal host with a deliberate override forcing `top_level: false` always — "standalone input has no router and no request... gets no exemption from `unknown:` checking" — but the matcher's generic host had no such override, so it fell through to the module default, which exempts routing/form keys (`controller`, `action`, `format`, `authenticity_token`, `_method`, `utf8`, `commit`) at the top level of a rootless rule. So `expect(contract).to accept_params(a: 1, controller: "x")` passed for a rootless `unknown: :error` contract that `contract.call(a: 1, controller: "x")` actually rejects — a false-positive `accept_params` (and false-negative `reject_params`) for exactly the keys a standalone contract is documented not to exempt. The matcher now mirrors `Contract`'s own `permittable_check_unknown` when `@subject` is a `Permittable::Contract`; a controller subject's behaviour — the generic host's default exemption, which is correct there — is unchanged.
148
+ - **`permittable:generate` could draft a field from a `permit`/`expect` call that only existed inside a string.** Comment-stripping (`executable_source`) removes `#`/`=begin`-`=end` tokens before the `PERMIT_CALL`/`EXPECT_CALL` regexes scan the source, but deliberately keeps string and heredoc *content* — `permit("name")` is a supported spelling whose key lives in a string token. That left the regexes free to match a permit call spelled out inside an unrelated string: `logger.warn "legacy path hit: params.require(:admin).permit(:superuser)"` drafted `:superuser` under an `:admin` root from a line that never runs — the same "wrong, security-flavoured suggestion from a line that does not execute" the comment-stripping exists to prevent, reached through a string literal instead of a comment. String/heredoc/regexp literal *content* is now masked (each character replaced with a same-length placeholder, so token offsets stay aligned, and closing parens left unmasked so the existing mismatched-quotes fallback keeps working) before the regex scan runs, while a real call's own string arguments — `permit("name")` — are still read back correctly from the unmasked source by position.
149
+
3
150
  ## 0.8.0 (2026-09-19)
4
151
  <!-- title: a no-op sensitive: cascade, an invalid OpenAPI export, and megabyte-scale rejections -->
5
152
 
@@ -62,6 +209,14 @@ Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error`
62
209
  - **An array declared with a block now checks its `default:` too.** `validate_array_authored_value!` only checked elements against `of:`, which is nil for a block array — so `array :items, default: [{ "nonsense" => true }] do required :sku, :string end` was accepted at class load and handed to every request that omitted the key, bypassing the contract the block declares. Elements are now checked against the block's own fields (required sub-fields present, scalar ones satisfying their own contract), the same shallow check `of:` gets. **Breaking** for a contract whose block-array default was already wrong, which previously returned that value rather than rejecting it.
63
210
 
64
211
 
212
+ ### Added
213
+ - **`accept_params` / `reject_params` RSpec matchers — asserting on what a contract *does*.** `permit_param` reads the declaration, which leaves the behaviour untested: whether a payload is accepted, and what it casts to. These run the rule against a payload directly — still no request dispatched — and assert the outcome. `accept_params(payload).returning(hash)` pins the **cast, defaulted, transformed** output, which the declaration matcher cannot reach; `reject_params(payload).with_violation("user.email", :format)` pins the violation, repeatably, with the code optional. Both take `for_action` and resolve it exactly as `permit_param` does, so ambiguity fails loudly the same way, and both work on a controller class, a controller instance, or a standalone `Permittable::Contract`. Failure messages name what actually happened rather than only that the expectation failed — `expected UsersController to reject those params with user.age (inclusion), but the violations were: user.name (missing), user.email (missing)`. They read the **contract**, not the rollout mode: a monitor-mode rule still `reject_params`, because the question a spec is asking is what the contract says, not what the deploy currently does with it.
214
+
215
+ ### Added
216
+ - **`Permittable.fields` and the `use` verb — reusable field groups.** A growing API produces two kinds of duplication that the DSL had no answer for: the `address` block three controllers want, and the `update` contract that is the `create` contract with nothing mandatory. `Permittable.fields { ... }` builds a **`FieldGroup`** — a frozen, reusable field list, the same data a contract's fields are — and `use SomeGroup` splices it in at the point of use, in the group's own order, exactly as if those fields had been typed there: identical request-time behaviour, drift guard, `sensitive:` registration and exported schema. It works at the top level of a contract, inside a nested or array block, and inside another group, so groups compose. **`use G, optional: true`** relaxes every spliced field — top level only, so an `address` sent at all still needs its own required sub-fields — which makes `use UserFields, optional: true` a complete `PATCH` contract with types, bounds and `default:` intact. **`only:`/`except:`** select a subset. Because a group is built by the same builder a contract is, every declaration is validated **when the group is defined**, so a typo fails once at the group rather than at each contract using it; `only:`/`except:` naming a field the group doesn't declare is a class-load error too, so a typo cannot quietly drop a field. A group is deliberately not a contract — no `root:`, `unknown:`, `model:` or `mode:`, and `finalize` is rejected — but a standalone **`Permittable::Contract` now answers `#fields`**, so `use SomeContract` lets a webhook payload and a controller action share one definition instead of two that drift.
217
+
218
+ All additive — contracts that don't use a group are byte-for-byte unaffected.
219
+
65
220
  ## 0.6.0 (2026-09-08)
66
221
  <!-- title: nullable fields, :json, and strict dates -->
67
222