permittable 0.10.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)
@@ -195,7 +197,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
195
197
  - **[Adopting on a live API](#adopting-on-a-live-api)**
196
198
  - [Monitor mode](#monitor-mode-roll-out-without-rejecting)
197
199
  - [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
198
- - [Auditing coverage](#auditing-coverage-permittableaudit)
200
+ - [Auditing coverage](#auditing-coverage-permittableaudit) · [Ignoring controllers and actions](#ignoring-controllers-and-actions)
199
201
  - **[Beyond the controller](#beyond-the-controller)**
200
202
  - [Testing contracts](#testing-contracts-rspec-matchers)
201
203
  - [Standalone contracts](#standalone-contracts-no-controller)
@@ -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,6 +1169,10 @@ bin/rails permittable:generate # every controller without a
832
1169
  bin/rails "permittable:generate[UsersController]" # one controller, even if covered
833
1170
  ```
834
1171
 
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.
1175
+
835
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:
836
1177
 
837
1178
  ```ruby
@@ -840,7 +1181,7 @@ For each controller the task infers the model from `controller_name` (columns gi
840
1181
  permit_params :create, root: :user, model: User, mode: :monitor do
841
1182
  required :name, :string
842
1183
  optional :age, :integer
843
- 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 }
844
1185
  optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
845
1186
  array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
846
1187
  end
@@ -849,7 +1190,7 @@ end
849
1190
  permit_params :update, root: :user, model: User, mode: :monitor do
850
1191
  optional :name, :string
851
1192
  optional :age, :integer
852
- 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 }
853
1194
  optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
854
1195
  array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
855
1196
  end
@@ -861,15 +1202,18 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
861
1202
 
862
1203
  - Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
863
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.
864
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.
865
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.
866
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.
867
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.
868
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.
869
- - `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.
870
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:
871
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)`.
872
- 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)`.
873
1217
  3. With a model that no envelope matches, the rootless calls, taken together, compete too. An envelope wins a tie with them.
874
1218
 
875
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:
@@ -921,9 +1265,9 @@ Three things it tells you that nothing else does:
921
1265
 
922
1266
  - **Which write actions are unguarded.** A `GET` without a contract is usually fine; a `POST` without one is untrusted input reaching the action unchecked. That count is the number `[strict]` fails on, which makes the task a CI gate: *no new unguarded write endpoint*.
923
1267
  - **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
924
- - **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind.
1268
+ - **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
925
1269
 
926
- A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
1270
+ A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
927
1271
 
928
1272
  A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
929
1273
 
@@ -934,11 +1278,60 @@ match "*path", to: ->(_env) { [404, { "content-type" => "text/plain" }, ["Not Fo
934
1278
 
935
1279
  A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found", via: :all`) shows the same rows. It needs only `via: :get`, because on 6.1 and later `ShowExceptions` re-dispatches the error request as a GET.
936
1280
 
937
- Under `[strict]` the task aborts on these rows, so the only choices today are to route the catch-all GET-only, as above, or to run the audit without `[strict]` until the ignore list ([#69](https://github.com/VSN2015/permittable/issues/69)) lands. Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
1281
+ Under `[strict]` the task aborts on these rows. Either route the catch-all GET-only, as above, or add `application#not_found` to the [ignore list](#ignoring-controllers-and-actions). Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
938
1282
 
939
1283
  The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call, so audit concatenated route lists (an app's and an engine's) separately, or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
940
1284
 
941
- Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails.
1285
+ Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails. It takes `ignore:` and `root:` too, which default to `Permittable.audit_ignore` and `Rails.root` (see below).
1286
+
1287
+ #### Ignoring controllers and actions
1288
+
1289
+ Some routed endpoints are not yours to guard. Two are common enough to fail almost every `[strict]` run: ActiveStorage's direct uploads controller (a `POST` with no contract), and the catch-all 404 above, whose `via: :all` adds `POST`, `PUT` and `PATCH` rows. List them in an initializer:
1290
+
1291
+ ```ruby
1292
+ # config/initializers/permittable.rb
1293
+ Permittable.audit_ignore = [
1294
+ "active_storage/direct_uploads", # every action of the controller
1295
+ "application#not_found" # one action, on every verb it is routed on
1296
+ ]
1297
+ ```
1298
+
1299
+ An entry is a controller path the way Rails routes it (the controller half of `bin/rails routes`, such as `admin/users`), or `controller#action`. Entries must be strings, checked when you assign the list:
1300
+
1301
+ - A class name raises `ArgumentError` naming the path you meant: `"ActiveStorage::DirectUploadsController"` → did you mean `"active_storage/direct_uploads"`?
1302
+ - A class raises too. Naming one in an initializer would autoload the controller during boot, which Zeitwerk refuses for a reloadable controller.
1303
+ - A `Regexp` raises. A pattern would also ignore controllers added after it, and catching a new unguarded endpoint is the point of the gate.
1304
+
1305
+ Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
1306
+
1307
+ **Controllers outside `Rails.root` are ignored by default.** These are engines and gems: ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
1308
+
1309
+ ```ruby
1310
+ Permittable.audit_ignore_outside_root = false
1311
+ ```
1312
+
1313
+ That default already covers ActiveStorage. Naming it, as above, keeps it ignored if you turn the default off. To put a gem's endpoint under the gate, subclass its controller in `app/controllers` (for Devise, `devise_for :users, controllers: { sessions: "users/sessions" }`). The subclass is the app's, so it is audited.
1314
+
1315
+ Ignored endpoints leave the counts and never fail `[strict]`, but they stay in the report. Each reason gets its own section, with one line per action and the verbs it is routed on. An entry that matches no routed action (a typo, or a route since removed) is listed as well. Otherwise a typo would ignore nothing, and nothing would say so. With the initializer above plus a mistyped `"admin/report"`, the end of the report reads:
1316
+
1317
+ ```
1318
+ Ignored by Permittable.audit_ignore (left out of the counts above):
1319
+ active_storage/direct_uploads#create POST
1320
+ application#not_found DELETE, GET, PATCH, POST, PUT
1321
+
1322
+ Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):
1323
+ active_storage/blobs/redirect#show GET
1324
+ active_storage/disk#show GET
1325
+ active_storage/disk#update PUT
1326
+ rails/health#show GET
1327
+
1328
+ Permittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):
1329
+ admin/report
1330
+ ```
1331
+
1332
+ An unmatched entry is reported, not failed: a mistyped `application#not_foud` leaves the catch-all's rows in the count, so `[strict]` fails on those, and this list says why.
1333
+
1334
+ `permittable:generate` honours the same list (see [generating draft contracts](#generating-draft-contracts-permittablegenerate)).
942
1335
 
943
1336
  ---
944
1337
 
@@ -1018,9 +1411,11 @@ CreateUser.json_schema # the contract as JSON Schema (draft 2020
1018
1411
  CreateUser.rule # the frozen, introspectable rule data
1019
1412
  ```
1020
1413
 
1021
- 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:`.
1022
1415
 
1023
- **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.
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.
1417
+
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.
1024
1419
 
1025
1420
  Three differences from the controller concern, all deliberate:
1026
1421
 
@@ -1037,7 +1432,7 @@ bin/rails permittable:openapi # JSON to stdout
1037
1432
  bin/rails "permittable:openapi[openapi/api.json]" # write to a file
1038
1433
  ```
1039
1434
 
1040
- 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. `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.
1041
1436
 
1042
1437
  Or build fragments programmatically — no Rails required:
1043
1438
 
@@ -1050,7 +1445,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1050
1445
 
1051
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.
1052
1447
 
1053
- **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 — 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.
1449
+
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}`.
1054
1451
 
1055
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:
1056
1453
 
@@ -1059,10 +1456,14 @@ Every operation references shared components for the [error envelope](#violation
1059
1456
  | One operation under two verbs (the separate PATCH and PUT routes `resources` draws to `update`, or one `match ..., via: [:patch, :put]` route) | `users_update` on PATCH, `users_update_put` on PUT |
1060
1457
  | The pair again at a second path (`resources :orgs { resources :users }`) | `users_update_2` on the second PATCH, `users_update_3` on the second PUT |
1061
1458
  | One operation under every verb (with `via: :all` routes, which the exporter documents under each verb) | `webhooks_receive` on GET, then `webhooks_receive_post`, `_put`, `_patch`, `_delete` |
1062
- | One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the first path in route order, `posts_create_2` on the other |
1063
- | Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on the first controller, `admin_users_index_2` on the second |
1459
+ | One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the shallower path, `posts_create_2` on the other |
1460
+ | Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on `admin/users`, whose controller path sorts first, and `admin_users_index_2` on `admin_users` |
1461
+
1462
+ One place keeps the plain id. The exporter ranks the places by what they are, never by the order it finds them in. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. Then operations are ranked by controller path, then action, compared as strings, whatever their verbs. Within one operation, the shallowest path comes first (the fewest segments, then alphabetical order), then the verb: GET, POST, PATCH, PUT, DELETE. So PATCH comes before PUT whichever the route lists first, and `match via: [:put, :patch]` and `resources` name the PATCH method the same way. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free. Otherwise it gets the next free number in rank order, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
1463
+
1464
+ **The stability rules.** An id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. Reordering `routes.rb`, or a controller that loads earlier, renames nothing, because the rank never reads that order. (The exception is two routes that answer the same path and verb. Their order decides which one Rails dispatches to, and the export follows Rails. When the first of them is constrained, both are kept, controller order picks which operation takes the slot, and the other lands under `x-permittable-controllers`.)
1064
1465
 
1065
- One place keeps the plain id. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. After that, controller, action and route order decide. Within one operation, PATCH comes before PUT whichever the route lists first, so `match via: [:put, :patch]` and `resources` name the PATCH method the same way. Between two operations only the order counts, whatever the verbs. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free. Otherwise it gets the next free number, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. **The stability rule:** an id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. So a change to routes or controllers can rename only operations that collide, never one that stands alone. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
1466
+ **What can still rename a generated method: a change to a colliding group.** No scheme can keep every id when a new collider arrives, because one of them must take a suffix. A new route, action or controller whose id collides gets a suffix. If it ranks first, it takes the plain id from the place that had it instead. Every numbered place ranked after it moves along by one. Removing or renaming a collider moves the others the same way. Only the members of the colliding group can be renamed, never an id that stands alone. If a client must never change, remove the collision: give the operations distinct controller paths or action names.
1066
1467
 
1067
1468
  Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
1068
1469
 
@@ -1085,7 +1486,7 @@ Six cases go the other way, and are worth knowing before you hand the document t
1085
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.
1086
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`.
1087
1488
 
1088
- 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.
1089
1490
 
1090
1491
 
1091
1492
  <details>
@@ -1095,7 +1496,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1095
1496
 
1096
1497
  | Contract | Emitted schema |
1097
1498
  |---|---|
1098
- | `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) |
1099
1500
  | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
1100
1501
  | `:date` / `:datetime` | `string` + `format: date` / `date-time` |
1101
1502
  | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
@@ -1106,7 +1507,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1106
1507
  | nested block / `array` | `object` + `properties` / `array` + `items` |
1107
1508
  | `unknown: :error` | `additionalProperties: false`, at every nesting level |
1108
1509
  | `root:` | the wrapping object, itself required |
1109
- | `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 |
1110
1511
 
1111
1512
  </details>
1112
1513
 
@@ -1144,12 +1545,14 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1144
1545
  | `Permittable.error_format` / `=` | `:envelope` (default) or `:problem` — see [RFC 9457 problem+json](#rfc-9457-problemjson) |
1145
1546
  | `Permittable.problem_base_uri` / `=` | Base URI for problem `type` members |
1146
1547
  | `Permittable.check_column_types` / `=` | Opt in to the [type half of the drift guard](#checking-types-too-opt-in) (default `false`) |
1548
+ | `Permittable.audit_ignore` / `=` | Controller paths and `controller#action` strings the audit and the generator [leave out](#ignoring-controllers-and-actions) (default `[]`) |
1549
+ | `Permittable.audit_ignore_outside_root` / `=` | Also leave out controllers whose source is outside `Rails.root` (default `true`) |
1147
1550
  | `Permittable.fields(&block)` | A reusable [field group](#reusing-fields-permittablefields-and-use) — splice it into a contract with `use` |
1148
1551
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
1149
1552
  | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
1150
1553
  | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
1151
- | `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
1152
- | `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
1554
+ | `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`, `.targets`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
1555
+ | `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`, `.ignore_reason`, `.unmatched_ignores`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
1153
1556
  | `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`, `#fields`) |
1154
1557
  | `Permittable::FieldGroup` | A [reusable field list](#reusing-fields-permittablefields-and-use) (`#fields`, `#names`) — built by `Permittable.fields` |
1155
1558
  | `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — `permit_param` for the declaration, `accept_params`/`reject_params` for the behaviour. See [testing contracts](#testing-contracts-rspec-matchers) |
@@ -1202,7 +1605,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
1202
1605
 
1203
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.
1204
1607
 
1205
- 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.
1206
1609
 
1207
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.
1208
1611