permittable 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  <p align="center">
14
14
  <a href="#quick-start">Quick start</a> ·
15
+ <a href="#complete-example">Complete example</a> ·
15
16
  <a href="#guide">Guide</a> ·
16
17
  <a href="#adopting-on-a-live-api">Adopting on a live API</a> ·
17
18
  <a href="#beyond-the-controller">Beyond the controller</a> ·
@@ -167,12 +168,13 @@ end
167
168
 
168
169
  That is the whole integration. Violations render the 422 envelope automatically, `model: Order` verifies the fields against the `orders` table when the class loads, and every rejected request emits an `invalid_parameters.permittable` notification.
169
170
 
170
- Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live API](#adopting-on-a-live-api): the gem can draft the contracts for you, and run them in a report-only mode until you are ready to enforce.
171
+ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live API](#adopting-on-a-live-api): the gem can draft the contracts for you, and run them in a report-only mode until you are ready to enforce. Want to see every option at once? See the [complete example](#complete-example).
171
172
 
172
173
  > **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
173
174
 
174
175
  ## Contents
175
176
 
177
+ - **[Complete example](#complete-example)**: every option in one events API
176
178
  - **[Guide](#guide)**
177
179
  - [How a request flows](#how-a-request-flows)
178
180
  - [Declaring a contract](#declaring-a-contract)
@@ -206,6 +208,317 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
206
208
 
207
209
  ---
208
210
 
211
+ ## Complete example
212
+
213
+ Every feature in one place: a small events API that uses every field type, every field option, every `permit_params` option and every app-wide setting. Nothing here is required. Copy the parts you need, and follow the links into the [Guide](#guide) for the details.
214
+
215
+ **1. App-wide settings.** All of them are optional.
216
+
217
+ ```ruby
218
+ # config/initializers/permittable.rb
219
+
220
+ # Roll out without rejecting: PERMITTABLE_MODE=monitor reports violations and lets the request through.
221
+ Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
222
+
223
+ # Render RFC 9457 problem+json instead of the default { success:, error: } envelope.
224
+ Permittable.error_format = :problem
225
+ Permittable.problem_base_uri = "https://api.example.com/problems"
226
+
227
+ # The drift guard always checks that a field's column exists. This also checks the column's type.
228
+ Permittable.check_column_types = true
229
+
230
+ # Endpoints that permittable:audit and permittable:generate leave alone.
231
+ Permittable.audit_ignore = %w[active_storage/direct_uploads application#not_found]
232
+ Permittable.audit_ignore_outside_root = true # the default: controllers from gems and engines are skipped
233
+
234
+ # One event per rejected request, and per would-be rejection in monitor mode.
235
+ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
236
+ Rails.logger.info("params #{payload[:mode]}: #{payload[:controller]}##{payload[:action]} #{payload[:details]}")
237
+ end
238
+ ```
239
+
240
+ ```yaml
241
+ # config/locales/en.yml: app-wide copy for any violation code a field gives no message: for
242
+ en:
243
+ permittable:
244
+ errors:
245
+ missing: "is required"
246
+ invalid_type: "is the wrong type"
247
+ unknown: "is not a recognized parameter"
248
+ ```
249
+
250
+ **2. Reusable field groups.** See [reusing fields](#reusing-fields-permittablefields-and-use).
251
+
252
+ ```ruby
253
+ # app/contracts/address_fields.rb
254
+ AddressFields = Permittable.fields do
255
+ required :line1, :string, length: 1..100, normalize: :squish # collapses runs of whitespace
256
+ optional :line2, :string, length: 0..100, normalize: :strip
257
+ required :city, :string, length: 1..80
258
+ required :country, :string, length: 2, normalize: :upcase # an Integer length is exact
259
+ optional :postcode, :string, format: /\A[A-Z0-9 -]{3,10}\z/, normalize: :upcase # any Regexp
260
+ optional :latitude, :float, in: -90.0..90.0
261
+ optional :longitude, :float, in: -180.0..180.0
262
+ end
263
+ ```
264
+
265
+ ```ruby
266
+ # app/contracts/event_fields.rb: shared by the create and update contracts below
267
+ EventFields = Permittable.fields do
268
+ # Scalars. Checks run in a fixed order: normalize → cast → length → in → format → validate.
269
+ required :title, :string, length: 1..120, normalize: :squish,
270
+ desc: "Shown as the page heading", # documentation only (OpenAPI)
271
+ example: "RubyConf 2026" # checked against the field at boot
272
+ required :slug, :string, length: 3..60, format: :slug, normalize: :downcase
273
+ optional :subtitle, :string, length: 1..140, nullable: true # null or "" clears the column
274
+ required :organizer_email, :string, format: :email, normalize: :email, # :email strips and downcases
275
+ message: { missing: "is required", format: "must be a valid email address" }
276
+ optional :website, :string, format: :url
277
+ optional :custom_domain, :string, format: :hostname, normalize: :downcase
278
+ optional :venue_id, :string, format: :uuid
279
+ required :capacity, :integer, in: 1..10_000, message: "must be between 1 and 10,000"
280
+ optional :ticket_price, :decimal, in: BigDecimal("0")..BigDecimal("9999.99"),
281
+ transform: ->(price) { price.round(2) } # runs after cast and checks
282
+ optional :waitlist_enabled, :boolean
283
+ required :starts_at, :datetime # converted to UTC
284
+ optional :ends_at, :datetime
285
+ optional :registration_closes_on, :date
286
+ optional :metadata, :json, length: 0..32, max_depth: 3 # any hash: ≤ 32 keys, ≤ 3 levels
287
+
288
+ # Arrays. length: bounds the element count, and is checked before any element is.
289
+ array :tags, of: :string, length: 0..10, transform: ->(tags) { tags.map(&:downcase).uniq }
290
+ array :co_host_ids, of: :integer, length: 0..5
291
+ array :ticket_tiers, required: true, length: 1..5, # arrays are optional by default
292
+ validate: ->(tiers) { tiers.map { |t| t[:name] }.uniq.size == tiers.size || :duplicate_names },
293
+ message: { duplicate_names: "must have unique names" } do # a Symbol from validate: is the code
294
+ required :name, :string, length: 1..40
295
+ required :price, :decimal, in: BigDecimal("0")..BigDecimal("9999.99")
296
+ optional :quantity, :integer, in: 1..10_000
297
+ end
298
+
299
+ # A nested hash. Violation paths are dotted: event.venue_address.city
300
+ optional :venue_address do
301
+ use AddressFields # groups compose
302
+ end
303
+ end
304
+ ```
305
+
306
+ **3. The controller.** See [declaring a contract](#declaring-a-contract) and [field options](#field-options).
307
+
308
+ ```ruby
309
+ # app/controllers/events_controller.rb
310
+ class EventsController < ApplicationController
311
+ include Permittable # or once, in ApplicationController
312
+
313
+ # No actions listed: a catch-all for every action without its own rule (index, show, destroy, ...).
314
+ # The last matching rule wins, so the catch-all goes first.
315
+ permit_params(unknown: :log) do # undeclared keys are dropped, and logged
316
+ optional :page, :integer, in: 1..1000, default: 1
317
+ optional :per_page, :integer, in: 1..100, default: 25
318
+ optional :q, :string, length: 0..100, normalize: :squish
319
+ optional :status, :string, in: Event.statuses.keys
320
+ optional :from, :date
321
+ end
322
+
323
+ permit_params :create,
324
+ root: :event, # unwrap params[:event]; missing or not a hash → 400
325
+ model: Event, # drift guard: each non-virtual scalar must be a column
326
+ unknown: :error, # an undeclared key is a violation, at every level
327
+ enforce: true, # validate in a before_action, not on first read
328
+ desc: "Create an event" do # documentation only (OpenAPI)
329
+ use EventFields
330
+
331
+ # Create-only fields. On an update rule, a default would overwrite the column on every PATCH that omits it.
332
+ optional :status, :string, in: Event.statuses.keys, default: "draft" # a Rails enum, sent by name
333
+ optional :currency, :string, in: %w[USD EUR GBP], default: "USD",
334
+ normalize: ->(code) { code.strip.upcase } # a Proc works too
335
+ required :terms_accepted, :boolean,
336
+ virtual: true, # not a column: no drift check
337
+ validate: ->(accepted) { accepted || :not_accepted },
338
+ message: { not_accepted: "must be accepted" }
339
+
340
+ optional :payout, sensitive: true do # cascades: every sub-field is redacted from logs...
341
+ required :iban, :string, length: 15..34
342
+ required :account_holder # the type defaults to :string
343
+ optional :scheme, :string, in: %w[sepa swift], sensitive: false # ...except an explicit opt-out
344
+ end
345
+
346
+ # Runs once every field is valid: cross-field rules, and reshaping the result.
347
+ finalize do |event|
348
+ if event[:ends_at] && event[:ends_at] <= event[:starts_at]
349
+ violate!("event.ends_at", :before_start, message: "must be after starts_at") # halts the block
350
+ end
351
+ event.except(:terms_accepted) # must return the final Hash
352
+ end
353
+ end
354
+
355
+ # PATCH: the same fields with nothing mandatory, and the slug fixed once created.
356
+ permit_params :update, root: :event, model: Event, unknown: :error, desc: "Edit an event" do
357
+ use EventFields, optional: true, except: %i[slug]
358
+ end
359
+
360
+ # A legacy action mid-rollout: violations are reported, nothing is rejected.
361
+ permit_params :reschedule, root: :event, model: Event, mode: :monitor do
362
+ use EventFields, only: %i[starts_at ends_at], optional: true
363
+ end
364
+
365
+ def index
366
+ render json: Events::Search.call(permitted_params) # page: 1, per_page: 25 when nothing is sent
367
+ end
368
+
369
+ def create
370
+ render json: Events::Create.call(permitted_params), status: :created
371
+ end
372
+
373
+ def update
374
+ Events::Update.call(params[:id], permitted_params) # holds only the keys the client sent
375
+ head :no_content
376
+ end
377
+
378
+ def reschedule
379
+ # The before_action has already validated. permittable_violations is [] for a clean request.
380
+ Rails.logger.info("reschedule would reject: #{permittable_violations}") if permittable_violations.any?
381
+ Event.find(params[:id]).update!(params.require(:event).permit(:starts_at, :ends_at)) # legacy code, unchanged
382
+ head :no_content
383
+ end
384
+ end
385
+ ```
386
+
387
+ Because of `model: Event` and `check_column_types`, a migration that drops the `capacity` column, or changes it to a string, fails at boot, not on a request. See [the schema-drift guard](#the-schema-drift-guard).
388
+
389
+ **4. What the action receives.** This request to `POST /events`:
390
+
391
+ ```json
392
+ { "event": {
393
+ "title": " RubyConf 2026 ", "slug": "RubyConf-2026", "organizer_email": " Ana@Example.COM ",
394
+ "capacity": "800", "ticket_price": "249.999", "waitlist_enabled": "true",
395
+ "starts_at": "2026-11-12T09:00:00-05:00", "ends_at": "2026-11-14T18:00:00-05:00",
396
+ "tags": ["Ruby", "rails", "ruby"], "co_host_ids": ["12", "31"],
397
+ "ticket_tiers": [{ "name": "Early bird", "price": "199", "quantity": "100" }],
398
+ "venue_address": { "line1": " 1 Main St ", "city": "Chicago", "country": "us" },
399
+ "currency": " eur ", "terms_accepted": "1",
400
+ "payout": { "iban": "DE89370400440532013000", "account_holder": "Ruby Central" } } }
401
+ ```
402
+
403
+ gives `#create` this `permitted_params`:
404
+
405
+ ```ruby
406
+ {
407
+ "title" => "RubyConf 2026", # squished
408
+ "slug" => "rubyconf-2026", # downcased, then format-checked
409
+ "organizer_email" => "ana@example.com",
410
+ "capacity" => 800, # cast from "800"
411
+ "ticket_price" => 0.25e3, # BigDecimal, rounded by transform:
412
+ "waitlist_enabled" => true,
413
+ "starts_at" => 2026-11-12 14:00:00 UTC, # a Time, converted to UTC
414
+ "ends_at" => 2026-11-14 23:00:00 UTC,
415
+ "tags" => ["ruby", "rails"], # transform: downcased, duplicates removed
416
+ "co_host_ids" => [12, 31],
417
+ "ticket_tiers" => [{ "name" => "Early bird", "price" => 0.199e3, "quantity" => 100 }],
418
+ "venue_address" => { "line1" => "1 Main St", "city" => "Chicago", "country" => "US" },
419
+ "status" => "draft", # the default
420
+ "currency" => "EUR", # the normalize: Proc
421
+ "payout" => { "iban" => "DE89370400440532013000", "account_holder" => "Ruby Central" }
422
+ } # terms_accepted was checked, then removed by finalize
423
+ ```
424
+
425
+ A request with a bad email, a `capacity` of `"0"`, two tiers with the same name, no `terms_accepted` and an extra `admin` key never reaches the action:
426
+
427
+ ```http
428
+ HTTP/1.1 422 Unprocessable Entity
429
+ Content-Type: application/problem+json
430
+ ```
431
+
432
+ ```json
433
+ {
434
+ "type": "https://api.example.com/problems/invalid-parameters",
435
+ "title": "Invalid parameters",
436
+ "status": 422,
437
+ "detail": "Invalid parameters: event.organizer_email must be a valid email address, event.capacity must be between 1 and 10,000, event.ticket_tiers must have unique names, event.terms_accepted is required, event.admin is not a recognized parameter",
438
+ "instance": "/events",
439
+ "errors": [
440
+ { "param": "event.organizer_email", "code": "format", "message": "must be a valid email address" },
441
+ { "param": "event.capacity", "code": "inclusion", "message": "must be between 1 and 10,000" },
442
+ { "param": "event.ticket_tiers", "code": "duplicate_names", "message": "must have unique names" },
443
+ { "param": "event.terms_accepted", "code": "missing", "message": "is required" },
444
+ { "param": "event.admin", "code": "unknown", "message": "is not a recognized parameter" }
445
+ ]
446
+ }
447
+ ```
448
+
449
+ The first three messages come from the fields' own `message:`, the last two from the locale file. `finalize` did not run, because it runs only when every field is valid.
450
+
451
+ **5. The same fields outside a controller.** See [standalone contracts](#standalone-contracts-no-controller).
452
+
453
+ ```ruby
454
+ # app/contracts/event_webhook.rb: webhooks, jobs, CSV rows, any Hash
455
+ EventWebhook = Permittable::Contract.define(root: :event, unknown: :error, desc: "Partner event feed") do
456
+ required :external_id, :string, format: :uuid
457
+ use EventFields, only: %i[title starts_at ends_at capacity]
458
+ end
459
+
460
+ result = EventWebhook.call(payload) # bad client data is a violation, never an exception
461
+ result.valid? # => false
462
+ result.violations # => [{ param: "event.capacity", code: "inclusion", message: "must be between 1 and 10,000" }]
463
+ result.params # the validated HashWithIndifferentAccess; nil when invalid
464
+
465
+ EventWebhook.call!(payload) # the params, or raises Permittable::InvalidParameters
466
+ EventWebhook.json_schema # JSON Schema (draft 2020-12)
467
+ ```
468
+
469
+ **6. Tests.** See [testing contracts](#testing-contracts-rspec-matchers).
470
+
471
+ ```ruby
472
+ # spec/controllers/events_contract_spec.rb
473
+ require "permittable/rspec"
474
+
475
+ RSpec.describe EventsController do
476
+ it "declares the contract" do
477
+ expect(described_class).to permit_param(:title).for_action(:create).as(:string).with_length(1..120).required
478
+ expect(described_class).to permit_param(:organizer_email).for_action(:create).matching(:email)
479
+ expect(described_class).to permit_param(:capacity).for_action(:create).as(:integer).within(1..10_000)
480
+ expect(described_class).to permit_param(:status).for_action(:create).with_default("draft")
481
+ expect(described_class).to permit_param(:tags).for_action(:create).as_array(of: :string)
482
+ expect(described_class).to permit_param("ticket_tiers.price").for_action(:create).as(:decimal)
483
+ expect(described_class).to permit_param(:terms_accepted).for_action(:create).virtual
484
+ expect(described_class).to permit_param("payout.iban").for_action(:create).sensitive
485
+ expect(described_class).to permit_param(:subtitle).for_action(:update).nullable.optional
486
+ expect(described_class).not_to permit_param(:slug).for_action(:update)
487
+ end
488
+
489
+ it "casts what it accepts and names what it rejects" do
490
+ expect(described_class).to accept_params(event: { title: "Ruby Night", capacity: "40" })
491
+ .for_action(:update).returning("title" => "Ruby Night", "capacity" => 40)
492
+
493
+ expect(described_class).to reject_params(event: { title: "Ruby Night", capacity: "0" })
494
+ .for_action(:create)
495
+ .with_violation("event.capacity", :inclusion)
496
+ .with_violation("event.terms_accepted", :missing)
497
+ end
498
+ end
499
+ ```
500
+
501
+ **7. Tooling.** See [adopting on a live API](#adopting-on-a-live-api) and [exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift).
502
+
503
+ ```sh
504
+ bin/rails permittable:generate # draft contracts for controllers that have none
505
+ bin/rails "permittable:generate[EventsController]" # draft one controller, even a covered one
506
+ bin/rails permittable:audit # every routed action: its contract and its mode
507
+ bin/rails "permittable:audit[strict]" # CI gate: exit 1 on any unguarded write action
508
+ bin/rails "permittable:openapi[openapi/api.json]" # OpenAPI 3.1, from the contracts above
509
+ ```
510
+
511
+ ```ruby
512
+ EventsController.permittable_contracts # every rule: frozen, introspectable data
513
+ EventsController.permit_rule_for(:destroy) # the rule a request would use (the catch-all)
514
+ Permittable::OpenAPI.request_body_for(EventsController, :create) # one requestBody object
515
+ Permittable::OpenAPI.operations_for(EventsController) # { "create" => ..., "update" => ..., ... }
516
+ ```
517
+
518
+ Not shown above: a controller's own [`render_error`](#violations-and-error-responses) (used when `error_format` is the default `:envelope`), overriding `render_invalid_parameters`, swapping in your own `Permittable.filter_parameter_registry`, and the plain-Ruby `Permittable::Generator` and `Permittable::Audit` APIs. The [Reference](#reference) lists them all.
519
+
520
+ ---
521
+
209
522
  ## Guide
210
523
 
211
524
  ### How a request flows
@@ -285,6 +598,8 @@ optional :metadata, :json, max_depth: 3, length: 0..32
285
598
 
286
599
  Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
287
600
 
601
+ An array of hashes also accepts the shape Rails' `fields_for` sends for nested attributes: `user[addresses_attributes][0][city]` reaches the params as a Hash keyed `"0"`, `"1"`, … and is read as the records it encodes, in the order sent, exactly as strong params and `accepts_nested_attributes_for` read it. Only that shape qualifies: every key an integer and every value a hash.
602
+
288
603
  `length:` is a **bound, not a report**: an array outside it is rejected without its elements being examined at all. A 200,000-element payload against `length: 0..10` is refused by its first check, so it costs one violation and a 40-byte body instead of 200,001 violations and several megabytes — milliseconds of contract work instead of seconds. There is **no default cap**: an array with no `length:` is unbounded, and every element of it is cast and checked however many arrive. Declare `length:` on every array you accept.
289
604
 
290
605
  ### Field options
@@ -293,11 +608,11 @@ Which options are legal depends on the field kind — anything else raises at cl
293
608
 
294
609
  | Option | Scalar | Array | Nested | Meaning |
295
610
  |---|:---:|:---:|:---:|---|
296
- | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
611
+ | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request, so it may be empty when the class loads: a live list filled in an initializer or `after_initialize` is fine, and for the same reason a `default:` or `example:` is not checked against it at class load, though every other rule still is) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
297
612
  | `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
298
613
  | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
299
614
  | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
300
- | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
615
+ | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. (An array with its own `transform:` still has its default checked as a request would be: a sub-field's `transform:` runs at class load only for a field below an array that declares `validate:`, which receives the transformed values.) Either way it is frozen, and each request gets its own deep copy |
301
616
  | `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code. On an array it runs only when no element violated — an undeclared key inside an element does not count — and `transform:` runs only when nothing violated at all |
302
617
  | `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
303
618
  | `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
@@ -307,7 +622,7 @@ Which options are legal depends on the field kind — anything else raises at cl
307
622
  | `max_depth:` | — | — | — | `:json` fields only — maximum container nesting. See [free-form hashes](#free-form-hashes-json) |
308
623
  | `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
309
624
  | `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
310
- | `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
625
+ | `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:`. Published as a client would send it: cast (`example: "18"` on an `:integer` is `18`), and never a `transform:`'s output, at any depth, even on a field that declares one. An array example is validated as a request would be, but the sub-field `default:`s a request would fill in are not published. A sub-field's `transform:` runs at class load only for a field below an array that declares `validate:` (which receives the transformed values), and its output is never published |
311
626
  | `nullable:` | ✅ | ✅ | ✅ | An explicitly-sent empty value yields `nil` instead of counting as absent — see [explicit nulls](#explicit-nulls-nullable) |
312
627
 
313
628
  ¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
@@ -350,7 +665,7 @@ optional :host, :string, format: :hostname
350
665
 
351
666
  A preset carries something a hand-written Regexp cannot: the JSON Schema **`format` keyword** the wider ecosystem understands, so [exported docs](#exporting-openapi-docs-that-cannot-drift) say `"format": "uuid"` rather than only a wall of `pattern`. The `pattern` is still emitted next to it — in draft 2020-12 `format` is an annotation unless a validator opts into asserting it, so the pattern is what actually enforces.
352
667
 
353
- An unknown preset name fails at class load, listing the presets. Passing a `Regexp` directly works exactly as before, and the RSpec matcher speaks both spellings: `matching(:email)` asserts the preset, `matching(/re/)` the Regexp.
668
+ An unknown preset name fails at class load, listing the presets. Passing a `Regexp` directly works exactly as before, and the RSpec matcher speaks both spellings: `matching(:email)` (or `matching("email")`, as the contract accepts either) asserts the preset, `matching(/re/)` the Regexp.
354
669
 
355
670
  ### Types and strict coercion
356
671
 
@@ -361,7 +676,7 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
361
676
  | `:string` | `String`, returned in the encoding it arrived in; `Numeric`/`true`/`false` are stringified | Arrays, hashes, a `String` whose bytes are invalid in its own encoding (`"caf\xC3"`) |
362
677
  | `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
363
678
  | `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
364
- | `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal` | Unparseable strings, `"1_000"`, `" 1.5 "` |
679
+ | `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Rational` no decimal holds exactly (`Rational(1, 3)`) |
365
680
  | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
366
681
  | `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
367
682
  | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
@@ -475,7 +790,7 @@ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
475
790
 
476
791
  **Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
477
792
 
478
- **Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when details exist, so hosts documenting a three-keyword contract keep working. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
793
+ **Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when the method can take it (it names `errors:`, takes `**kwargs`, or declares no keywords at all), so hosts documenting a three-keyword `(message:, code:, status:)` contract keep working. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
479
794
 
480
795
  ### Custom error messages (`message:`)
481
796
 
@@ -525,6 +840,21 @@ en:
525
840
 
526
841
  Resolution order per violation: the field's own `message:` (String, or the Hash entry for that code) → the app's `permittable.errors.<code>` translation → the bare `{ param:, code: }` shape. The lookup also covers a missing `root:`, `unknown` keys, Symbol codes returned by `validate:` (`permittable.errors.must_be_even`), and `violate!` codes in `finalize` (an explicit `violate!(..., message:)` still wins). Only a String translation counts — a missing key or a nested Hash falls back to the bare shape rather than leaking structure to clients. No I18n, no change: apps without the gem or the keys behave exactly as before.
527
842
 
843
+ **Per-request locales.** Messages are resolved in the locale that is current when the check runs. A lazy rule runs inside the action, so a locale switch such as the Rails i18n guide's `around_action :switch_locale` covers it as long as nothing reads `permitted_params` before the switch runs. An app `before_action` that runs earlier and reads `permitted_params` or `permittable_violations` (an authorization callback, say) validates the rule before the switch. An `enforce: true` rule and every [monitor-mode](#monitor-mode-roll-out-without-rejecting) rule run earlier, in a `before_action` that `include Permittable` registers. Rails runs callbacks in the order they were declared, so a switch declared *after* the include runs after that check, and the same request is answered in the default locale (`"is required"`) with `enforce: true` and in the request's (`"fehlt"`) without it. Put the switch first, with `prepend_around_action` (which puts it ahead of every callback, the app's own authentication included, which is normally what a locale switch wants), or declare it before the include:
844
+
845
+ ```ruby
846
+ class ApplicationController < ActionController::API
847
+ include Permittable
848
+ prepend_around_action :switch_locale
849
+
850
+ private
851
+
852
+ def switch_locale(&action)
853
+ I18n.with_locale(params[:locale] || I18n.default_locale, &action)
854
+ end
855
+ end
856
+ ```
857
+
528
858
  ### RFC 9457 problem+json
529
859
 
530
860
  For a public API, the standard shape for an error is [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html). One app-wide setting renders it:
@@ -574,7 +904,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
574
904
 
575
905
  Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
576
906
 
577
- 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 `commit` from an ordinary form POST. All seven are exempt at the top level. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
907
+ Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **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.
578
908
 
579
909
  All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
580
910
 
@@ -716,7 +1046,7 @@ A value outside the enum would pass the contract and then raise on assignment.
716
1046
  Declare it with in: Order.statuses.keys.
717
1047
  ```
718
1048
 
719
- The `in:` may list names and, for a string-backed enum, stored values, since assignment accepts both. It must be a list: a Range is refused because it cannot be checked. String-backed enums follow the same rule. Other declarations are still held to the column's own group: `:integer` on an integer-backed enum passes, and `:datetime` fails with a suggestion of the enum contract rather than `virtual: true`.
1049
+ The `in:` may list names and, for a string-backed enum, stored values, since assignment accepts both. An integer-backed enum's stored integers (`"1"`) are accepted only beside a `transform:` that maps them back to names, which is the recipe `permittable:generate`'s TODO spells out. It must be a list: a Range is refused because it cannot be checked. String-backed enums follow the same rule. Other declarations are still held to the column's own group: `:integer` on an integer-backed enum passes, and `:datetime` fails with a suggestion of the enum contract rather than `virtual: true`.
720
1050
 
721
1051
  The attribute API is **not** treated the same way, by choice. `attribute :starts_at, :datetime` over a string column is still compared against the string column. An enum's mapping says exactly which strings are valid, so the exemption can demand a matching `in:`. An attribute override gives the guard nothing comparable to check the contract against, so exempting it would only switch the check off for that field. Declare such a field to match its column, or leave the check off.
722
1052
 
@@ -755,7 +1085,12 @@ Two mechanisms, because neither covers the ground alone.
755
1085
 
756
1086
  A **single proc appended once at boot, consulting a live registry at filter time**, is what reaches consumers that snapshot `config.filter_parameters` at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers — so a field registered when a controller loads later (lazy loading in development) is still redacted there. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
757
1087
 
758
- But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
1088
+ But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. ActiveRecord is the exception that has to be fed by hand: it copies `config.filter_parameters` into `filter_attributes` once, when `ActiveRecord::Base` loads, which in a real app is before any controller registers its names (the first controller's own `model: Person` loads it). So each name is also appended to `ActiveRecord::Base.filter_attributes` directly, which is what keeps an `:integer` sensitive value out of the SQL bind log (`[["pin_code", 4321]]`), where the proc alone redacts only Strings. (Rails 6.1 does not filter bind values at all.) Every registration also restores any registered name that is missing from that list, so a name lost to an app *assigning* `filter_attributes` is back as soon as the next contract loads or reloads. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
1089
+
1090
+ Two things for an app that logs SQL, because `filter_attributes` is ActiveRecord's list and the gem only feeds it parameter names:
1091
+
1092
+ - **Append to `filter_attributes` (`+=`), never assign it.** `ActiveRecord::Base.filter_attributes = [:password]` in an initializer or a `to_prepare` block discards every name fed in so far. The gem puts them back the next time a contract registers a name (on a code reload, or when the next controller loads), but until then, and for a name that is never registered again, the bind log and `#inspect` show the value.
1093
+ - **A sensitive parameter name is matched against *attribute* names, by Rails' substring rule, so a column the name is not a substring of is not covered.** `optional :pin_alias, :integer, sensitive: true` guards a column called `pin_alias`, but not `alias_attribute :pin_alias, :secret_pin`, whose column is `secret_pin`; nor does `sensitive: true` on a param `pin_code` cover a virtual `attribute :pin` (`"pin"` does not contain `"pin_code"`). Add that attribute name to `filter_parameters` yourself (`config.filter_parameters += [:secret_pin, :pin]` in `config/application.rb`, which ActiveRecord copies when it loads).
759
1094
 
760
1095
  Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#names`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own. `#to_proc` must return a callable of arity 2 (`key, value`) or 3 (`key, value, original_params`), matching what Rails' own parameter filtering accepts; anything that does not respond to `#to_proc` is refused at the point of the swap rather than on the next request.
761
1096
 
@@ -819,6 +1154,8 @@ On a violating request in monitor mode:
819
1154
  - `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash; a rootless contract drops only Rails' routing keys.
820
1155
  - `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
821
1156
 
1157
+ The before-action check that monitors actions which never call `permitted_params` keeps that promise when it cannot check at all. A body Rails cannot parse, or an exception from the contract's own code (`validate:`, `transform:`, `finalize`), is logged as `[monitor] #create could not be checked: <exception class>` and the action runs. An action that then reads `permitted_params` meets the same exception itself, as it would without a contract.
1158
+
822
1159
  Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enforce:`** — telemetry must not depend on the action calling `permitted_params`, since legacy actions still reading `params` directly are exactly the ones worth monitoring. (On a plain-Ruby host without `before_action`, validation stays lazy.)
823
1160
 
824
1161
  [Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) marks operations whose rule declares `mode: :monitor` with `x-permittable-mode: "monitor"` — the docs shouldn't promise a 422 the server doesn't yet send. Only the per-rule declaration is exported: the global `Permittable.mode` is runtime configuration, not contract data.
@@ -832,7 +1169,9 @@ bin/rails permittable:generate # every controller without a
832
1169
  bin/rails "permittable:generate[UsersController]" # one controller, even if covered
833
1170
  ```
834
1171
 
835
- The sweep skips what the audit [ignores](#ignoring-controllers-and-actions): controllers whose source is outside `Rails.root` (so ActiveStorage's direct uploads controller is not drafted), and anything in `Permittable.audit_ignore`. A `controller#action` entry leaves only that action out of the controller's draft. Naming a controller drafts it regardless, the same way it drafts a covered one.
1172
+ The sweep skips what the audit [ignores](#ignoring-controllers-and-actions): controllers whose source is outside `Rails.root` (so ActiveStorage's direct uploads controller is not drafted), and anything in `Permittable.audit_ignore`. A `controller#action` entry leaves only that action out of the controller's draft.
1173
+
1174
+ A draft covers `:create` and `:update`, and only the ones the controller defines, read the way `permittable:audit` reads them (an `action_missing` handler or a template answers an action too): a `SessionsController` with `create` and `destroy` gets a `:create` rule and no `:update`, which would guard nothing and which the audit would list as stale. A controller that defines neither, like `ApplicationController`, is not drafted at all — a rule there would be inherited by every controller below it. Naming a controller drafts it regardless, the same way it drafts a covered one, and leaves out an action it does not define in the same way; only a controller that defines neither is drafted with both.
836
1175
 
837
1176
  For each controller the task infers the model from `controller_name` (columns give types, NOT NULL gives `required`), scans the controller source for `params.require(...).permit(...)` and `params.expect(...)` calls (permitted keys give the field list and the `root:`), and prints a paste-ready draft:
838
1177
 
@@ -842,7 +1181,7 @@ For each controller the task infers the model from `controller_name` (columns gi
842
1181
  permit_params :create, root: :user, model: User, mode: :monitor do
843
1182
  required :name, :string
844
1183
  optional :age, :integer
845
- optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform:
1184
+ optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform: ->(v) { User.statuses.key(Integer(v, exception: false)) || v }
846
1185
  optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
847
1186
  array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
848
1187
  end
@@ -851,7 +1190,7 @@ end
851
1190
  permit_params :update, root: :user, model: User, mode: :monitor do
852
1191
  optional :name, :string
853
1192
  optional :age, :integer
854
- optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform:
1193
+ optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform: ->(v) { User.statuses.key(Integer(v, exception: false)) || v }
855
1194
  optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
856
1195
  array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
857
1196
  end
@@ -863,15 +1202,18 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
863
1202
 
864
1203
  - Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
865
1204
  - A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
1205
+ - Drafted from the columns alone, `created_at`/`updated_at` and a primary key the database generates are left out: Rails or the database assigns them. A generated key is an auto-increment column (as the adapter reports it, even as one part of a composite key) or one with a default or default function, such as a uuid. A permit call that **lists** one keeps it, typed from its real column (`optional :created_at, :datetime`) and always `optional`, even though the column is NOT NULL — it is a column like any other, so the schema-drift guard stays on for it. A primary key the client must send is no such column: a natural key (`sku`, from `create_table :items, primary_key: :sku, id: :string`) or a part of a composite key that is not auto-increment is drafted like any other, `required` on `:create` when it is NOT NULL with no default.
866
1206
  - **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
1207
+ - **Parentheses are optional.** A call is read with or without them (`permit :name, :email`, `expect user: [...]`), to the end of its statement, and a root spelled as a String (`require("user")`) is read like a Symbol.
867
1208
  - NOT NULL is only true of a **create**. When a column makes a field `required`, the draft splits into a `:create` rule and an `:update` rule with every field optional, so a PATCH carrying only the edited field is not rejected for what it left out. With nothing required it stays one `:create, :update` rule. A default the **model** declares (`attribute :plan, default: "free"`, `enum ..., default: :pending`) keeps a NOT NULL column optional just as a database default does, and is shown as `# model default:`, written as declared rather than cast through the attribute type — a `Proc` default is named, never called.
868
1209
  - A Rails `enum` drafts as the keys a form sends (`:string, in: User.statuses.keys`), not the integer it is stored as — and reads them from the model, so a new enum value cannot leave the contract behind. For an integer-backed enum, Rails also accepts the stored integer (`status: 1`), which JSON clients sometimes send; a TODO on the line says how to admit it.
869
1210
  - The STI inheritance column (`type`, when the model actually uses STI) and the optimistic-locking column (`lock_version`, when `lock_optimistically` is on) are **not** drafted from the columns alone: assigning `type` changes the record's class. Each is named in a TODO saying why, so the omission is visible. When the controller's own permit call lists one, it **stays a field**, with a TODO — an edit form that round-trips `lock_version` is how Rails detects a stale update, and dropping it would switch that off the day the draft is enforced.
870
1211
  - A database default is noted in a comment but **not** copied into `default:` — a contract default is injected on every request that omits the field, which would overwrite columns on partial updates. The database already handles creation.
871
- - `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes. In a `params.expect` call the two shapes are distinguishable — `key: [:a]` is a nested hash, `key: [[:a]]` is an array of hashes — so that draft carries no TODO at all.
1212
+ - `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes, unless it is a `has_many`'s nested attributes (`addresses_attributes: [...]` on a model whose `addresses` is a collection), which drafts as `array ... do`. In a `params.expect` call the two shapes are distinguishable — `key: [:a]` is a nested hash, `key: [[:a]]` is an array of hashes — so that draft carries no TODO at all.
1213
+ - A **word list** reads as the list it spells, as Rails reads it: `expect(user: %i[name email])` is the `user` envelope with two fields, `address: %w[city zip]` a nested hash, and `permit(%i[name email])` or `permit(*%i[name email])` two scalars (Rails flattens a list among the filters). `%I` and `%W` count, with any delimiter but a parenthesis or a comma. A list among other fields is flattened as Rails flattens it: `expect(user: [%i[name email], :age])` has three fields, and only `[[...]]` as the outer list's one element is an array of hashes. A word list holding anything but plain keys (`%i[first-name]`, an interpolation) is kept verbatim in a TODO, and as an `expect` envelope's value it still roots the draft.
872
1214
  - **One contract, one envelope.** A contract has one `root:`, so the generator picks it from every call in the file before it drafts any field:
873
1215
  1. When the model is known and any call uses its envelope (`post` for `Post`), that envelope wins outright, even `require(:post).permit(*PERMITTED)`.
874
- 2. With no model known, an envelope beats the rootless calls if it has at least one **parsed** field (`*PERMITTED` counts for nothing). An envelope with no parsed field, such as `expect(search: FILTERS)`, beats only rootless calls with no parsed field either. Among the envelopes, the one with more parsed fields wins. Every envelope of an `expect(post: [...], comment: [...])` call counts, and so does `expect(post: PERMITTED_PARAMS)`.
1216
+ 2. With no model known, an envelope beats the rootless calls if it has at least one **parsed** field (`*PERMITTED` counts for nothing). An envelope with no parsed field, such as `expect(search: FILTERS)`, beats only rootless calls with no parsed field either. Among the envelopes, the one with more parsed fields wins. Every envelope of an `expect(post: [...], comment: [...])` call counts, and so does one whose fields cannot be read, such as `expect(post: PERMITTED_PARAMS)` or `expect(post: permitted_keys)`.
875
1217
  3. With a model that no envelope matches, the rootless calls, taken together, compete too. An envelope wins a tie with them.
876
1218
 
877
1219
  Remaining ties go to the first call in the source. A single-key `expect` lookup of `:id` or a `*_id` key, like a Rails 8 scaffold's `Post.find(params.expect(:id))`, is a route param. It never scores and is never drafted as a field. A single-key `params.permit(:group_id)` is mass assignment and is drafted as usual. Only the winner's calls become fields, and a key the winner drafts is never also a TODO. Everything else stays visible as a TODO that says why:
@@ -1069,9 +1411,11 @@ CreateUser.json_schema # the contract as JSON Schema (draft 2020
1069
1411
  CreateUser.rule # the frozen, introspectable rule data
1070
1412
  ```
1071
1413
 
1072
- Everything carries over — strict coercion, `""`/`nil` absence, defaults, `finalize` with `violate!`, `sensitive:` log-redaction registration, `invalid_parameters.permittable` instrumentation, 400-vs-422 status semantics for a missing `root:`.
1414
+ Everything carries over — strict coercion, `""`/`nil` absence, defaults, `finalize` with `violate!`, `sensitive:` log-redaction registration, `invalid_parameters.permittable` instrumentation (with `payload[:controller]` `"Permittable::Contract"` and `payload[:action]` `"call"`), 400-vs-422 status semantics for a missing `root:`.
1415
+
1416
+ **What `#call` still raises.** Client data never raises out of the gem's own checks. Wrong types, non-finite numbers, Strings in any encoding (valid or not) and undeclared keys in any encoding all come back as violations in the `Result`. Two things do raise, on purpose, because neither is the client's mistake. An input that is not a Hash, `nil` (read as `{}`) or an object answering `to_unsafe_h` (such as `ActionController::Parameters`) raises `ArgumentError`. `ActionController::Parameters` nested inside a Hash, as in `CreateUser.call(user: params[:user])`, are converted the same way. And an exception raised by your own code (a `validate:`, `transform:` or `normalize:` proc, or `finalize`) reaches the caller unchanged, since swallowing it would hide a bug. The one exception is a `normalize:` proc that raises `ArgumentError` or an encoding error on a String that is neither UTF-8 nor ASCII-only; that value is left as it is, like a preset's.
1073
1417
 
1074
- **What `#call` still raises.** Client data never raises out of the gem's own checks. Wrong types, non-finite numbers, Strings in any encoding (valid or not) and undeclared keys in any encoding all come back as violations in the `Result`. Two things do raise, on purpose, because neither is the client's mistake. An input that is not a Hash, `nil` (read as `{}`) or an object answering `to_unsafe_h` (such as `ActionController::Parameters`) raises `ArgumentError`. And an exception raised by your own code (a `validate:`, `transform:` or `normalize:` proc, or `finalize`) reaches the caller unchanged, since swallowing it would hide a bug. The one exception is a `normalize:` proc that raises `ArgumentError` or an encoding error on a String that is neither UTF-8 nor ASCII-only; that value is left as it is, like a preset's.
1418
+ **`unknown: :log` warns here too.** It writes through `Rails.logger` (read on every call, so a logger assigned after the contract's constant loaded is still found) or, with no Rails or no `Rails.logger`, to `$stderr`, so a plain script still hears about undeclared keys rather than having them dropped silently.
1075
1419
 
1076
1420
  Three differences from the controller concern, all deliberate:
1077
1421
 
@@ -1088,7 +1432,7 @@ bin/rails permittable:openapi # JSON to stdout
1088
1432
  bin/rails "permittable:openapi[openapi/api.json]" # write to a file
1089
1433
  ```
1090
1434
 
1091
- The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. Each path and verb is documented under the action Rails dispatches it to, which is the first route that matches. So an explicit `get` before a `via: :all` route on the same path keeps `GET`, and a route behind an earlier, unconstrained one with the same path and verb is not documented under that verb. A constrained route hides nothing ([the audit](#auditing-coverage-permittableaudit) explains why). When two routes then claim one path and verb, the second operation lands under `x-permittable-controllers`. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
1435
+ The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. Each path and verb is documented under the action Rails dispatches it to, which is the first route that matches. So an explicit `get` before a `via: :all` route on the same path keeps `GET`, and a route behind an earlier, unconstrained one with the same path and verb is not documented under that verb. A constrained route hides nothing ([the audit](#auditing-coverage-permittableaudit) explains why). When two routes then claim one path and verb, the second operation lands under `x-permittable-controllers`. Only OpenAPI's eight verbs are documented under `paths`: a route on another verb Rails accepts (`via: :report`, WebDAV's and CalDAV's) places nothing, since a Path Item has no field for it, and an operation reached only that way lands under `x-permittable-controllers` too. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
1092
1436
 
1093
1437
  Or build fragments programmatically — no Rails required:
1094
1438
 
@@ -1101,9 +1445,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1101
1445
 
1102
1446
  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.
1103
1447
 
1104
- **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`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1448
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1105
1449
 
1106
- **Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
1450
+ **Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. Rails squeezes a run of slashes to one before it matches, so a variant an absent leading group leaves with two in a row is no URL: `get "(:locale)(/:theme)"` documents `/`, `/{locale}` and `/{locale}/{theme}`, never `//{theme}`, and `get "(:locale(/:region))"` documents no `/`, which Rails answers with a 404. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
1107
1451
 
1108
1452
  **Every `operationId` is unique across the document, and only a collision is ever renamed.** An operation's id is its controller path with `/` folded to `_`, then its action: `users_create`, `admin_users_index`. Client generators name a method after the id, so the scheme itself never changes. Where two places in the document would carry one id, the exporter renames all but one of them:
1109
1453
 
@@ -1142,7 +1486,7 @@ Six cases go the other way, and are worth knowing before you hand the document t
1142
1486
  - **A Range of non-numbers.** `in: "a".."m"` has no `minimum`/`maximum` equivalent (those constrain numbers only) and rides along as `x-permittable-range` instead. A value outside it still passes the docs.
1143
1487
  - **Strings whose validity is a `format`.** `:decimal`, `:date` and `:datetime` are sent as strings, and what makes such a string valid is its `format` (`"decimal"`, `"date"`, `"date-time"`) — which draft 2020-12 treats as an annotation unless a validator opts into asserting it. So `"abc"` or `"NaN"` for a `:decimal` and `"2026-02-30"` for a `:date` pass most validators and fail the server's cast with `invalid_type`.
1144
1488
 
1145
- A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
1489
+ A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. A `finalize` block is looser too, and has no field to sit on: its `violate!` cross-field checks (`ends_on` before `starts_on`) run only once every field has passed, so a payload each field's schema accepts can still earn a 422. The rule's own object carries `x-permittable-finalize: true` to say the schema alone is not the whole verdict, and so does the operation, so the marker is still there when an operation has no request body to hold the schema. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
1146
1490
 
1147
1491
 
1148
1492
  <details>
@@ -1152,7 +1496,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1152
1496
 
1153
1497
  | Contract | Emitted schema |
1154
1498
  |---|---|
1155
- | `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
1499
+ | `required` / `optional` | the object's `required:` array; required strings, and the string encoding of a required `:decimal`/`:date`/`:datetime`, also get `minLength: 1` (`""` is absent; a `nullable:` `:decimal`/`:date`/`:datetime` reads it as a null, so it gets none) |
1156
1500
  | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
1157
1501
  | `:date` / `:datetime` | `string` + `format: date` / `date-time` |
1158
1502
  | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
@@ -1163,7 +1507,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1163
1507
  | nested block / `array` | `object` + `properties` / `array` + `items` |
1164
1508
  | `unknown: :error` | `additionalProperties: false`, at every nesting level |
1165
1509
  | `root:` | the wrapping object, itself required |
1166
- | `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published |
1510
+ | `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published, and so are those of an array that holds it at any depth, since they would carry its value, and so are its `in:` (list or Range) and a hand-written `format:` regexp, which would publish the secret itself. A field that declares either is flagged `x-permittable-custom-validation`, so the schema is looser than the server there; a `format:` preset such as `:email` is public and is still published |
1167
1511
 
1168
1512
  </details>
1169
1513
 
@@ -1261,7 +1605,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
1261
1605
 
1262
1606
  `actionpack` and `activerecord` are optional because every touchpoint is guarded with `respond_to?`/`defined?` — your app brings whatever it already has. The concern works on a plain Ruby object that responds to `params`, which is what makes it straightforward to unit-test.
1263
1607
 
1264
- Both claims are **tested rather than asserted**. CI runs the full suite against every ActiveSupport line in the range — 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 — across the supported Rubies (see [`gemfiles/`](gemfiles/README.md)), and a separate job installs the built gem with *nothing but activesupport* and exercises every controller-free surface, so "activesupport is the only runtime dependency" cannot quietly stop being true.
1608
+ Both claims are **tested rather than asserted**. CI runs the full suite against every ActiveSupport line in the range — 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 — on every supported Ruby, 3.2 through 4.0 (see [`gemfiles/`](gemfiles/README.md)), with Rails `main` and Ruby `head` as a non-blocking early warning, and a separate job installs the built gem with *nothing but activesupport* and exercises every controller-free surface, so "activesupport is the only runtime dependency" cannot quietly stop being true.
1265
1609
 
1266
1610
  The 6.1 floor isn't arbitrary. `class_attribute ... default:`, which declares the contract registry, arrived in Rails 5.2 — on 5.0 and 5.1 a contract cannot be declared at all — and 5.2/6.0 predate Ruby 3.x support, which this gem's own Ruby floor requires.
1267
1611