Skip to content

Hand out defaults cast and owned, and never alias request strings - #62

Merged
VSN2015 merged 5 commits into
masterfrom
fix/owned-result-values
Sep 25, 2026
Merged

VSN2015 merged 5 commits into
masterfrom
fix/owned-result-values

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

A bug fix. Three reproduced defects share one root: values in permitted_params that weren't really the app's own. A default came out in the wrong type, a default had frozen Strings inside, and Strings were shared with the request. Updated twice after review: see "Review follow-ups" below, most recently the "(third commit)" section — a final regression pass over the second round's own fix.

The bugs

1. An authored default: was stored in its authored form, not cast. validate_authored_value! cast the value to check it, then threw the cast away.

  declaration                                   omitted key yielded      a request sending it gets
  :integer, default: "18"                       "18"                     18
  :boolean, default: "false"                    "false"  (truthy!)       false
  :decimal, default: 1.5                        1.5 (Float)              BigDecimal("1.5")
  :date, default: "2026-01-05"                  "2026-01-05"             Date
  array of: :integer, default: ["1", "2"]       ["1", "2"]               [1, 2]

The exported JSON Schema said the same thing: "type": "integer", "default": "18".

2. Defaults had frozen Strings inside them. Only a top-level String was copied on the way out. So permitted_params[:tags].first << "x" on an of: :string default raised FrozenError, and so did any edit to a String inside a :json default.

3. The result shared the request's own Strings. permitted_params[:name] << "!", or a mutating transform:/normalize: proc, rewrote the caller's Hash or ActionController::Parameters. The module doc promises "the request's params is never touched".

The fix

  • Store the value as the contract reads it. A scalar is normalized and cast.
  • Read array defaults with the request walker. An array's authored default:/example: now goes through the request walker itself. The new Permittable::AuthoredValues runs permittable_check_array at class load, so the stored value is exactly what a request sending it gets:
    • elements are cast at every depth, nested hashes and arrays included
    • "" on a nullable sub-field becomes nil
    • a sub-field's own default: is filled in
    • undeclared keys are dropped, as unknown: :ignore drops them
    • the array's validate: runs
      The hand-rolled one-level check this replaces is gone.
  • Deep-copy on read. permittable_default is now field[:default].deep_dup.
  • Copy each String once, as the walker reads it. The copy happens in permittable_normalized, before normalize: sees the value, and in permittable_check_element for of: elements. cast_string returns its argument again. A :json hash is deep-copied after its length:/max_depth: bounds pass, and before validate:/transform: see it.

Decisions I'd like reviewed

