permittable 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9b826995301bd0dc72ab69a6560ddfe58e13ae67323a252a9a506c21f61420d
4
- data.tar.gz: 5e354c9e104237d9116591df08d4c1a58962f98d543d0f5ff6de983b96f3bc51
3
+ metadata.gz: ddca88b2da7672d0995e338ab901188c4ba019f4d5928d7aaf0a5f73a7f8ba9c
4
+ data.tar.gz: 1a66ef88adc7ab843798f25c7a5e3444a3e550795d1353139d52b65e05ac5005
5
5
  SHA512:
6
- metadata.gz: c9cf62a2210a42cad9190f25ce259250c268dabf72bb966bae4110d2c627f27ce7a050a64f92c9e1729c9eb088d48077384b901481475a5cae077d1d802684ab
7
- data.tar.gz: a6db0156d761c972ef0c9f6b41ef96e3fee3f0f48158ce01344b8a6ea39f9a8504dd2df015bcf3c379841420a66e857a42a2d0ff07d2d94902b49d89fec6b570
6
+ metadata.gz: 7288c663fd8a2d740ca1c1e7fe9ad61d50854fa47e0e9c4a75bb0922bd119d7c4ca9fe99b020b6ea03edfb456d0fe88322161d53cc1bdd71afb6b88f6cf6ccda
7
+ data.tar.gz: a9ecd5c620fac068991a0401d76f339163eb20b6bed73ab83a33f5c3895f8bfb82479a86ab9d6385d85321732ce8ebc6c332819b32174bc8d0494c6021d1623f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.6.0 (2026-09-08)
4
+ <!-- title: nullable fields, :json, and strict dates -->
5
+
6
+ Two gaps in the field vocabulary closed and one guess removed. A contract can now say *clear this column* (`nullable:`) and *this hash has no shape, but it has bounds* (`:json`), and a date string must name the whole date instead of borrowing the missing parts from today. Contracts that use neither new option see only the date-parsing change, which is called out below.
7
+
8
+ ### Added
9
+ - **`:json` field type — free-form hashes, the `jsonb` column case.** A `json`/`jsonb` column exists precisely so its contents need no schema, and every other field kind describes a shape. Until now a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it** and the column never saw the data. Strong parameters has always had an answer (`params.permit(metadata: {})`); now so does a contract. `optional :metadata, :json` passes an arbitrary Hash through untouched — keys neither filtered nor cast, nested arrays and mixed scalars intact, `unknown:` deliberately not descending into it, `{}` a value rather than an absence, anything that is not a Hash an `invalid_type`. What it gives up is the shape; what it keeps is every bound worth having: **`length:`** caps the top-level key count, **`max_depth:`** caps container nesting with arrays counting as a level (violation code `depth`), `validate:`/`transform:` see the whole hash, and the field maps onto a column like a scalar does, so the schema-drift guard still catches a dropped `metadata` column. That matters more than it looks — an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects, and "opaque, but not unlimited" is strictly more than `permit(metadata: {})` can say. Values arrive as plain data, never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe. Exported as `{"type": "object"}` with `minProperties`/`maxProperties`, plus `x-permittable-max-depth` for the nesting bound JSON Schema has no keyword for.
10
+ - **`permittable:generate` now drafts `json`, `jsonb` and `hstore` columns as `:json`** instead of leaving a TODO comment. Columns with no faithful representation at all (`binary`, geometry types) still become TODOs rather than guesses.
11
+
12
+ - **`nullable:` field option — an explicit null is now part of a contract's vocabulary.** One absence rule (`nil` and `""` are both absent) is right for `PATCH` and wrong for the request that means *clear this*; `nullable: true` splits it in two for a single field. A key the client never sent stays **absent** — `default:` applies to it, a `required` field still violates `missing` — but a key sent **empty** (JSON `null`, or `""` from a form) is an explicit null and yields `nil` in the result, **ahead of the field's `default:`**, which is exactly what a `PATCH` clearing a column needs. Nothing is cast or checked for an explicit null: `in:`, `format:`, `length:`, `validate:`, and `transform:` never see a `nil` they didn't agree to handle. `required` + `nullable` reads as it does in SQL (the client must state the field; `null` is a legal statement), `default: nil` — legal only on a nullable field — gives the `PUT` reading where absence also means clear, and on arrays and nested blocks `nullable:` applies to the array or object itself, never its contents (a null *element* is still `invalid_type`). Exported JSON Schema / OpenAPI stays truthful: the field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum` (the one keyword that constrains the instance rather than a type). The RSpec matcher gains a `.nullable` chain, and `default: nil` / `example: nil` on a non-nullable field now fails at class load naming the fix, instead of the confusing `invalid_type`.
13
+
14
+ Contracts that don't opt in are byte-for-byte unaffected: absence keeps its single meaning and nothing new appears in an exported schema.
15
+
16
+ ### Fixed
17
+ - **`:date` and `:datetime` invented the parts a string left out, from today's date.** Coercion is documented as strict — "a value the type cannot faithfully represent is a violation, not a guess" — but it handed strings straight to `Date.parse`, which fills in what they omit from the current date: `"09/2026"` became the 1st of September, `"5th"` became the 5th of *this* month of *this* year, `"Sept"` became the 1st of September *this* year. The same request therefore meant different things on different days, which is a guess and a non-deterministic one. A `:date` or `:datetime` string must now name all three of year, month and day; which **format** it names them in is still `Date.parse`'s business, so every complete format it understands keeps working (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`, `"5 September 2026"`). A `:datetime` may still omit the **time** part, which reads as midnight UTC as documented, but a string with only a time (`"10:30"`, previously *today* at 10:30) is now `invalid_type`. `Date`, `Time`, `DateTime` and `ActiveSupport::TimeWithZone` objects are unaffected.
18
+
19
+ This is a **behaviour change** for any endpoint that was relying on the fill-in, but the values it produced were not the ones the client meant, and an exported `"format": "date"` already promised RFC 3339 rather than `"5th"`.
20
+
21
+ ## 0.5.2 (2026-09-06)
22
+ <!-- title: Railtie coverage and a new README -->
23
+
24
+ Patch release with no behaviour changes: the boot-time integration gets its first real spec, and the README was rewritten from the ground up.
25
+
26
+ ### Added
27
+ - **`spec/railtie_spec.rb` — the boot-time integration is now covered.** Everything `Permittable::Railtie` does happens during a Rails boot, and none of it had a spec: the `filter_parameters` wiring, its reach into ActiveRecord, or the rake tasks. Unit specs cannot see any of it, and the `FakeController` harness deliberately has no Rails at all. The new spec boots a **real Rails application in a subprocess** — the same approach the "without Rails loaded" specs use, and for the same reason: a boot mutates global state (`Rails.application` is a singleton, initializers run once) and must not leak into the rest of the suite. It asserts that exactly one filter proc is appended without disturbing the app's own entries, that a `sensitive:` field declared by a controller loaded **after** boot is redacted (the whole reason for a live registry rather than appended symbols), that the proc reaches `ActiveRecord::Base.filter_attributes` so a model's `#inspect` redacts too, and that both rake tasks load. `railties` joins the dev bundle for it, and the spec skips rather than fails where railties is absent, so a host without Rails — and the compatibility gemfiles that omit it — are unaffected.
28
+
29
+ ### Changed
30
+ - **README rewritten as a navigable overview.** A badges-and-nav header, a table of contents, and sections grouped into Guide, Adopting on a live API, Beyond the controller, and Reference; a motivating before/after example; a diagram of how one contract feeds validation, the drift guard, OpenAPI export, and the RSpec matchers; and the long enumerations (class-load errors, the schema mapping table) folded into `<details>` blocks. Documentation only.
31
+
3
32
  ## 0.5.1 (2026-09-02)
4
33
  <!-- title: nested input outside Rails -->
5
34
 
data/README.md CHANGED
@@ -1,14 +1,30 @@
1
- # Permittable
1
+ <h1 align="center">Permittable</h1>
2
+
3
+ <p align="center"><strong>Strong parameters for Rails that also know types, bounds, and defaults.</strong></p>
4
+
5
+ <p align="center">
6
+ <a href="https://rubygems.org/gems/permittable"><img src="https://img.shields.io/gem/v/permittable.svg" alt="Gem Version"></a>
7
+ <a href="https://github.com/VSN2015/permittable/actions/workflows/ci.yml"><img src="https://github.com/VSN2015/permittable/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
8
+ <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%E2%89%A5%203.2-CC342D.svg" alt="Ruby >= 3.2"></a>
9
+ <a href="https://rubyonrails.org/"><img src="https://img.shields.io/badge/rails-5.0%20%E2%80%93%208.x-D30001.svg" alt="Rails 5.0 - 8.x"></a>
10
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="#quick-start">Quick start</a> ·
15
+ <a href="#guide">Guide</a> ·
16
+ <a href="#adopting-on-a-live-api">Adopting on a live API</a> ·
17
+ <a href="#beyond-the-controller">Beyond the controller</a> ·
18
+ <a href="#reference">Reference</a> ·
19
+ <a href="docs/comparison.md">Comparison</a> ·
20
+ <a href="CHANGELOG.md">Changelog</a>
21
+ </p>
2
22
 
3
- [![Gem Version](https://img.shields.io/gem/v/permittable.svg)](https://rubygems.org/gems/permittable)
4
- [![CI](https://github.com/VSN2015/permittable/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/permittable/actions/workflows/ci.yml)
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
-
7
- **Strong parameters for Rails that also know types, bounds, and defaults.**
23
+ ---
8
24
 
9
25
  `params.permit` answers exactly one question: *which keys may pass?* Everything else — is `age` really a number, is `email` shaped like an email, what should `plan` be when the client omits it, and *why* was this request rejected — is left to you, usually as hand-written checks scattered through the action.
10
26
 
11
- A Permittable contract answers those questions too. It **casts** each field to a declared type, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422 that names the offending parameter.
27
+ A Permittable **contract** answers those questions too. Declared once on the controller class, it **casts** each field to a declared type, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422 that names the offending parameter.
12
28
 
13
29
  And because a contract is *class-level data* rather than code inside the action, it can be inspected — and checked against your database when the controller loads, so a column dropped by a migration fails the deploy instead of the request.
14
30
 
@@ -44,26 +60,33 @@ A violating request never reaches your action:
44
60
  "details": [{ "param": "user.age", "code": "inclusion" }] } }
45
61
  ```
46
62
 
47
- ---
63
+ ## Why
48
64
 
49
- ## Contents
65
+ Here is what the contract above replaces. Every Rails codebase has a version of this, and no two of them agree on the error shape:
50
66
 
51
- - [Why](#why) · [Installation](#installation) · [How a request flows](#how-a-request-flows)
52
- - [Declaring a contract](#declaring-a-contract) · [The field DSL](#the-field-dsl) · [Field options](#field-options)
53
- - [Types and strict coercion](#types-and-strict-coercion) · [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
54
- - [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
55
- - [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
56
- - [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
57
- - [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Generating draft contracts](#generating-draft-contracts-permittablegenerate) · [Testing contracts](#testing-contracts-rspec-matchers)
58
- - [Standalone contracts](#standalone-contracts-no-controller) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
59
- - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
67
+ ```ruby
68
+ def create
69
+ attrs = params.require(:user).permit(:name, :email, :age, :plan)
70
+
71
+ if attrs[:age].present?
72
+ age = Integer(attrs[:age], exception: false)
73
+ return render(json: { error: "age must be a number" }, status: 422) if age.nil?
74
+ return render(json: { error: "age must be 18..120" }, status: 422) unless (18..120).cover?(age)
75
+ attrs[:age] = age
76
+ end
77
+ unless attrs[:email].to_s.match?(URI::MailTo::EMAIL_REGEXP)
78
+ return render(json: { error: "email is invalid" }, status: 422)
79
+ end
80
+ attrs[:plan] = "free" if attrs[:plan].blank?
60
81
 
61
- ---
82
+ User.create!(attrs)
83
+ end
84
+ ```
62
85
 
63
- ## Why
86
+ A contract moves all of it out of the action and into data that the rest of your toolchain can read:
64
87
 
65
88
  | | `params.permit` | `params.expect` (Rails 8) | Permittable |
66
- |---|---|---|---|
89
+ |---|:---:|:---:|:---:|
67
90
  | Filters unknown keys | ✅ | ✅ | ✅ |
68
91
  | Requires a root key | via `require` | ✅ | ✅ |
69
92
  | Casts to a declared type | ❌ | ❌ | ✅ |
@@ -75,18 +98,50 @@ A violating request never reaches your action:
75
98
  | Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
76
99
  | Report-only rollout mode | ❌ | ❌ | ✅ |
77
100
  | Drafts contracts from your schema | ❌ | ❌ | ✅ |
101
+ | Works outside controllers | ❌ | ❌ | ✅ |
78
102
 
79
- The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
103
+ Every option in this space is good software, and Permittable is not always the right one. A longer, honest comparison `params.expect`, rails_param, dry-validation, typed_params, rswag, the cases where each is the better choice, benchmarks, and migration costs lives in [docs/comparison.md](docs/comparison.md).
80
104
 
81
- A longer, honest comparison `params.expect`, rails_param, dry-validation, typed_params, rswag, with the cases where each of them is the better choice, plus benchmarks and migration costs — lives in [docs/comparison.md](docs/comparison.md).
105
+ ## One idea: a contract is data
82
106
 
83
- ## Installation
107
+ Everything in this gem follows from a single decision. A contract is **declared once at the class level, frozen, inheritable, and introspectable**. It is not code that runs inside your action. That makes it readable by more than the validator:
108
+
109
+ ```
110
+ ┌────────────────────────────────────────────────────┐
111
+ │ permit_params :create, root: :user, model: User │
112
+ │ required :email, :string, format: EMAIL_REGEXP │
113
+ │ optional :age, :integer, in: 18..120 │
114
+ │ end │
115
+ └──────────────────────────┬─────────────────────────┘
116
+ one frozen, class-level contract
117
+
118
+ ┌─────────────────┬─────────────────┼─────────────────┬─────────────────┐
119
+ ▼ ▼ ▼ ▼ ▼
120
+ Validator Drift guard OpenAPI RSpec matchers Contract
121
+ casts, checks, compares fields 3.1 export from assert on the the same DSL on
122
+ defaults; a 422 to DB columns the same data rule itself, no any Hash, no
123
+ names the at class load; the server request needed controller
124
+ parameter fails the deploy enforces required
125
+ ```
126
+
127
+ The principles that fall out of it:
128
+
129
+ - **Strict, never lenient.** `"abc"` is never `0`. A value the type cannot faithfully represent is a violation, not a guess.
130
+ - **`nil` and `""` are absent.** Absent optionals are omitted from the result, so partial updates never nil-out columns.
131
+ - **Mistakes fail at class load.** A malformed contract, a default that violates its own field, or a column that no longer exists fails the boot, never the request.
132
+ - **The request's `params` is never mutated.** Reshaping happens on the validated copy.
133
+ - **Exports never guess.** Anything JSON Schema cannot represent stays visible as an `x-permittable-*` extension instead of being mistranslated.
134
+ - **One dependency.** `activesupport` is the only runtime requirement. Rails, ActionPack, and ActiveRecord are optional integration points.
135
+
136
+ ## Quick start
137
+
138
+ **1. Add the gem**
84
139
 
85
140
  ```ruby
86
141
  gem "permittable"
87
142
  ```
88
143
 
89
- Then include it wherever you need it — typically once in `ApplicationController`:
144
+ **2. Include it once**
90
145
 
91
146
  ```ruby
92
147
  class ApplicationController < ActionController::Base
@@ -94,28 +149,79 @@ class ApplicationController < ActionController::Base
94
149
  end
95
150
  ```
96
151
 
97
- The **only runtime dependency is `activesupport`**. `actionpack` (for `rescue_from`, `before_action`, and `ActionController::Parameters`) and `activerecord` (for the `model:` schema-drift guard) are optional — every touchpoint is guarded with `respond_to?`/`defined?`, so 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.
152
+ **3. Declare a contract and read `permitted_params`**
153
+
154
+ ```ruby
155
+ class OrdersController < ApplicationController
156
+ permit_params :create, root: :order, model: Order do
157
+ required :sku, :string
158
+ optional :quantity, :integer, in: 1..99, default: 1
159
+ optional :notes, :string, length: 0..500
160
+ end
161
+
162
+ def create
163
+ Order.create!(permitted_params)
164
+ end
165
+ end
166
+ ```
167
+
168
+ 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
+ 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.
98
171
 
99
172
  > **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
100
173
 
101
- ## How a request flows
174
+ ## Contents
175
+
176
+ - **[Guide](#guide)**
177
+ - [How a request flows](#how-a-request-flows)
178
+ - [Declaring a contract](#declaring-a-contract)
179
+ - [The field DSL](#the-field-dsl)
180
+ - [Field options](#field-options)
181
+ - [Types and strict coercion](#types-and-strict-coercion)
182
+ - [Free-form hashes](#free-form-hashes-json)
183
+ - [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
184
+ - [Explicit nulls](#explicit-nulls-nullable)
185
+ - [Violations and error responses](#violations-and-error-responses)
186
+ - [Custom error messages](#custom-error-messages-message) · [Localizing with I18n](#localizing-default-messages-i18n)
187
+ - [Unknown parameters](#unknown-parameters)
188
+ - [Output reshaping](#output-reshaping-transform-and-finalize)
189
+ - [The schema-drift guard](#the-schema-drift-guard)
190
+ - [Sensitive parameters and log redaction](#sensitive-parameters-and-log-redaction)
191
+ - [Instrumentation](#instrumentation)
192
+ - **[Adopting on a live API](#adopting-on-a-live-api)**
193
+ - [Monitor mode](#monitor-mode-roll-out-without-rejecting)
194
+ - [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
195
+ - **[Beyond the controller](#beyond-the-controller)**
196
+ - [Testing contracts](#testing-contracts-rspec-matchers)
197
+ - [Standalone contracts](#standalone-contracts-no-controller)
198
+ - [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
199
+ - **[Reference](#reference)**
200
+ - [API](#api) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
201
+ - [Development](#development) · [License](#license)
202
+
203
+ ---
204
+
205
+ ## Guide
206
+
207
+ ### How a request flows
102
208
 
103
209
  ```
104
- params
105
-
106
- ├─ 1. unwrap root: params[:user] → 400 if missing or not a hash
107
- ├─ 2. per field: normalize → cast → validate → transform
108
- ├─ 3. unknown-key check at every nesting level
109
- ├─ 4. finalize only if nothing violated
110
-
111
- └─ permitted_params HashWithIndifferentAccess (or raises InvalidParameters)
210
+ request params
211
+
212
+ ├─ 1 unwrap root: params[:user] missing or not a hash → 400
213
+ ├─ 2 each field normalize → cast → validate → transform
214
+ ├─ 3 unknown-key check at every nesting level (unknown: :ignore | :log | :error)
215
+ ├─ 4 finalize only when nothing violated
216
+
217
+ └─ permitted_params HashWithIndifferentAccess or raises InvalidParameters → 422
112
218
  ```
113
219
 
114
220
  Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
115
221
 
116
222
  In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
117
223
 
118
- ## Declaring a contract
224
+ ### Declaring a contract
119
225
 
120
226
  ```ruby
121
227
  permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &contract)
@@ -147,39 +253,35 @@ end
147
253
 
148
254
  Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
149
255
 
150
- ## The field DSL
256
+ ### The field DSL
151
257
 
152
- ### Scalars
258
+ Three verbs. `required` and `optional` declare scalars (or, with a block, nested hashes); `array` declares a list.
153
259
 
154
260
  ```ruby
155
- required :name, :string # type defaults to :string
261
+ # Scalars the type defaults to :string
262
+ required :name, :string
156
263
  optional :age, :integer
157
- ```
158
264
 
159
- ### Nested hashes
160
-
161
- Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
162
-
163
- ```ruby
265
+ # Nested hashes — pass a block instead of a type. Violation paths are dotted: user.address.zip
164
266
  optional :address do
165
267
  required :city, :string
166
268
  optional :zip, :string, format: /\A\d{5}\z/
167
269
  end
168
- ```
169
-
170
- ### Arrays
171
270
 
172
- `of:` declares an array of scalars; a block declares an array of hashes. Arrays are **optional unless `required: true`**, `length:` constrains the element **count**, and element failures carry their index (`items[1]`).
173
-
174
- ```ruby
175
- array :tag_names, of: :string, length: 0..10
271
+ # Arrays of: for scalars, a block for hashes. Element failures carry their index: items[1].sku
272
+ array :tag_names, of: :string, length: 0..10
176
273
  array :line_items, required: true do
177
274
  required :sku, :string
178
275
  required :quantity, :integer, in: 1..99
179
276
  end
277
+
278
+ # Free-form hashes — :json takes any hash, uncast and unfiltered, with bounds
279
+ optional :metadata, :json, max_depth: 3, length: 0..32
180
280
  ```
181
281
 
182
- ## Field options
282
+ Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
283
+
284
+ ### Field options
183
285
 
184
286
  Which options are legal depends on the field kind — anything else raises at class load.
185
287
 
@@ -196,9 +298,11 @@ Which options are legal depends on the field kind — anything else raises at cl
196
298
  | `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
197
299
  | `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
198
300
  | `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
301
+ | `max_depth:` | — | — | — | `:json` fields only — maximum container nesting. See [free-form hashes](#free-form-hashes-json) |
199
302
  | `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
200
303
  | `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
201
304
  | `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
305
+ | `nullable:` | ✅ | ✅ | ✅ | An explicitly-sent empty value yields `nil` instead of counting as absent — see [explicit nulls](#explicit-nulls-nullable) |
202
306
 
203
307
  ¹ `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.
204
308
 
@@ -208,7 +312,7 @@ Which options are legal depends on the field kind — anything else raises at cl
208
312
  optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
209
313
  ```
210
314
 
211
- ## Types and strict coercion
315
+ ### Types and strict coercion
212
316
 
213
317
  Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`. Rails' casts are lenient by design — `"abc".to_i` is `0`, `Boolean.cast("abc")` is `true` — and silently corrupting untrusted input is precisely what a contract must not do. A value the type cannot faithfully represent is a **violation, not a guess**.
214
318
 
@@ -219,31 +323,94 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
219
323
  | `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
220
324
  | `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
221
325
  | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
222
- | `:date` | `Date`; any `Date.parse`-able string | Unparseable strings |
223
- | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`, parseable strings | Unparseable strings |
326
+ | `: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"`) |
327
+ | `: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"`) |
328
+ | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
329
+
330
+ **Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC.
224
331
 
225
- Two behaviours worth committing to memory:
332
+ Two more behaviours worth committing to memory:
226
333
 
227
334
  - **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
228
335
  - **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
229
336
 
230
- ## Absence, defaults, and partial updates
337
+ ### Free-form hashes (`:json`)
338
+
339
+ A `json`/`jsonb` column exists precisely so its contents need no schema. Every other field kind describes a shape, so until `:json` a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it**, and the column never saw the data. Strong parameters has always had an answer here (`params.permit(metadata: {})`); now so does a contract.
340
+
341
+ ```ruby
342
+ permit_params :create, root: :user, model: User do
343
+ required :name, :string
344
+ optional :metadata, :json, max_depth: 3, length: 0..32
345
+ end
346
+ ```
347
+
348
+ The hash passes through **untouched** — keys are neither filtered nor cast, nested arrays and mixed scalars survive, and `unknown:` does not descend into it. `{}` is a value, not an absence. Anything that is not a hash (an array, a string, a number) is `invalid_type`.
349
+
350
+ What you give up is the shape. What you keep:
351
+
352
+ | | |
353
+ |---|---|
354
+ | `length:` | Caps the **top-level key count** — same reading as an array's element count |
355
+ | `max_depth:` | Caps **container nesting**, counting arrays as a level: `{"a": 1}` is 1, `{"a": {"b": 1}}` and `{"a": [1, 2]}` are 2, `{"a": [{"b": 1}]}` is 3. Violation code `depth` |
356
+ | `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
357
+ | `model:` | The field maps onto a column like a scalar does, so the [drift guard](#the-schema-drift-guard) still catches a dropped `metadata` column |
358
+ | `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash, and are checked against the field's own bounds at class load) |
359
+
360
+ Bounding it matters more than it looks: an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects. `max_depth:` and `length:` are how a contract says "opaque, but not unlimited" — which is strictly more than `permit(metadata: {})` can say.
361
+
362
+ Values arrive as plain data (`HashWithIndifferentAccess`), never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe.
363
+
364
+ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{"type": "object"}` plus `minProperties`/`maxProperties`; JSON Schema has no nesting-depth keyword, so `max_depth:` stays visible as `x-permittable-max-depth` rather than being dropped or mistranslated.
365
+
366
+ ### Absence, defaults, and partial updates
231
367
 
232
368
  `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
233
369
 
234
370
  That single rule produces the behaviour you want from a `PATCH`:
235
371
 
236
- - An **absent optional field is omitted** from the result, so partial updates never nil-out columns.
237
- - An **absent required field violates** with `missing`.
238
- - An absent field **with a `default:` gets the default** so a defaulted field can never report `missing`. (Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality.)
372
+ | The field is | Result |
373
+ |---|---|
374
+ | absent and **optional** | omitted from the result, so partial updates never nil-out columns |
375
+ | absent and **required** | a `missing` violation |
376
+ | absent with a **`default:`** | the default — a defaulted field can never report `missing` |
239
377
 
240
- Because absence and `nil` are the same thing here, **clearing a column to NULL is outside a contract's vocabulary**. Do that explicitly in the action.
378
+ Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And because absence and `nil` are the same thing here, a plain field cannot clear a column to NULL declare it [`nullable:`](#explicit-nulls-nullable) when it should.
241
379
 
242
380
  Defaults are checked against the field's own contract when the class loads, so `default: "gold"` on a field declared `in: %w[free pro]` fails at boot rather than on every request.
243
381
 
244
- ## Violations and error responses
382
+ ### Explicit nulls (`nullable:`)
245
383
 
246
- Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope.
384
+ One rule `nil` and `""` are absent is right for `PATCH` and wrong for the request that means *clear this*. `nullable: true` splits it in two for a single field:
385
+
386
+ ```ruby
387
+ permit_params :update, root: :user, model: User do
388
+ optional :nickname, :string, nullable: true
389
+ optional :plan, :string, in: %w[free pro], default: "free", nullable: true
390
+ end
391
+ ```
392
+
393
+ | Request | `nickname` in the result |
394
+ |---|---|
395
+ | `{ "user": {} }` | **omitted** — the column is untouched |
396
+ | `{ "user": { "nickname": null } }` | `nil` — the column is cleared |
397
+ | `{ "user": { "nickname": "" } }` | `nil` — the form-encoded spelling of the same intent |
398
+
399
+ A key the client never sent is still **absent**: `default:` applies to it and a `required` field still violates with `missing`. Only *present-but-empty* changes meaning, and it changes it decisively — an explicit null wins over the field's `default:`, which is the behaviour a `PATCH` needs (`{ "plan": null }` clears the plan instead of silently resetting it to `"free"`).
400
+
401
+ Nothing is cast or checked for an explicit null. `in:`, `format:`, `length:`, `validate:`, and `transform:` all see a value or nothing at all — never a `nil` they never agreed to handle.
402
+
403
+ Three more readings worth knowing:
404
+
405
+ - **`required` + `nullable`** is coherent, and means what it says in SQL: the client *must* state the field, and `null` is a legal statement. A missing key still violates.
406
+ - **`default: nil`** — legal only on a nullable field — gives the `PUT` reading, where absence *also* means clear.
407
+ - **On arrays and nested blocks**, `nullable:` applies to the array or object itself, never to its contents. `{ "tags": null }` yields `nil` (distinct from `[]`, which still gets length-checked); a null *element* inside `tags` is still `invalid_type`.
408
+
409
+ Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth about all of this: a nullable field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum`.
410
+
411
+ ### Violations and error responses
412
+
413
+ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope shown at the [top of this README](#permittable).
247
414
 
248
415
  | Code | Raised when |
249
416
  |---|---|
@@ -258,11 +425,11 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
258
425
 
259
426
  Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
260
427
 
261
- **Status codes:** a missing root key renders **400** (the request is malformed the envelope you asked for isn't there); field-level violations render **422** (well-formed, semantically wrong).
428
+ **Status codes.** A missing root key renders **400** the request is malformed; the envelope you asked for isn't there. Field-level violations render **422** well-formed, semantically wrong.
262
429
 
263
- **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 shown at the top of this README is rendered. Either way, `render_invalid_parameters` is a normal method you can override.
430
+ **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. For full control over the body (RFC 9457, a different envelope), `error.details` gives you the structured violations to build from.
264
431
 
265
- ## Custom error messages (`message:`)
432
+ ### Custom error messages (`message:`)
266
433
 
267
434
  Violations stay machine-first — the `code` is the contract — but any field can attach human-readable copy with `message:`. A **String** covers every code on the field; a **Hash of code → String** targets specific codes, and codes without an entry keep the default rendering:
268
435
 
@@ -293,7 +460,7 @@ The rules:
293
460
  - `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
294
461
  - A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
295
462
 
296
- ### Localizing default messages (I18n)
463
+ #### Localizing default messages (I18n)
297
464
 
298
465
  App-wide copy for a violation code — without repeating `message:` on every field — comes from I18n, under `permittable.errors.<code>`:
299
466
 
@@ -310,9 +477,7 @@ en:
310
477
 
311
478
  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.
312
479
 
313
- For full control over the response body itself (RFC 9457, a different envelope), override `render_invalid_parameters` or define `render_error` as described above; `error.details` gives you the structured violations to build from.
314
-
315
- ## Unknown parameters
480
+ ### Unknown parameters
316
481
 
317
482
  `unknown:` decides what happens to keys you never declared, **at every nesting level**.
318
483
 
@@ -324,13 +489,11 @@ For full control over the response body itself (RFC 9457, a different envelope),
324
489
 
325
490
  Rails merges `controller`, `action`, and `format` into `params`; these are exempt at the top level so `unknown: :error` doesn't flag the router's own bookkeeping. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there.
326
491
 
327
- ## Output reshaping (`transform:` and `finalize`)
492
+ ### Output reshaping (`transform:` and `finalize`)
328
493
 
329
494
  This is the safe replacement for params-mutating `before_action`s. **Both layers operate on the validated copy — the request's `params` is never touched.**
330
495
 
331
- ### `transform:` — per field
332
-
333
- A callable applied **after** cast and validation, reshaping one field's output:
496
+ **`transform:` — per field.** A callable applied **after** cast and validation, reshaping one field's output:
334
497
 
335
498
  ```ruby
336
499
  required :tags, :string, transform: ->(v) { v.split(",") }
@@ -338,11 +501,9 @@ required :tags, :string, transform: ->(v) { v.split(",") }
338
501
 
339
502
  It runs only on request-supplied values. Absent fields stay absent, `default:` values are authored in their final shape, and a **partially-invalid array is never transformed** — user code is never handed garbage it didn't agree to see.
340
503
 
341
- ### `finalize` — per contract
504
+ **`finalize` — per contract.** Declared once, at the top level only. It runs after every field has validated cleanly, receives the result hash, and must return the final `Hash`. Use it to combine parallel fields, build value objects, or drop scaffolding keys.
342
505
 
343
- Declared once, at the top level only. It runs after every field has validated cleanly, receives the result hash, and must return the final `Hash`. Use it to combine parallel fields, build value objects, or drop scaffolding keys.
344
-
345
- It executes on a **bare runner, not the controller**, so contracts stay pure data plus pure functions and can never grow a dependency on request state. Its one extra verb is `violate!(param, code, message: nil)`, which records a violation (the optional [`message:`](#custom-error-messages-message) rides into the detail) and **halts the block immediately** — so the code after a `violate!` may assume the invariant it just checked. That makes `finalize` the natural home for cross-field validation (`ends_at` after `starts_at`, matching array lengths).
506
+ It executes on a **bare runner, not the controller**, so contracts stay pure data plus pure functions and can never grow a dependency on request state. Its one extra verb is `violate!(param, code, message: nil)`, which records a violation and **halts the block immediately** — so the code after a `violate!` may assume the invariant it just checked. That makes `finalize` the natural home for cross-field validation (`ends_at` after `starts_at`, matching array lengths).
346
507
 
347
508
  ```ruby
348
509
  permit_params :create, root: :lease_addendum_form do
@@ -364,7 +525,7 @@ end
364
525
 
365
526
  Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
366
527
 
367
- ## The schema-drift guard
528
+ ### The schema-drift guard
368
529
 
369
530
  This is why `model:` exists. Pass a model class (or `true` to infer it from `controller_name`) and **every non-virtual scalar field is checked against the model's columns when the macro runs** — that is, at controller class load.
370
531
 
@@ -384,7 +545,7 @@ The error carries a ready-to-paste migration command, typed from your own field
384
545
 
385
546
  In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
386
547
 
387
- ## Sensitive parameters and log redaction
548
+ ### Sensitive parameters and log redaction
388
549
 
389
550
  Mark a field `sensitive: true` and its name is registered with `Permittable.filter_parameter_registry`; `Permittable::Railtie` appends a filter proc to `config.filter_parameters` at boot.
390
551
 
@@ -396,7 +557,7 @@ The indirection is deliberate. Appending plain symbols to `config.filter_paramet
396
557
 
397
558
  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`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own.
398
559
 
399
- ## Instrumentation
560
+ ### Instrumentation
400
561
 
401
562
  Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
402
563
 
@@ -404,13 +565,29 @@ Every violation emits an `ActiveSupport::Notifications` event, so rejected reque
404
565
  ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
405
566
  payload[:controller] # "users"
406
567
  payload[:action] # "create"
568
+ payload[:mode] # :enforce, or :monitor for a would-be rejection
407
569
  payload[:details] # [{ param: "user.age", code: "inclusion" }]
408
570
  end
409
571
  ```
410
572
 
411
- ## Monitor mode (roll out without rejecting)
573
+ ---
574
+
575
+ ## Adopting on a live API
412
576
 
413
- Adopting contracts on a live API or tightening one field on an existing contract — has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send. `mode: :monitor` resolves it: the full pipeline runs (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
577
+ Adding contracts to an API with real traffic has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send.
578
+
579
+ Permittable's answer is an afternoon-sized recipe:
580
+
581
+ 1. **Draft.** `bin/rails permittable:generate` writes a first contract for every controller from the model's columns and the `params.permit` calls already in the source. Action code stays as-is.
582
+ 2. **Monitor.** Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; every would-be rejection is logged and instrumented.
583
+ 3. **Watch.** Point your existing notification subscriber at a dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
584
+ 4. **Enforce.** Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
585
+
586
+ The two halves of that recipe are below.
587
+
588
+ ### Monitor mode (roll out without rejecting)
589
+
590
+ `mode: :monitor` runs the full pipeline — unwrap, cast, validate, defaults — but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
414
591
 
415
592
  ```ruby
416
593
  class OrdersController < ApplicationController
@@ -434,24 +611,17 @@ Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
434
611
  On a violating request in monitor mode:
435
612
 
436
613
  - **Nothing raises and nothing renders** — the action runs.
437
- - The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths. Point your existing subscriber at a dashboard and you have a per-controller rollout report.
438
- - `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 (the envelope you asked for isn't there); a rootless contract drops only Rails' routing keys.
614
+ - The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths.
615
+ - `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.
439
616
  - `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
440
617
 
441
618
  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.)
442
619
 
443
- The rollout recipe:
444
-
445
- 1. Write contracts for a legacy controller — or let [`permittable:generate`](#generating-draft-contracts-permittablegenerate) draft them. The action code stays as-is.
446
- 2. Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; telemetry starts.
447
- 3. Watch the dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
448
- 4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
449
-
450
620
  [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.
451
621
 
452
- ## Generating draft contracts (`permittable:generate`)
622
+ ### Generating draft contracts (`permittable:generate`)
453
623
 
454
- The blank-page problem, solved: the first draft of every contract can be generated from what the app already knows — the model's columns, and the `params.permit` calls already sitting in the controller.
624
+ The blank-page problem, solved: the first draft of every contract is generated from what the app already knows.
455
625
 
456
626
  ```sh
457
627
  bin/rails permittable:generate # every controller without a contract
@@ -481,11 +651,15 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
481
651
 
482
652
  No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.for_controller(controller, source: File.read(path))`, and `.scan(source)` are plain Ruby.
483
653
 
484
- Together with [monitor mode](#monitor-mode-roll-out-without-rejecting) this makes the whole adoption path one afternoon: generate drafts, paste, deploy monitoring, watch the dashboard, flip to enforce.
654
+ ---
655
+
656
+ ## Beyond the controller
485
657
 
486
- ## Testing contracts (RSpec matchers)
658
+ Because a contract is data, it has readers other than the request validator.
487
659
 
488
- Because a contract is data, it can be specified without dispatching a request. `require "permittable/rspec"` (in `spec_helper.rb`) auto-includes the matchers:
660
+ ### Testing contracts (RSpec matchers)
661
+
662
+ A contract can be specified without dispatching a request. `require "permittable/rspec"` (in `spec_helper.rb`) auto-includes the matchers:
489
663
 
490
664
  ```ruby
491
665
  RSpec.describe UsersController do
@@ -505,7 +679,7 @@ Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (
505
679
 
506
680
  `for_action` picks the rule exactly like a request would (`permit_rule_for`), and may be omitted only when the controller declares a single contract — an ambiguous expectation raises instead of silently checking the wrong rule. Failure messages name what the contract actually declares.
507
681
 
508
- ## Standalone contracts (no controller)
682
+ ### Standalone contracts (no controller)
509
683
 
510
684
  The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
511
685
 
@@ -532,9 +706,9 @@ Everything carries over — strict coercion, `""`/`nil` absence, defaults, `fina
532
706
  - **No router-key exemption.** `unknown: :error` flags a stray `action` or `controller` key — standalone input has no router to excuse.
533
707
  - **No memoization.** Every `#call` validates fresh, so one frozen contract is safely reusable and shareable (assign it to a constant).
534
708
 
535
- ## Exporting OpenAPI (docs that cannot drift)
709
+ ### Exporting OpenAPI (docs that cannot drift)
536
710
 
537
- Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward the docs cannot lie:
711
+ An exporter emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema) from the same frozen data the server enforces. Like the drift guard pointed outward: the docs cannot lie.
538
712
 
539
713
  ```sh
540
714
  bin/rails permittable:openapi # JSON to stdout
@@ -552,7 +726,16 @@ Permittable::OpenAPI.operations_for(UsersController) # { acti
552
726
  Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
553
727
  ```
554
728
 
555
- How contracts map:
729
+ 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.
730
+
731
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `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 land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
732
+
733
+ 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.
734
+
735
+ <details>
736
+ <summary><strong>How contracts map onto JSON Schema</strong></summary>
737
+
738
+ <br>
556
739
 
557
740
  | Contract | Emitted schema |
558
741
  |---|---|
@@ -569,15 +752,15 @@ How contracts map:
569
752
  | `root:` | the wrapping object, itself required |
570
753
  | `sensitive: true` | `writeOnly: true` (never echoed in responses) |
571
754
 
572
- 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.
755
+ </details>
573
756
 
574
- **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `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 land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
757
+ ---
575
758
 
576
- 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.
759
+ ## Reference
577
760
 
578
- ## API reference
761
+ ### API
579
762
 
580
- ### Instance methods
763
+ **Instance methods**
581
764
 
582
765
  | Method | Purpose |
583
766
  |---|---|
@@ -586,7 +769,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
586
769
  | `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
587
770
  | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
588
771
 
589
- ### Class methods
772
+ **Class methods**
590
773
 
591
774
  | Method | Purpose |
592
775
  |---|---|
@@ -594,9 +777,9 @@ Output is deterministic (fixed key order, declaration-order properties), so the
594
777
  | `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
595
778
  | `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
596
779
 
597
- ### Module
780
+ **Module**
598
781
 
599
- | | |
782
+ | Constant | Purpose |
600
783
  |---|---|
601
784
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
602
785
  | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
@@ -608,10 +791,15 @@ Output is deterministic (fixed key order, declaration-order properties), so the
608
791
  | `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`) |
609
792
  | `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — see [testing contracts](#testing-contracts-rspec-matchers) |
610
793
 
611
- ## Errors caught at class load
794
+ ### Errors caught at class load
612
795
 
613
796
  A bad contract is a programmer error, so it fails when the class loads — never at request time. Every message names the field and explains the fix.
614
797
 
798
+ <details>
799
+ <summary><strong>The full list</strong></summary>
800
+
801
+ <br>
802
+
615
803
  - A field declared twice in one contract
616
804
  - An unknown option for the field's kind, listing what *is* allowed
617
805
  - An unknown type, listing the supported ones
@@ -620,31 +808,40 @@ A bad contract is a programmer error, so it fails when the class loads — never
620
808
  - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
621
809
  - `validate:` or `transform:` that isn't callable
622
810
  - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
811
+ - A `default: nil` or `example: nil` on a field that isn't `nullable:`
812
+ - A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject
813
+ - A `max_depth:` that isn't a positive Integer
623
814
  - `required: true` combined with `default:`
624
815
  - A field given both a type and a nested block; an array given both `of:` and a block
625
816
  - An empty contract, or a nested block declaring no sub-fields
626
817
  - `finalize` declared twice, without a block, or inside a nested block
627
818
  - `permit_params` without a block, or an invalid `unknown:` mode
819
+ - A `root:` that isn't a single key (several top-level envelopes are a rootless contract with one nested block per key)
628
820
  - An invalid `mode:` (and `Permittable.mode =` rejects invalid values at assignment)
629
821
  - A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
630
822
 
631
- ## Compatibility
823
+ </details>
632
824
 
633
- | | |
825
+ ### Compatibility
826
+
827
+ | Requirement | Supported |
634
828
  |---|---|
635
829
  | Ruby | >= 3.2 |
636
830
  | Rails / ActiveSupport | >= 5.0, < 9 |
637
831
  | Required dependency | `activesupport` only |
638
832
  | Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
639
833
 
834
+ `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.
835
+
640
836
  Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `ConcernsOnRails::Controllers::Permittable` is an alias for this module, and `sensitive:` registrations pool into that gem's shared filter registry.
641
837
 
642
838
  ## Development
643
839
 
644
840
  ```sh
645
841
  bundle install
646
- bundle exec rspec # 125 examples
842
+ bundle exec rspec # the suite, with a coverage report in coverage/
647
843
  bundle exec rubocop
844
+ bundle exec ruby benchmark/overhead.rb # a full contract vs. the params.permit call it replaces
648
845
  ```
649
846
 
650
847
  Releases are automated: bump `lib/permittable/version.rb`, add a `CHANGELOG.md` section, then push a `vX.Y.Z` tag. CI publishes to RubyGems via trusted publishing (OIDC — no API keys stored) and creates the GitHub release.
@@ -19,14 +19,17 @@ module Permittable
19
19
  DEFAULT_ACTIONS = %i[create update].freeze
20
20
  SKIPPED_COLUMNS = %w[created_at updated_at].freeze
21
21
 
22
- # Column type => contract type. Anything absent here (json, jsonb, hstore,
23
- # binary, ...) has no faithful scalar representation and becomes a TODO
24
- # comment rather than a guess.
22
+ # Column type => contract type. Document-shaped columns map onto the
23
+ # opaque `:json` field the shape stays undeclared, which is what a
24
+ # jsonb column is for, and `max_depth:`/`length:` can bound it later.
25
+ # Anything absent here (binary, geometry, ...) has no faithful
26
+ # representation and becomes a TODO comment rather than a guess.
25
27
  COLUMN_TYPES = {
26
28
  string: :string, text: :string, citext: :string, uuid: :string,
27
29
  integer: :integer, bigint: :integer, float: :float, decimal: :decimal,
28
30
  boolean: :boolean, date: :date, datetime: :datetime,
29
- timestamp: :datetime, timestamptz: :datetime
31
+ timestamp: :datetime, timestamptz: :datetime,
32
+ json: :json, jsonb: :json, hstore: :json
30
33
  }.freeze
31
34
 
32
35
  # What a source scan recovered from existing permit calls. `scalars` are
@@ -168,7 +171,7 @@ module Permittable
168
171
 
169
172
  def column_line(column)
170
173
  type = COLUMN_TYPES[column.type]
171
- return "# TODO: #{column.name} (#{column.type}) has no scalar contract type — declare it as a nested block or an array" unless type
174
+ return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
172
175
 
173
176
  line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
174
177
  line += " # database default: #{column.default.inspect}" unless column.default.nil?
@@ -75,12 +75,29 @@ module Permittable
75
75
  def field(field, unknown: :ignore)
76
76
  schema = case field[:kind]
77
77
  when :scalar then scalar_schema(field)
78
+ when :json then opaque_schema(field)
78
79
  when :nested then object(field[:fields], unknown: unknown)
79
80
  when :array then array_schema(field, unknown: unknown)
80
81
  end
82
+ nullify!(schema, field)
81
83
  annotate(schema, field)
82
84
  end
83
85
 
86
+ # `nullable: true` means an explicitly-sent empty value yields null, so
87
+ # the type gains "null". Assigning over the existing key keeps its
88
+ # position, preserving deterministic emission. `enum` is the one keyword
89
+ # that constrains the instance rather than one type (minLength, pattern,
90
+ # minimum and friends only apply to instances of their own type), so a
91
+ # nullable enum has to list null itself or it would reject the very null
92
+ # the type now permits.
93
+ def nullify!(schema, field)
94
+ return schema unless field[:nullable]
95
+
96
+ schema["type"] = Array(schema["type"]) + ["null"] if schema["type"]
97
+ schema["enum"] += [nil] if schema.key?("enum")
98
+ schema
99
+ end
100
+
84
101
  def scalar_schema(field)
85
102
  schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
86
103
  apply_in!(schema, field[:in])
@@ -89,6 +106,19 @@ module Permittable
89
106
  schema
90
107
  end
91
108
 
109
+ # A `:json` field's shape is deliberately undeclared, so the schema says
110
+ # "an object" and carries only the bounds the field does declare. JSON
111
+ # Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
112
+ # extension rather than being dropped or mistranslated.
113
+ def opaque_schema(field)
114
+ schema = { "type" => "object" }
115
+ min, max = length_bounds(field[:length])
116
+ schema["minProperties"] = min if min
117
+ schema["maxProperties"] = max if max
118
+ schema["x-permittable-max-depth"] = field[:max_depth] if field[:max_depth]
119
+ schema
120
+ end
121
+
92
122
  def array_schema(field, unknown:)
93
123
  schema = { "type" => "array" }
94
124
  min, max = length_bounds(field[:length])
@@ -183,6 +213,9 @@ module Permittable
183
213
  def json_value(value)
184
214
  case value
185
215
  when Array then value.map { |v| json_value(v) }
216
+ # An authored `:json` default/example is a whole hash; its values get
217
+ # the same re-encoding as any other authored scalar.
218
+ when Hash then value.to_h { |k, v| [k.to_s, json_value(v)] }
186
219
  when BigDecimal then value.to_s("F")
187
220
  when Time then value.utc.iso8601
188
221
  # DateTime subclasses Date, so it must match first.
@@ -91,6 +91,11 @@ module Permittable
91
91
  self
92
92
  end
93
93
 
94
+ def nullable
95
+ @expected[:nullable] = true
96
+ self
97
+ end
98
+
94
99
  # -- RSpec protocol ---------------------------------------------------
95
100
 
96
101
  def matches?(subject)
@@ -191,7 +196,7 @@ module Permittable
191
196
  when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
192
197
  when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
193
198
  when :required then required_mismatch(field, value)
194
- when :virtual, :sensitive then "expected the field to be #{key}, but it is not" unless field[key]
199
+ when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
195
200
  else option_mismatch(field, key, value)
196
201
  end
197
202
  end
@@ -226,7 +231,7 @@ module Permittable
226
231
  when :array then "as an array"
227
232
  when :of then "of :#{value}"
228
233
  when :required then value ? "required" : "optional"
229
- when :virtual, :sensitive then key.to_s
234
+ when :virtual, :sensitive, :nullable then key.to_s
230
235
  else "#{OPTION_LABELS.fetch(key)} #{value.inspect}"
231
236
  end
232
237
  end
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.5.1".freeze
2
+ VERSION = "0.6.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -34,6 +34,7 @@ require "permittable/filter_parameter_registry"
34
34
  # optional :ssn, :string, sensitive: true
35
35
  # optional :plan, :string, in: %w[free pro], default: "free"
36
36
  # array :tag_names, of: :string, length: 0..10, virtual: true
37
+ # optional :metadata, :json, max_depth: 3, length: 0..32
37
38
  # optional :address do
38
39
  # required :city, :string
39
40
  # optional :zip, :string, format: /\A\d{5}\z/
@@ -79,6 +80,17 @@ require "permittable/filter_parameter_registry"
79
80
  # request. `permittable_violations` reads the recorded details ([] when
80
81
  # the request was clean).
81
82
  #
83
+ # THE :json FIELD — the deliberate hole. A json/jsonb column exists precisely
84
+ # so its contents need no schema, and until it was declarable a contract could
85
+ # only drop that key (strong parameters spells it `permit(metadata: {})`).
86
+ # `optional :metadata, :json` passes an arbitrary Hash through untouched —
87
+ # keys are neither filtered nor cast, and `unknown:` does not descend into it
88
+ # — while still letting the contract bound the shape it refuses to describe:
89
+ # `length:` caps the top-level key count, `max_depth:` caps container nesting
90
+ # (arrays count as a level), and `validate:`/`transform:` see the whole hash.
91
+ # Anything that is not a Hash is `invalid_type`, and the field still maps onto
92
+ # a column for the drift guard.
93
+ #
82
94
  # Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
83
95
  # casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
84
96
  # and silently corrupting untrusted input is exactly what a contract must not
@@ -86,8 +98,16 @@ require "permittable/filter_parameter_registry"
86
98
  # guess. nil and "" are both treated as ABSENT (the query-param convention):
87
99
  # absent optional fields are OMITTED from the result (so partial updates never
88
100
  # nil-out columns), absent required fields violate, and `default:` fills
89
- # absence. Clearing a column to NULL is therefore outside a contract's
90
- # vocabulary — do that explicitly.
101
+ # absence.
102
+ #
103
+ # `nullable: true` splits that rule in two for one field, which is how a PATCH
104
+ # clears a column: a key the client never sent stays absent (defaults apply,
105
+ # required violates), but a key sent EMPTY (JSON null, or "" from a form) is an
106
+ # explicit null and yields nil in the result — ahead of any `default:`, and
107
+ # without casting or checking a value that isn't there. It reads on arrays and
108
+ # nested blocks too (the array/object itself may be null, never its elements),
109
+ # and `default: nil` — legal only on a nullable field — gives the PUT reading
110
+ # where absence also means clear.
91
111
  #
92
112
  # Failures raise Permittable::InvalidParameters, rescued (on a real
93
113
  # controller) into the shared ErrorEnvelope shape with `details:` entries of
@@ -134,6 +154,9 @@ module Permittable
134
154
 
135
155
  LABEL = "Permittable".freeze
136
156
  SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
157
+ # Not a scalar: an opaque hash whose shape is deliberately undeclared, for
158
+ # the json/jsonb column a contract has to be able to carry.
159
+ JSON_TYPE = :json
137
160
  UNKNOWN_MODES = %i[ignore log error].freeze
138
161
  MODES = %i[enforce monitor].freeze
139
162
  # Rails merges routing bookkeeping into params; a top-level (root: false)
@@ -245,6 +268,31 @@ module Permittable
245
268
  check_custom(field[:validate], value)
246
269
  end
247
270
 
271
+ # Free-form hash. The shape is deliberately undeclared, so the only
272
+ # checks are the bounds the field asked for: breadth (`length:`, the
273
+ # top-level key count, same reading as an array's element count) and
274
+ # nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
275
+ # checking, like check_scalar.
276
+ def check_json(field, value)
277
+ return [:error, "invalid_type"] unless value.is_a?(Hash)
278
+ return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
279
+ return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
280
+
281
+ check_custom(field[:validate], value)
282
+ end
283
+
284
+ # Container nesting, with the field's own hash as level 1. An Array counts
285
+ # as a level too — a deeply nested payload is a deeply nested payload
286
+ # whichever container carries it. Bails at the first breach instead of
287
+ # measuring the whole tree.
288
+ def depth_exceeds?(value, limit)
289
+ return false unless value.is_a?(Hash) || value.is_a?(Array)
290
+ return true if limit < 1
291
+
292
+ children = value.is_a?(Hash) ? value.each_value : value.each
293
+ children.any? { |child| depth_exceeds?(child, limit - 1) }
294
+ end
295
+
248
296
  # A custom validator returning a Symbol fails with that symbol as the
249
297
  # violation code; false/nil fails as "invalid"; any other truthy value
250
298
  # passes.
@@ -318,16 +366,37 @@ module Permittable
318
366
  [:error, "invalid_type"]
319
367
  end
320
368
 
369
+ # Date.parse fills in what a string omits FROM TODAY: "09/2026" becomes
370
+ # the 1st, "5th" becomes this month of this year. That is a guess, and a
371
+ # non-deterministic one — the same request means different things on
372
+ # different days — which is exactly what this coercion exists to refuse.
373
+ # So the string must name all three parts; which format it names them in
374
+ # is Date.parse's business, and every complete format it understands
375
+ # ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
321
376
  def cast_date(value)
322
377
  case value
323
378
  when Date then [:ok, value]
324
- when String then [:ok, Date.parse(value)]
379
+ when String
380
+ found = Date._parse(value)
381
+ return [:error, "invalid_type"] unless complete_date?(found)
382
+
383
+ # Built from the components rather than re-running Date.parse, which
384
+ # would parse the same string a second time — and Date.parse is the
385
+ # expensive half. Date.new applies the same calendar validation, so
386
+ # "2026-02-30" still fails.
387
+ [:ok, Date.new(found[:year], found[:mon], found[:mday])]
325
388
  else [:error, "invalid_type"]
326
389
  end
327
390
  rescue ArgumentError, RangeError
328
391
  [:error, "invalid_type"]
329
392
  end
330
393
 
394
+ # Date._parse is the layer under Date.parse, and reports which components
395
+ # it actually FOUND rather than the filled-in result.
396
+ def complete_date?(found)
397
+ found.key?(:year) && found.key?(:mon) && found.key?(:mday)
398
+ end
399
+
331
400
  # A zoneless String parses as UTC regardless of the host timezone
332
401
  # (deterministic); explicit offsets are honoured and normalised to UTC.
333
402
  def cast_datetime(value)
@@ -335,7 +404,17 @@ module Permittable
335
404
  # DateTime is listed here, ahead of Date, because it subclasses Date.
336
405
  when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
337
406
  when Date then [:ok, Time.utc(value.year, value.month, value.day)]
338
- when String then [:ok, DateTime.parse(value).to_time.utc]
407
+ when String
408
+ # Same rule as :date — the DATE part must be named in full, or it is
409
+ # taken from today ("10:30" meant today at 10:30). An absent TIME part
410
+ # is fine and means midnight, which is the documented reading of a
411
+ # date given to a :datetime field.
412
+ #
413
+ # Unlike :date this still parses twice, deliberately: rebuilding a
414
+ # Time from components would have to reimplement DateTime.parse's
415
+ # handling of offsets, zone names and sub-second precision, and
416
+ # getting that subtly wrong costs more than the parse.
417
+ complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]
339
418
  else [:error, "invalid_type"]
340
419
  end
341
420
  rescue ArgumentError, RangeError
@@ -365,9 +444,13 @@ module Permittable
365
444
  # declaration is validated eagerly: a bad contract is a programmer error and
366
445
  # should fail at class load, not at request time.
367
446
  class ContractBuilder
368
- SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example].freeze
369
- NESTED_OPTS = %i[virtual sensitive message desc].freeze
370
- ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example].freeze
447
+ SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
448
+ nullable].freeze
449
+ NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
450
+ JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
451
+ nullable].freeze
452
+ ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
453
+ nullable].freeze
371
454
 
372
455
  attr_reader :finalizer
373
456
 
@@ -437,6 +520,12 @@ module Permittable
437
520
  field = { name: name, kind: :nested, required: required,
438
521
  fields: nested_fields!(name, &block), **opts }
439
522
  validate_message!(field)
523
+ elsif type&.to_sym == JSON_TYPE
524
+ assert_opts!(name, opts, JSON_OPTS)
525
+ # `type:` is carried alongside `kind:` so the same `as(:json)` matcher
526
+ # chain and the same error wording work as for a scalar.
527
+ field = { name: name, kind: :json, required: required, type: JSON_TYPE, **opts }
528
+ validate_json_opts!(field)
440
529
  else
441
530
  assert_opts!(name, opts, SCALAR_OPTS)
442
531
  field = { name: name, kind: :scalar, required: required,
@@ -500,6 +589,40 @@ module Permittable
500
589
  validate_message!(field)
501
590
  end
502
591
 
592
+ def validate_json_opts!(field)
593
+ name = field[:name]
594
+ if field[:required] && field.key?(:default)
595
+ raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
596
+ end
597
+
598
+ validate_length!(name, field[:length]) if field.key?(:length)
599
+ validate_max_depth!(name, field[:max_depth]) if field.key?(:max_depth)
600
+ validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
601
+ validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
602
+ validate_json_authored_value!(field, :default)
603
+ validate_json_authored_value!(field, :example)
604
+ validate_message!(field)
605
+ end
606
+
607
+ def validate_max_depth!(name, depth)
608
+ return if depth.is_a?(Integer) && depth.positive?
609
+
610
+ raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
611
+ end
612
+
613
+ # Same rule as a scalar's authored value, over check_json: a `default:` or
614
+ # `example:` that its own bounds would reject fails at class load.
615
+ def validate_json_authored_value!(field, opt)
616
+ return unless field.key?(opt)
617
+ return if authored_nil!(field, opt)
618
+ raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
619
+
620
+ status, code = Coercion.check_json(field, field[opt])
621
+ return if status == :ok
622
+
623
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
624
+ end
625
+
503
626
  # format / length / normalize reason about characters; on any other
504
627
  # type they would silently apply to a cast non-String and mislead.
505
628
  def validate_string_only_opts!(field)
@@ -540,6 +663,7 @@ module Permittable
540
663
  # shipping it to every request (or publishing it in generated docs).
541
664
  def validate_authored_value!(field, opt)
542
665
  return unless field.key?(opt)
666
+ return if authored_nil!(field, opt)
543
667
 
544
668
  status, code = Coercion.check_scalar(field, field[opt])
545
669
  return if status == :ok
@@ -549,6 +673,7 @@ module Permittable
549
673
 
550
674
  def validate_array_authored_value!(field, opt)
551
675
  value = field[opt]
676
+ return if authored_nil!(field, opt)
552
677
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
553
678
  return unless field[:of]
554
679
 
@@ -560,6 +685,19 @@ module Permittable
560
685
  end
561
686
  end
562
687
 
688
+ # An authored nil is only meaningful on a nullable field, where it says
689
+ # "absent means clear" (PUT semantics) rather than "no default". On any
690
+ # other field it is a value nil could never satisfy, so it fails at class
691
+ # load with the fix named.
692
+ def authored_nil!(field, opt)
693
+ return false unless field[opt].nil?
694
+ return true if field[:nullable]
695
+
696
+ raise ArgumentError,
697
+ "#{LABEL}: :#{opt} for field :#{field[:name]} is nil but the field is not nullable — " \
698
+ "declare nullable: true to make an explicit null part of the contract"
699
+ end
700
+
563
701
  # `message:` customizes what the client reads for a violation on this
564
702
  # field: one String covering every code, or a Hash of code => String
565
703
  # (codes without an entry keep the default rendering). Keys are
@@ -694,9 +832,10 @@ module Permittable
694
832
  end
695
833
 
696
834
  # The drift guard. Nested/array fields are implicitly virtual — only
697
- # scalar fields map one-to-one onto columns.
835
+ # scalar fields, and the opaque `:json` field standing in for a
836
+ # json/jsonb column, map one-to-one onto columns.
698
837
  def guard_contract_columns!(model_class, fields)
699
- checked = fields.select { |f| f[:kind] == :scalar && !f[:virtual] }
838
+ checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
700
839
  return if checked.empty?
701
840
 
702
841
  types = checked.to_h { |f| [f[:name], f[:type]] }
@@ -903,7 +1042,9 @@ module Permittable
903
1042
  value = hash[key]
904
1043
 
905
1044
  if permittable_absent?(value, hash, key)
906
- if field.key?(:default)
1045
+ if permittable_explicit_null?(field, hash, key)
1046
+ result[key] = nil
1047
+ elsif field.key?(:default)
907
1048
  result[key] = field[:default]
908
1049
  elsif field[:required]
909
1050
  violations << permittable_violation(field, full, "missing")
@@ -921,13 +1062,9 @@ module Permittable
921
1062
  key = field[:name].to_s
922
1063
  case field[:kind]
923
1064
  when :scalar
924
- status, out = Coercion.check_scalar(field, value)
925
- if status == :ok
926
- out = field[:transform].call(out) if field[:transform]
927
- result[key] = out
928
- else
929
- violations << permittable_violation(field, full, out)
930
- end
1065
+ permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
1066
+ when :json
1067
+ permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
931
1068
  when :nested
932
1069
  if value.is_a?(Hash)
933
1070
  result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
@@ -944,6 +1081,19 @@ module Permittable
944
1081
  end
945
1082
  end
946
1083
 
1084
+ # The shared tail of the two kinds whose entire value is checked in one
1085
+ # call — a scalar, or an opaque hash. A clean value is transformed into the
1086
+ # result; anything else records its code.
1087
+ def permittable_check_whole(field, outcome, full, result, violations:)
1088
+ status, out = outcome
1089
+ if status == :ok
1090
+ out = field[:transform].call(out) if field[:transform]
1091
+ result[field[:name].to_s] = out
1092
+ else
1093
+ violations << permittable_violation(field, full, out)
1094
+ end
1095
+ end
1096
+
947
1097
  def permittable_check_array(field, value, path:, unknown:, violations:)
948
1098
  before = violations.length
949
1099
  violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
@@ -982,6 +1132,15 @@ module Permittable
982
1132
  !hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
983
1133
  end
984
1134
 
1135
+ # `nullable: true` splits the one absence rule in two: a key the client
1136
+ # never sent is still absent (defaults apply, required violates), but a key
1137
+ # sent EMPTY is an explicit null — the field yields nil, so a PATCH can
1138
+ # clear a column. Nothing is cast or checked: there is no value to check,
1139
+ # and `transform:` never sees a nil it did not agree to.
1140
+ def permittable_explicit_null?(field, hash, key)
1141
+ field[:nullable] && hash.key?(key)
1142
+ end
1143
+
985
1144
  def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
986
1145
  return if unknown == :ignore
987
1146
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport