Skip to content

fix: resolve self-root refs against their source file when bundling - #44

Open
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix-dangling-self-root-ref
Open

fix: resolve self-root refs against their source file when bundling#44
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix-dangling-self-root-ref

Conversation

@vishkaty

@vishkaty vishkaty commented Aug 4, 2026

Copy link
Copy Markdown

Fixes #43.

Observed

When the bundler inlines a referenced fragment, a $ref: "#" inside it is left as is; after bundling, the nearest enclosing $id belongs to the INLINING file, so the jsonschema crate resolves # to the wrong root (correctly, per 2020-12 embedded resource rules). In the 2026-04-08 UCP set this voids the payment_instrument base schema on every checkout payment path: an instrument with none of the required id, handler_id, type validates as {"valid": true}, a valid instrument with an extra property named instruments is rejected, and strict mode rejects every valid instrument. Details and reproduction in #43.

Change

# inside an inlined fragment now keeps meaning the root of the file it was written in:

  • inline_self_root_ref (local and remote loader paths, and the compose extraction path) removes the $ref, bundles any sibling values in the source file context first, then attaches the resolved source root: a direct splice when the site carries no siblings, or an allOf entry when it does, so sibling keywords compose by conjunction per 2020-12 rather than key merging.
  • When the source root itself cycles (contains # or refs back into the fragment), the fallback embeds the source file once as a $defs resource with its $id intact (content derived id when the file has none, so distinct files can never collide) and rewrites # to point at it.
  • bundle_preserves_self_root_ref now asserts the resolved SEMANTICS through validation, not just the preserved text; its previous form pinned the buggy binding.

Verification

  • New tests fail on unmodified main for the exact defects and pass with the change; the full suite is 326 passing (fmt, clippy with warnings denied, and release build clean). Coverage includes the local, remote (mockito) and compose paths, sibling conjunction, distinct synthesized ids, and cycle termination.
  • Bundling all 78 schema files of the 2026-04-08 set in both request and response directions: zero failures, and the output differs from main in exactly the 7 files whose payment subtree contained the dangling # — nothing else changes.
  • The issue's probe pair now agrees with an independent Python jsonschema referee on both CLI paths (explicit --schema and the self describing base path): the false accept rejects with the three required property errors, the false reject and the strict mode rejections accept.
  • One evidence note for transparency: the compose path leak (a discarded extraction attempt leaving a dangling synthesized ref) is not reachable end to end through the CLI on current main because loader bundling resolves # before compose extraction sees one; it is pinned by a unit test at the function level, which is where the contract lives and where a future pipeline reordering would resurrect it.

Happy to split anything out or adjust the approach if you prefer one of the other directions sketched in #43.

Observed: when the bundler inlines a schema fragment extracted from
another file, a `$ref: "#"` inside that fragment was left as-is
(loader.rs local and remote paths; compose.rs inline_internal_refs
likewise handled only `#/$defs/...`). After bundling, `#` resolves
against the nearest enclosing $id, which belongs to the INLINING file,
so the fragment validates against the wrong schema root. In the
2026-04-08 set, checkout payment.instruments[*] (payment.json ->
types/payment_instrument.json#/$defs/selected_payment_instrument, whose
allOf[0] is {"$ref": "#"}) binds to payment.json instead of
payment_instrument.json: instruments missing every required field
(id, handler_id, type) pass, a valid instrument carrying an extra
property named "instruments" is rejected, and strict mode rejects every
valid instrument.

Expected: `#` inside an inlined fragment keeps meaning the root of the
file it was written in.

Fix: when inlined content is extracted from an external file, resolve
`$ref: "#"` by inlining a copy of that file's root schema at the site
($defs/$id/$schema stripped; internal refs still resolve against the
source file as before). Sibling keywords at the `#` site are bundled
first — they are subschemas written in the same source file — and,
because 2020-12 applies `$ref` in conjunction with its siblings, the
inlined root joins them through `allOf` rather than a key-wise merge
that would drop colliding keys; a sibling-free site is spliced
directly. If the inline cannot terminate — the source root itself
contains `$ref: "#"`, or resolving it cycles back into the fragment —
fall back to embedding the source file once as an $id'd $defs resource
and rewriting the ref to that id, which 2020-12 embedded-resource
rules resolve correctly (the rewritten `$ref` next to the remaining
siblings is native conjunction). Synthesized ids for $id-less files
are content-derived, so two different files that happen to share a
relative ref path cannot collide. Whole-file inlines already keep the
file's $id, which is exactly what `#` binds to; if such a file has no
$id a content-derived one is synthesized so `#` cannot escape to the
inlining document root. Applied to both loader paths (local and
remote) and to compose-time $defs extraction, where the inline attempt
is committed unconditionally — a discarded attempt would leak the
nested fallback's bookkeeping and emit a `$ref` to a resource that was
never materialized.

bundle_preserves_self_root_ref previously asserted only that the "#"
text survived bundling; in its own fixture that surviving "#" bound to
the outer document root, i.e. the assertion pinned the buggy behavior.
It now also validates payloads through the bundled schema, so it fails
whenever "#" stops resolving to its source file's root, while the
original textual assertion is kept.

Bundled output for the 2026-04-08 schema set is unchanged except the
selected_payment_instrument allOf branch, which now carries the inlined
payment_instrument.json root instead of a dangling "#".

Fixes Universal-Commerce-Protocol#43
@damaz91 damaz91 added status:needs-triage Signal that the PR is ready for human triage status:under-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 4, 2026
@damaz91

damaz91 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This LGTM, but I'm not super familiar on how the ucp-schema tool works. @igrigorik do you mind taking a look (or re-assigning to someone with more familiarity with this repo)?

@vishkaty

vishkaty commented Aug 5, 2026

Copy link
Copy Markdown
Author

Thanks @damaz91! Happy to give the short version, since the tool internals are the awkward part here.

What the bundler does: to validate a payload it inlines the referenced schema files into one document. The bug was that a $ref: "#" (meaning "this file's own root") kept pointing at the inlining file after it was moved, instead of the file it was written in. In the 2026-04-08 set that one # lives in payment_instrument.json, so after bundling it silently resolved to payment.json and the payment-instrument constraints were never applied — an instrument missing id/handler_id/type validated as clean.

Why it is safe to take even without deep tool familiarity, in decreasing order of how much it should reassure you:

  • The change touches nothing that was correct: the 78-file bundle output is byte-identical to before except the 7 files whose payment subtree carried that # — I diffed every bundle before/after and that is the entire footprint.
  • Every fix is pinned by a test that fails on the old code and passes on the new (and I kill-tested each by reverting the fix and watching the test go red).
  • The verdicts are cross-checked against an independent Python jsonschema implementation on the same schemas — the two engines now agree on the probe cases where they previously disagreed. So the "correct" behavior here is not just my assertion; a second, unrelated validator says the same thing.

If it is easier, I am glad to hop on the schema-tool basics or walk through any single hunk — no rush on your end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bundler leaves $ref "#" dangling when inlining, so checkout payment.instruments validate against the wrong schema root (false accepts and false rejects)

2 participants