Skip to content

fix: stop rewriting a required that is not an array of strings - #78

Open
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/preserve-malformed-required
Open

fix: stop rewriting a required that is not an array of strings#78
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/preserve-malformed-required

Conversation

@vishkaty

Copy link
Copy Markdown

Observed

A schema whose required is a string rather than an array resolves to one whose
required is empty, and validation of an instance missing that field then
succeeds:

$ cat bad.json
{ "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.invalid/bad.json",
  "type": "object",
  "properties": { "name": { "type": "string" } },
  "required": "name" }

$ ucp-schema resolve bad.json --op create --request | jq .required
[]

$ ucp-schema validate empty.json --schema bad.json --request --op create --json
{"valid":true}          # exit 0

The pinned jsonschema crate would have refused that schema: validator_for
returns "name" is not of type "array". The constraint is removed before it
gets there.

Root cause

resolve_object seeds its required list with

map.get("required").and_then(|v| v.as_array())..unwrap_or_default()

so a required of any other shape yields an empty list, and the emit branch
writes "required": [] back out because the key was present. The authored
value is discarded with no diagnostic.

Change

required is rewritten only when it is well formed, meaning an array of
strings. Anything else is passed through untouched, so the validator sees the
authored value and can reject the schema. An element that is not a string is
treated the same way, since a partial rewrite would drop it just as quietly.

A second thing this fixes

OpenAPI and OpenRPC parameter objects carry a boolean required. Those
documents are resolved too, and the flag was being rewritten to [], which
means nothing at that position. For example
schemas/shopping/fulfillment.json authors

{ "name": "checkout", "required": true, "schema": { "$ref": "checkout.json" } }

and resolution turned that into "required": []. It now survives.

Testing

Three resolver cases and one CLI case, each failing before this change and
passing after. Full suite 358 passed, from 354. cargo fmt --check and
cargo clippy --all-targets are clean.

After the change, validate on the schema above reports
invalid schema: "name" is not of type "array" and exits 2, while a well
formed schema with a genuinely missing field still exits 1. The two failure
modes stay distinguishable.

Blast radius, measured

Resolving every schema and service definition in the specification at
release/2026-08-25, in both directions, is 246 comparisons. 16 outputs
change and none errors.
Every change restores an authored value: six service
definitions and schemas/shopping/fulfillment.json, all of them the boolean
required described above. No schema in that tree has a malformed required
keyword, so nothing else moves.

The specification CI gates are unchanged, checked against a control binary:

gate baseline with this change
ucp-schema lint source/ 124 files, all passed, 13 warnings identical
scripts/validate_examples.py 343 passed, 0 failed, 50 skipped identical

Related, not addressed here

lint does not validate a schema document against the dialect its own
$schema declares, which is why the malformed schema above passes linting in
the first place. That is a separate change and I have not bundled it. Happy to
follow up if it is wanted.

Four open PRs touch resolver.rs (#48, #58, #62, #77). None of them touches the
required seeding or emit logic, so there is no overlap in substance, though
#77 and this may need a trivial rebase against each other.

`resolve_object` seeded its required list with

    map.get("required").and_then(|v| v.as_array()).….unwrap_or_default()

so any `required` that is not an array produced an empty list, and the emit
branch below then wrote `"required": []` back out because the key had been
present. The authored value was discarded silently.

Two consequences.

A schema whose `required` is a string resolved to one whose `required` is
empty, so the constraint disappeared. `validate` then reported an instance
missing that field as valid and exited 0, where the pinned jsonschema crate
would have refused the schema outright. With the authored value preserved the
validator sees it, reports `invalid schema`, and the run exits 2.

OpenAPI and OpenRPC parameter objects carry a boolean `required`. Those
documents are resolved too, and the flag was being rewritten to `[]`, which
means nothing at that position. It now survives. This accounts for the
resolved output changing on six service definitions and on
schemas/shopping/fulfillment.json, whose embedded method parameters carry the
same boolean. Every one of those changes restores the authored value.

An element that is not a string is treated the same way, since a partial
rewrite would drop it just as quietly.

Testing. Three resolver cases and one CLI case, each failing before this change
and passing after. Full suite 358 passed, from 354 before. cargo fmt and clippy
clean. Resolving every schema and service definition in the specification at
release/2026-08-25 in both directions, 246 comparisons, changes 16 outputs and
errors on none. The specification CI gates are unchanged against a control:
`ucp-schema lint source/` 124 files all passed, and validate_examples 343
passed 0 failed 50 skipped, identical with and without this change.
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:needs-triage Signal that the PR is ready for human triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants