Skip to content

Test the claim that the exported schema cannot drift - #27

Open
VSN2015 wants to merge 1 commit into
masterfrom
test/schema-conformance
Open

Test the claim that the exported schema cannot drift#27
VSN2015 wants to merge 1 commit into
masterfrom
test/schema-conformance

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Tests and documentation only — no behaviour changes.

The untested claim

Because the schema is emitted from the same frozen data the server enforces, the docs cannot drift from the validation.

That's the gem's headline claim, and nothing tested it. Every existing spec checks one side: what the validator accepts, or what the exporter emits. None checked that the two agree.

The spec

spec/schema_conformance_spec.rb walks canonical JSON payloads through both a contract and its own exported schema and compares the verdicts — 53 examples across scalars and bounds, enums, exclusive and endless ranges, exact lengths, formats, arrays of scalars and of hashes, nested hashes under unknown: :error, rooted contracts, and a required string with no length: of its own.

spec/support/tiny_json_schema.rb is a deliberately small validator covering exactly the keywords the exporter emits and nothing else — a measuring instrument, not a dependency. One example asserts the exporter emits no keyword the validator silently ignores, so the two can't fall out of step as the exporter grows.

It was mutation-tested, not trusted for being green

Injected drift Spec
drop maxItems from arrays ✅ fails
drop additionalProperties: false ✅ fails
drop the required-string minLength: 1 ✅ fails
drop the root: required wrapper ✅ fails

The minLength mutation initially did not fail — a gap in my case table, not the design: that logic only bites on a required string with no length: of its own, and I had no such case. Adding one made the mutation fail as it should. Worth knowing that the table is the coverage, so extending it is how the guarantee grows.

What the sweep found

No drift in the code. It did find two places where the schema and the runtime legitimately differ that weren't written down anywhere:

  • Non-canonical encodings — coercion accepts "30" for an :integer and 1 for a :string, because form and query payloads are all strings. (The exporter's comment already mentioned this one.)
  • null as absence — the runtime reads {"age": null} as {}; JSON Schema cannot express that, so type: integer rejects a null the server would accept and ignore. This one was undocumented.

Both now appear in Permittable::JsonSchema's comment and in a new README subsection.

The direction is the point

Both divergences leave the schema stricter than the server, never looser — so a client validating against the published document is conservative, never surprised by a 422. The spec asserts that direction for every divergence it permits:

expect([runtime, documented]).to eq(%i[accept reject]),
  "#{expectation} is only allowed where the server accepts and the docs reject"

So a new divergence in the dangerous direction — docs promising something the server rejects — fails CI rather than shipping quietly.

Verification

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #27: Rigorous conformance testing. Validating emitted JSON Schemas against real runtime execution ensures contracts and exported documentation never drift.

#
# Anything the exporter does not emit is out of scope on purpose: this is a
# measuring instrument, not a general validator.
module TinyJsonSchema

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TinyJsonSchema provides a lightweight, dependency-free validator covering exactly the emitted keywords, keeping the test suite standalone.

"an exclusive range" => {
contract: proc { optional :pct, :integer, in: 0...100 },
payloads: [[{ "pct" => 0 }, :agree], [{ "pct" => 99 }, :agree], [{ "pct" => 100 }, :agree]]
},

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bidirectional assertion checking (expect(documented).to eq(runtime)) effectively proves that client requests valid against the emitted schema will not fail server validation.

"Because the schema is emitted from the same frozen data the server
enforces, the docs cannot drift from the validation" is the gem's
headline claim, and nothing tested it. Every existing spec checks one
side or the other: what the validator accepts, or what the exporter
emits. None checked that the two AGREE.

spec/schema_conformance_spec.rb walks canonical JSON payloads through
both a contract and its own exported schema and compares the verdicts —
scalars and bounds, enums, exclusive and endless ranges, exact lengths,
formats, arrays of scalars and of hashes, nested hashes under
unknown: :error, rooted contracts, and a required string with no
length: of its own.

spec/support/tiny_json_schema.rb is a deliberately small validator
covering exactly the keywords the exporter emits and nothing else: a
measuring instrument, not a dependency. One example asserts the
exporter emits no keyword the validator silently ignores, so the two
cannot fall out of step as the exporter grows.

The spec was mutation-tested rather than trusted for being green.
Dropping maxItems, additionalProperties: false, the required-string
minLength: 1, or the root: required wrapper each makes it fail. The
minLength mutation initially did NOT fail, which was a gap in the case
table rather than the design — that logic only bites on a required
string with no length: of its own, and there was no such case. Adding
one made the mutation fail as it should.

The sweep found no drift in the code, and two places where the schema
and the runtime legitimately differ that were not written down: the
runtime accepts non-canonical encodings ("30" for an :integer, 1 for a
:string), and it reads an explicit null as ABSENCE, which JSON Schema
cannot express. Both are now documented in JsonSchema and the README.

Both leave the schema STRICTER than the server, never looser — a client
validating against the published document is conservative, never
surprised by a 422 — and the spec asserts that direction for every
divergence it permits, so a new one in the dangerous direction fails
CI rather than shipping quietly.

Tests and documentation only; no behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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