transform: never runs on a default, and — after the third-commit fix below — neither does the cast. A field declaring transform: stores its default exactly as authored (still validated against the field's own contract at class load, like any default). A field with no transform: keeps the cast-and-normalized storage above. That split exists because casting a transform: field's default against its declared type used to silently fight the README's own advice to author such a default already in the shape transform: would produce — see finding 1 under "Review follow-ups (third commit)".

example: is read the same way. Both go through the same functions. For a block array, this means a sub-field default is filled into the published example too. That is still a valid request payload.

New class-load strictness for arrays. A default that the array's own validate: refuses now fails at class load, which scalars already did. The error names the walker's path and code, for example :default for array :items violates its own contract: items[0].sku (missing). This still runs even for a transform: field's default (to catch a genuine authoring mistake), independently of whether the cast result is the one stored.

A :decimal default/example is exported as a JSON number when a Float holds it exactly. BigDecimal(f.to_s) == value decides it; otherwise it stays a string. This keeps default: 1.5 exported as 1.5, as it was on master. It also turns an authored example: BigDecimal("19.99") from "19.99" into 19.99. This applies to a :decimal field's own default:/example:/enum (in:) — the third-commit fix below extends it to enum too, so a default stays a member of its own enum's exported list — but never recurses into a :json field's opaque contents, which stay untyped. minimum/maximum keep their existing encoding. Heads-up for #65: that branch changes in:/enum export too (casting in: members at class load, and re-encoding a :decimal enum as the same string default: used to export) — the two touch the same apply_in! code and will need reconciling at merge time, independently of whichever branch lands first.

with_default reads its argument the way the contract reads its default — cast-and-normalized for a field with no transform:, bare for one that has it, matching how the default is now stored either way. The declaration's own spelling passes: with_default("18"), "2026-01-05", "false", [{ sku: "a" }], or with_default(25) against a transform: field's own default: 25. So does the stored value, and a failure shows what the spec wrote. It also copies its argument before normalizing (see finding 3 below).

Monitor mode's pass-through is left alone. It promises the pre-contract app's params, and params.permit hands those Strings back by reference too. There is a comment at the call site explaining this.

Review follow-ups (second commit)

  1. The copy moved to the walker's input, ahead of normalize:. There is now one copy per String. Specs cover a Hash and a real ActionController::Parameters.
  2. Block-array defaults are read by the request walker at class load. A spec checks that the defaulted result equals the result of sending the same values, at every depth.
  3. transform: never runs on a default, and the docs say so.
  4. :json is copied only on the :ok path. A spec shows a rejected payload is never copied.
  5. Exact decimals are exported as JSON numbers.
  6. with_default casts its argument before comparing.

Review follow-ups (third commit)

A final regression pass over the second commit's own fix, five findings, TDD throughout (failing specs first):

  1. Regression: casting a transform: field's default changed its type. optional :page_size, :string, transform: ->(v){ v.to_i }, default: 25 used to hand out the Integer 25 when the param was omitted (master's documented "author it in the final shape" behavior); this PR's cast-every-default fix instead cast it against the field's declared :string type and handed out "25". Same shape of bug for :date → :datetime transforms and for of: array element casts. Fixed by storing a transform: field's default as authored (deep-frozen, still copied per request) instead of cast — see validate_authored_value!/validate_array_authored_value! in lib/permittable.rb. A field with no transform: is unaffected.
  2. A :decimal default: exported as a number could fall outside its own enum. in: still exported every BigDecimal member as a String, so a default that round-trips through Float exactly (and now exports as a number) was no longer one of the values its own enum lists — schema linters reject that. JsonSchema.apply_in! now applies the same numeric-vs-string rule to a :decimal field's enum that default:/example: already use.
  3. with_default's cast could raise FrozenError, or silently mutate the spec's own literal. It ran the field's normalize: on the spec's argument directly, unlike validate_authored_value!, which copies first specifically so a mutating normalize: proc can't rewrite the host's own literal. Fixed by copying (a String only) before normalizing, in PermitParamMatcher#cast_default.
  4. The :decimal numeric-export rule leaked into :json field contents. A :json field's default is opaque and meant to pass through untouched, but annotate applied decimal: :number to every default/example regardless of field kind, so a BigDecimal nested inside a :json default was reinterpreted too — a finite one silently became a JSON number outside any :decimal schema's justification, and BigDecimal("Infinity") became Float::INFINITY, which then crashes JSON.generate. annotate now scopes the numeric-export rule to non-:json fields; a :json field's default/example always renders a BigDecimal as a String via to_s("F"), finite or not.
  5. A BigDecimal default:/example: on a plain :string field rendered in scientific notation. cast_string fell through to the generic Numeric#to_s, and BigDecimal#to_s defaults to engineering notation ("0.15e1" for 1.5) — stdlib's own behavior, only masked in this repo's own spec suite because active_record (required by spec_helper.rb) pulls in active_support/core_ext/big_decimal/conversions, which patches that default to "F" — the same way the 0.8.0 TimeWithZone regression was once masked. cast_string now special-cases BigDecimal with to_s("F") explicitly, so a standalone host (no Rails) gets the same plain rendering. Regression coverage for this one lives in spec/support/runtime_deps_smoke.rb, the one place in the suite that genuinely runs without active_record loaded; a value-pinning unit spec is also in spec/permittable_spec.rb for documentation, though it can't turn red in-suite for the reason above.

CHANGELOG.md and README.md are updated to describe the transform:-vs-cast default split, the :json-scoped numeric-export rule, and the enum consistency fix precisely.

Verification

  • 661 examples, 0 failures (655 + 6 new specs from this round, all written failing first).
  • RuboCop clean

🤖 Generated with Claude Code

Sang and others added 3 commits September 24, 2026 19:16
An authored default: was validated by casting and then stored in its
authored form, so `default: "18"` on an :integer yielded "18" (and the
JSON Schema published "type": "integer", "default": "18"), and a
:boolean default of "false" was truthy. Store the cast value — for of:
elements and block-array scalar sub-fields too — and the same for
example:, which shares the check.

