Skip to content

Stop an array default's transform: suppression from leaking to sub-fields - #83

Merged
VSN2015 merged 1 commit into
masterfrom
fix/array-default-transform-leak
Sep 29, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/array-default-transform-leak

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

The bug

AuthoredValues is the request walker validate_array_authored_value! points at an authored array default:/example: at class load — the same permittable_check_array a real request goes through, so the two paths cannot drift apart in how they cast, normalize, and validate. It overrides permittable_transform so that the array field's own transform: never runs over its authored default (that result is discarded anyway — the value is stored exactly AS AUTHORED when the field declares transform:, per validate_array_authored_value!).

That override was unconditional — it ignored which field it was even being asked about, so it suppressed transform: for every sub-field the walk touched too, not just the outer array field's own:

array :line_items, default: [{"price"=>"10.00"}] do
  optional :price, :decimal, transform: ->(v){ v * 100 }
end

line_items itself declares no transform:, so field[:default] is supposed to be stored as AuthoredValues' read of it — "exactly what a request sending it gets", per the surrounding comment. But because the walk suppressed :price's transform too, the stored default became [{"price"=>10}] instead of [{"price"=>1000}]:

  • A request omitting line_items (falling back to the default) yielded price == 10.
  • An equivalent request explicitly sending {"price"=>"10.00"} yielded price == 1000.

Two requests that should be indistinguishable silently diverged 100x. The exported OpenAPI default (which reads the same field[:default]) also documented the untransformed value — a value the server never actually produces for equivalent input.

The fix

AuthoredValues#permittable_transform now compares the field it's asked about against @field — the one field whose default:/example: this particular walk is validating — by identity, instead of suppressing every field unconditionally:

def permittable_transform(field, value)
  return value if @field[:transform] || field.equal?(@field)

  super
end
  • @field (the array field itself) still never runs its own transform: during this walk — its result was always going to be discarded when it declares one, so nothing inside its subtree is worth reading transformed, keeping the walk free of app code exactly as a scalar default's cast-only check already is.
  • A sub-field's own transform: now runs normally, so the stored default matches what an equivalent request actually produces.

Updated the two comments (in authored_values.rb and validate_array_authored_value!) that documented the old, unconditional suppression.

Verification

  • New test written first (spec/permittable_spec.rb), confirmed to fail before the implementation change (price => 10 instead of 1000) and pass after
  • bundle exec rspec — 852 examples, 0 failures
  • bundle exec rubocop lib/permittable.rb lib/permittable/authored_values.rb spec/permittable_spec.rb — no offenses
  • Confirmed the existing regression test "never runs an app's transform: over a default:, at class load or on the way out" (where the array field itself DOES declare transform:) still passes unchanged, since @field[:transform] truthy keeps suppressing the whole subtree in that case

🤖 Generated with Claude Code

…s sub-fields

AuthoredValues, the walker that validates an array field's default:/example:
at class load, overrode permittable_transform unconditionally to skip the
field's own transform: (its result is discarded anyway — see
validate_array_authored_value!). But the override ignored which field it
was being asked about, so it also suppressed transform: for every nested
sub-field the walk touched.

That meant a request omitting the array (falling back to the stored
default:) and an equivalent request explicitly sending the same shape
silently diverged whenever a sub-field declared transform::

  array :line_items, default: [{"price"=>"10.00"}] do
    optional :price, :decimal, transform: ->(v){ v * 100 }
  end

Omitting line_items yielded price == 10; sending {"price"=>"10.00"}
explicitly yielded price == 1000. The exported OpenAPI default, which reads
the same field[:default], documented the untransformed value the server
never actually produced for equivalent input.

The override now compares the field it is asked about against the one
default:/example: validation is running for (by identity), so it still
suppresses that field's own transform: but runs every nested sub-field's
transform: normally, matching what an equivalent request would produce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit 0606fb4 into master Sep 29, 2026
16 checks passed
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