Export numeric bounds as JSON numbers and label every looser divergence - #60
Merged
Merged
Conversation
A :decimal bounded by BigDecimals published "minimum": "0.01" as a string, because range bounds went through the authored-value re-encoding; the metaschema requires numbers, so the document was invalid. Bounds are now Integers when exact, otherwise Floats rounded inward when a double cannot round-trip the bound, so the published range is never wider than the enforced one. normalize: is now exported as x-permittable-normalize, and it and the string encoding of a bounded :decimal join max_depth: as documented, direction-asserted divergences in the README and the conformance spec. No runtime behaviour changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
An infinite or NaN range endpoint is now omitted instead of crashing the
export (BigDecimal("Infinity").to_i raised) or emitting Infinity. The
inward nudge for an unrepresentable bound now asks the field's own cast
and comparison, so a :float bound is safe too; it used to be one double
short there.
The documented looser divergences now also cover strings whose validity
is only a format annotation (:decimal, :date, :datetime), validate:
procs, and non-numeric ranges. The conformance spec checks each marker
on the diverging field's own schema, and its tables live in a module.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Addressed all six review points in dd5be8e (after merging master): infinite/NaN bounds omitted instead of crashing; the inward nudge now checks the field's own cast and comparison, so :float is safe too; format-only strings, validate: and non-numeric ranges added to the looser list and labelled; release notes and README counts reconciled; LOOSER markers checked on the diverging field's own schema; the tables moved into a SchemaConformance module. 671 examples, 0 failures; RuboCop clean. Details are in the updated description. |
optional :x, :float, in: (10**400..) dropped `minimum` entirely: the inward-nudge loop asks the field's own cast whether the published bound is honoured, and that cast runs the bound through `to_f`, which overflows a value this large to Infinity and walked the bound to Infinity too, where it was omitted like a genuinely infinite one. Master published it outright as `minimum: 10**400`, so this was a regression, not a labelled divergence: a JSON integer has no size limit, so an integral bound needs neither Float conversion nor the nudge machinery, which exists only to protect a FRACTIONAL bound from double-rounding. `json_bound` now returns an integral bound before the loop runs whenever `to_f` would overflow it. A genuinely fractional bound past Float::MAX still has no arbitrary- precision JSON representation to fall back on and stays omitted, now documented as deliberate rather than silent. Also, from the last regression pass: - Qualify the "published range can only be narrower than the enforced one" claim in the CHANGELOG/README: bounds are exact as published, but a client parsing with ordinary double-precision floats can still round a value across the boundary. That is inherent to double parsing, not an exporter bug, so no code change. - Make the CHANGELOG/README looser-divergence counts match what spec/schema_conformance_spec.rb actually asserts (LOOSER has six keys, not seven): split the "rules only an extension can carry" bullet so each of the six maps to one bullet, and say plainly that an untranslatable format: regexp has no conformance case yet, since PR #66 owns the regexp-translation rework it would depend on. - Delete the stale "No behaviour changes: this release adds tests and documentation only" line — Unreleased now contains substantial behaviour changes, this bound fix included. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Owner
Author
|
Final regression pass, addressed in fc88567:
|
CHANGELOG only: keep master's entries ahead of this PR's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VSN2015
pushed a commit
that referenced
this pull request
Sep 25, 2026
Resolves CHANGELOG/README conflicts (kept both sides' entries; master's CHANGELOG entries first) and merges apply_in!'s numeric-bound-safe rework (#60) with this branch's opaque/in_published in: handling. Also reconciles an interaction the merge surfaced: assert_comparable_range! (added on this branch, to catch a wrong-TYPED Range like a String range on an :integer) treated a NaN endpoint the same way, since NaN's <=> always returns nil — which would have raised at class load for `in: Float::NAN..`, a Range #60's own new export logic explicitly allows to load (and simply omits from the schema, since it's never `finite?`). A NaN endpoint is not evidence of a wrong-typed bound, so it is left alone here exactly as an infinite endpoint already is. # Conflicts: # CHANGELOG.md # README.md # lib/permittable/json_schema.rb # spec/json_schema_spec.rb Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VSN2015
pushed a commit
that referenced
this pull request
Sep 25, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bugs
1. A decimal range bound came out as strings, which makes the schema invalid. Range bounds went through
json_value, the re-encoding used for an authoreddefault:/example:. That method renders a BigDecimal as its precision-safe string. The natural way to bound a price therefore published:{ "type": ["string", "number"], "format": "decimal", "minimum": "0.01", "maximum": "999.99" }The metaschema requires
minimum/maximumto be numbers, so the whole document failed validation.2. A bounded
:decimalsent as a string skipped its bounds, and this was not labelled.:decimalis typed["string", "number"], butminimum/maximumonly apply to numbers. So"5000"passed the docs, and the server answeredinclusion.3.
normalize:was not in the export. Withrequired :name, :string, length: 3..10, normalize: :squish:The fix
Float::INFINITYandBigDecimal("Infinity")mean "no bound", so no key is emitted. The same goes for NaN, which compares to nothing. Neither is a JSON number.normalize:is exported asx-permittable-normalize. The value is the preset's name ("squish","email", …), which a client can apply itself, ortruefor a custom proc. The contract stores only the resolved lambda, so the exporter gets the name back withNORMALIZERS.key(callable), a lookup by identity. Contract data is unchanged.JsonSchemaheader comment, and inspec/schema_conformance_spec.rb. The README now lists three safe cases and six looser ones.The decision I'd most like reviewed: rounding a bound that a double cannot hold
to_frounds to the nearest double, which can land on the wrong side of the bound. For example,BigDecimal("0.1000000000000000001").to_fis0.1. A client that sends0.1passesminimum: 0.1and the server then refuses it.How far is "wrong" depends on the field's type:
:decimalfield reads the number back asBigDecimal("0.1")and compares exactly;:floatfield compares the Float throughBigDecimal#<=>, which reads the Float at limited precision. So a:floatfield needs a few doubles more.So the exporter doesn't model either comparison. It asks the server. It takes the most extreme value the published keyword admits:
minimum/maximum;exclusiveMaximum;:integerfield, the ceiling or floor.While that value would fail the field's own cast and comparison, the bound moves one double inward (a minimum up, a maximum down). The published range is then only ever narrower than the enforced one, on every numeric type, and the loop stops at the first double the server accepts. (Bounds are exact as published; a client that reads the document back with ordinary double-precision floats, rather than the digits as sent, can still round a value across a boundary — that's inherent to parsing any JSON number as a double, not something the exporter controls.)
Details:
999.99, not999.9899999999999.10**400included — a JSON integer has no size limit), so it never goes through this nudge at all. A genuinely FRACTIONAL bound beyondFloat::MAXhas no such escape and is omitted, same as an infinite bound — see the "Round 2" section below for why this needed a separate fix.The conformance spec
The looser labels live in a
LOOSERtable. Each label names the keyword the diverging field's own schema must carry. The spec checks both the direction (server rejects, docs accept) and that the keyword is on that field, not anywhere in the document::extension_only:jsonvaluex-permittable-max-depth:range_extension"zebra"forin: "a".."m"x-permittable-range:custom_validationvalidate:proc refusesx-permittable-custom-validation:normalized_first" "," a "under squishx-permittable-normalize:format_annotation"abc"/"NaN"(:decimal),"2026-02-30"(:date),"not a time"(:datetime)format:string_decimal"5000"against..999.99maximum(as a number)The padded case (
" abcdefghij ") goes the safe way and has its own label,:normalized_encoding.CASESandLOOSERare now constants of aSchemaConformancemodule, instead of top-level constants defined insideRSpec.describe.I mutation-checked the per-field lookup: pointing
:custom_validationatx-permittable-rangefails the spec. The old anywhere-in-the-document check would have passed, because a sibling field in the same case carries that extension.TinyJsonSchema needed no change, and the "exporter emits no keyword the validator silently ignores" example still passes.
Also
UNTRANSLATABLE,apply_pattern!,ecma_pattern). Inscalar_schemaonly theapply_in!call changed, because it now passes the field type.format:regexp (x-permittable-pattern) is not one of the sixLOOSERcases the spec asserts — #66, reworking Ruby → ECMA-262patterntranslation, owns adding that case. The README and CHANGELOG now say so plainly instead of listing it alongside the six.Review follow-up
BigDecimal("Infinity")crashed the export (to_iraisedFloatDomainError), andFloat::INFINITYwas emitted asInfinity. Both are now omitted, and so is NaN. Specs:treats an infinite bound as no bound at all,omits a NaN bound.:floatfields. The nudge is now checked against the field's own cast and comparison, as described above. Specs cover both:decimaland:float: the published minimum and maximum are accepted, and the minimum is no further inward than it needs to be.format(:decimal,:date,:datetime),validate:procs, and non-numeric ranges are now labelled and direction-checked.:normalized_encoding.SchemaConformancemodule.master was merged in first, with master's CHANGELOG entries kept ahead of this PR's.
Round 2: final regression-pass fixes
A last regression pass compared every export against master's and found one real regression plus three documentation-accuracy issues. All fixed:
optional :x, :float, in: (10**400..)used to export nominimumat all — the inward-nudge loop asks the field's own cast whether the bound is "honoured" by routing it throughto_f, which overflows a value this large toInfinity, walks the bound toInfinity, and drops it. Master publishedminimum: 10**400outright. Fixed:json_boundnow returns an integral bound exactly, before the nudge loop runs, wheneverto_fwould overflow it — a JSON integer has no size limit, so this needs no Float conversion and none of the machinery that exists only to protect a fractional bound from double-rounding. A genuinely fractional bound pastFloat::MAX(e.g.BigDecimal("1e400") + BigDecimal("0.5")) has no arbitrary-precision JSON fallback and stays omitted — now a documented, deliberate choice rather than a silent one. New specs:publishes an integral bound beyond Float::MAX exactly, matching masterandomits a FRACTIONAL bound beyond Float::MAX, unlike an integral one.LOOSERhas six keys; the README's "Six cases go the other way" header was accurate, but its bullet list bundled:custom_validation,:range_extensionand the untranslatableformat:regexp into one bullet — five bullets naming seven rules, one of which (the regexp case) has no conformance case at all. Split into six one-to-one bullets, and the regexp case is now called out separately as not asserted by this spec, owned by Translate format: regexps into patterns valid under Ajv's u flag #66. Same fix applied to the parallel CHANGELOG bullet.Verification
json_schema_spec.rb(publishes an integral bound beyond Float::MAX exactly, matching master,omits a FRACTIONAL bound beyond Float::MAX, unlike an integral one), on top of the 30 from the original round (5 injson_schema_spec.rb, 25 conformance payloads). 673 examples in total, 0 failures.🤖 Generated with Claude Code