Defaults were handed out with frozen Strings inside (only a top-level
String was copied), so editing an array element or a :json default
raised FrozenError. Each request now gets a deep copy.

cast_string returned the caller's String, so mutating the result or a
mutating transform: rewrote params. Copy it, and deep-copy a :json
value before validate:/transform: see it. Monitor mode's pass-through
is left as-is: it promises the pre-contract app's params, by reference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…way in

Review follow-ups for #62:

- Copy a request's String at the walker's input, before normalize:,
  instead of in cast_string, so a mutating normalize: proc cannot
  rewrite params either and each String is copied once.
- Read an array's authored default:/example: with the request walker
  itself (permittable_check_array), minus transform:, so nested values
  are cast, nullable "" becomes nil, sub-defaults are filled in,
  undeclared keys are dropped and the array's validate: runs.
- Never run transform: on a default, and say so precisely.
- Deep-copy a :json value only after its bounds pass.
- Export an exact BigDecimal default/example as a JSON number.
- Cast with_default's argument before comparing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@VSN2015

VSN2015 commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Addressed the review in 0e03c37 (after merging master):

  1. Strings are copied at the walker's input, before normalize: (once each). cast_string no longer copies.
  2. Array defaults/examples are read by the request walker itself at class load (new Permittable::AuthoredValues).
  3. transform: never runs on a default. CHANGELOG, README and the module comment say so precisely.
  4. :json is deep-copied only after its bounds pass.
  5. An exact BigDecimal default/example exports as a JSON number.
  6. with_default casts its argument before comparing.
    655 examples, 0 failures; RuboCop clean.

…port edges

Final regression pass on the "values the app owns" PR fixes five findings:

- A field declaring transform: now stores its default: exactly as authored
  (deep-frozen, still copied per request), instead of cast against its
  declared type. Casting it silently broke the README's own advice to author
  such a default already in the shape transform: would produce
  (transform: ->(v) { v.to_i }, default: 25 on a :string field used to hand
  out Integer 25; this PR's earlier fix cast it to "25"). A field with no
  transform: is unaffected and keeps the cast-and-normalized storage.

- JsonSchema.apply_in! applies the same numeric-vs-string rule to a :decimal
  field's enum (in:) that default:/example: already use, so a numerically
  exported default stays a member of its own enum's exported list.

- PermitParamMatcher#cast_default copies its argument (a String) before
  normalizing, matching validate_authored_value!'s own copy-first discipline,
  so a mutating normalize: proc can't raise FrozenError or silently rewrite
  the spec's own literal. It also compares a transform: field's default bare
  rather than cast, matching the new storage rule above.

- The :decimal numeric-export rule in JsonSchema#annotate is now scoped to a
  field's own kind and never recurses into a :json field's opaque contents,
  which stay untyped: a BigDecimal inside a :json default renders as a
  String via to_s("F") as before, rather than becoming a JSON number outside
  any :decimal schema's justification, or Float::INFINITY (which crashes
  JSON.generate) for a non-finite one.

- Coercion.cast_string renders a BigDecimal with to_s("F") instead of the
  generic Numeric#to_s, so a BigDecimal default:/example: on a plain :string
  field publishes as "1.5" rather than the scientific "0.15e1" BigDecimal#to_s
  gives by default. This one is masked in the main spec suite because
  active_record (required by spec_helper.rb) pulls in
  active_support/core_ext/big_decimal/conversions, which patches that
  default away — regression coverage lives in
  spec/support/runtime_deps_smoke.rb, the one place that runs without it.

TDD throughout: each finding got a failing spec first. 661 examples (was
655), 0 failures; RuboCop clean. CHANGELOG.md and README.md updated to
describe the transform:-vs-cast split and the :json-scoped export rule
precisely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015

VSN2015 commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Pushed 206cf7e — final regression pass, 5 findings, TDD (failing specs first):

  1. A transform: field's default: is now stored as authored (not cast against its declared type) — fixes a regression where transform: ->(v) { v.to_i }, default: 25 on a :string field started handing out "25" instead of the Integer 25 master always gave. A field with no transform: still gets the cast-and-normalized storage from the last round.
  2. :decimal enum (in:) now uses the same numeric-vs-string export rule as default:/example:, so a numerically-exported default stays a member of its own enum's exported list.
  3. with_default's cast now copies its argument before normalizing, so a mutating normalize: proc can't raise FrozenError or rewrite the spec's own literal — matching validate_authored_value!'s own copy-first discipline. It also compares a transform: field's default bare, per (1).
  4. The :decimal numeric-export rule no longer recurses into a :json field's opaque contents — a BigDecimal inside a :json default stays a String (to_s("F")), so a finite one doesn't silently become a number and BigDecimal("Infinity") doesn't become Float::INFINITY and crash JSON.generate.
  5. cast_string renders a BigDecimal with to_s("F") instead of the generic to_s, so a BigDecimal default on a plain :string field publishes as "1.5", not "0.15e1". This one is masked in the main suite by active_record's own BigDecimal#to_s patch (loaded via spec_helper.rb) — regression coverage lives in spec/support/runtime_deps_smoke.rb, which runs without it.

661 examples (was 655), 0 failures. RuboCop clean. CHANGELOG.md/README.md updated. PR body updated with the details above.

Noted a likely overlap with #65 in the PR body: that branch also touches :decimal in:/enum export in apply_in!, with a different encoding decision (string, matching master's older default: encoding) — will need reconciling at merge time.

Master had moved to include #59-#61, #64 and #66 since this branch's last
merge, plus #60 and #65 which both touch json_schema.rb's :decimal/:datetime
export and lib/permittable/rspec.rb's `in:`/`default:` matcher chains —
real conflicts, not just additive.

lib/permittable.rb: kept this branch's permittable_transform seam
(AuthoredValues needs it to walk a default without running transform: on
it) — same guard (`violations.length == before`) as master's inline form.

lib/permittable/json_schema.rb (4 hunks): combined rather than picked a
side.
- apply_in!'s enum export now uses BOTH #65's `field[:in_published] ||
  allowed` (keeps a :date/:datetime member's authored String form) AND
  this branch's `decimal: :number` (keeps a :decimal member numeric and
  consistent with its own default/example export).
- kept #60's `apply_normalize!` method (unrelated to this branch) ahead of
  json_value, whose signature this branch changes to `decimal: :string`.
- json_value's Hash/BigDecimal/Time cases: kept this branch's `decimal:`
  threading for Hash/BigDecimal, and #65's `exact_iso8601` for Time (fixes
  sub-second Time :in members exporting as whole seconds — this branch's
  plain `.utc.iso8601` would have regressed that).
- kept both decimal_json (this branch) and exact_iso8601 (#65) methods;
  each is used from the merged json_value body above.
Verified with a probe: a :decimal in: list now exports numerically AND
agrees with its own numeric default, and a :datetime in: list with
sub-second members still exports the authored string via in_published —
both at once, which neither branch alone tested.

lib/permittable/rspec.rb (2 hunks) and spec/matchers_spec.rb: purely
additive — #65's `cast_in`/`same_in?`/`within` alongside this branch's
`default_mismatch`/`cast_default`. Combined by keeping both.

README.md: combined the `default:` row (this branch, the transform:
exception) with the `validate:` row (master, the array-skip-on-failed-
validate note) — same table, different rows each PR had touched.

Fixed along the way (found while merging, not part of either PR):
- spec/permittable_spec.rb: a #61 perf spec asserted `valid_encoding?` is
  called on the caller's OWN string object, but this branch's
  permittable_own copies a request's String before Coercion.cast ever sees
  it (to avoid aliasing params) — so the original object is never touched,
  though the copy is (correctly) scanned exactly once. Rewrote the spec
  to count via a shared counter that survives `dup`, so it asserts the
  real invariant (one scan overall) rather than one specific object's
  identity.
- spec/matchers_spec.rb: my own conflict resolution (both PRs inserted a
  new `it` block at the same point in the file) left one block's closing
  `end` missing — a syntax error that silently dropped all 42 examples in
  this file from the suite total without failing the run. Fixed; the
  suite total is now 819 (was silently 777).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit 7c72364 into master Sep 25, 2026
17 checks passed
@VSN2015
VSN2015 deleted the fix/owned-result-values branch September 25, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant