From 1da5a174a5a74b7f96ce993b2e78c55de0bc452f Mon Sep 17 00:00:00 2001 From: chaz8081 <123976510+chaz8081@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:05:42 -0400 Subject: [PATCH] fix: resolve entity local refs before inlining (python-sdk d650f0b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports python-sdk PR #79, which fixes python-sdk#72 — the dangling-$ref defect this SDK reported. flatten_entity_reference deep-copied ucp.json#/$defs/entity into capability.json, payment_handler.json and service.json without resolving the entity's own document-relative refs, leaving 24 refs to "#/$defs/version" pointing at nothing. Upstream resolves the entity's local refs once, at extraction, while the body still sits in ucp.json; every copy made afterwards is self-contained and flatten_entity_reference is untouched. preprocess.ResolveLocalRefs is that port, called from Preprocess at the same point. Three behaviours are faithful to python rather than to Go taste, because goldens are byte-compared against its output: sibling keys override the resolved target, an unresolvable ref is left alone rather than erroring, and the walk continues with the caller's seen set. All three are documented at the code. ResolveRef's fallback, which resolved these refs against ucp.json and is why our emitted models were never affected, is deleted: the corpus now contains no unresolvable local ref. TestResolveRefDoesNotRescueDangling- LocalRefs pins the stricter rule in its place. Generated models change for the three affected packages: the inlined schema no longer routes through ucp.json's $defs, so Version UCPVersion becomes Version string with the pattern checked in place. Same validation strength, but a breaking change for callers naming the type. The oracle can now compile the 71 targets it previously could not, which roughly tripled differential coverage — 693 payloads across 157 types to 1,024 across 228 — and the first wider run found a real gap. Fields the emitter carries as json.RawMessage are invisible to Validate, so a payload invalid only inside one is accepted. Reported and counted every run rather than absorbed, with attribution proved from the oracle's own error locations: every leaf must fall inside a raw field or the payload stays a mismatch. Two properties are affected — error_response's ucp, to break an import cycle, and capability's extends, which has no single Go shape. Fixing that gap is a separate design decision. --- README.md | 97 ++++++++++------- capability.go | 34 +++--- cmd/ucpgen/emit/resolve.go | 19 ---- cmd/ucpgen/emit/resolve_test.go | 27 +++-- cmd/ucpgen/preprocess/pipeline.go | 6 ++ cmd/ucpgen/preprocess/refs.go | 62 +++++++++++ cmd/ucpgen/preprocess/refs_test.go | 78 ++++++++++++++ conformance/differential_test.go | 108 +++++++++++++++++++ conformance/rawgap_test.go | 136 ++++++++++++++++++++++++ goldens/2026-04-08.provenance.txt | 4 +- goldens/2026-04-08/capability.json | 20 ++-- goldens/2026-04-08/payment_handler.json | 20 ++-- goldens/2026-04-08/service.json | 80 ++++++++------ payment_handler.go | 42 +++++--- service.go | 34 +++--- 15 files changed, 608 insertions(+), 159 deletions(-) create mode 100644 conformance/rawgap_test.go diff --git a/README.md b/README.md index 2fcc656..3c5a4f9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ an independent implementation is the only thing that catches it. **2. Differential agreement.** The same JSON bytes are driven through the generated models' `Validate` and through a real draft-2020-12 validator (`santhosh-tekuri/jsonschema/v6`), and the two must reach the same verdict: -**693 payloads across 157 generated types (128 of them schema-file roots), +**1,024 payloads across 228 generated types (137 of them schema-file roots), zero disagreements.** This layer catches wrong *enforcement*. Golden tests prove the emitter is @@ -140,12 +140,26 @@ widening redefinition would surface here as a disagreement. Nothing is suppressed to reach zero. There is no skip list of known-failing payloads, and a disagreement fails the suite. -Figures below the headline are equally literal. 71 targets cannot be -compiled by the oracle at all: `capability.json`, `payment_handler.json` and -`service.json` each `$ref` a `#/$defs/version` that no file defines, which -is inherited from the upstream preprocessor rather than introduced here -(python-sdk#72). They are reported as skips, not folded into the exercised -count. +Figures below the headline are equally literal. 3 targets are skipped, for a +union alongside sibling `properties` that the harness does not model; they +are reported as skips, not folded into the exercised count. + +This number used to be 71. Every one of those was a schema the oracle could +not compile because of the dangling `#/$defs/version` references described +below, now fixed upstream. Unblocking them roughly tripled what the harness +actually compares — from 693 payloads across 157 types to 1,024 across +228 — and the very first run of the wider corpus found a real gap, described +next. The coverage figure had looked healthy the whole time. + +One payload disagrees and is reported rather than counted as agreement: +`shopping/types/error_response.json`'s `ucp` property is carried as +`json.RawMessage`, so `Validate` cannot see inside it and cannot reject a +malformed value there. The harness proves that attribution instead of +assuming it — every leaf of the oracle's rejection must fall inside a raw +field, or the payload stays a mismatch — and prints the count every run +(`TestRawFieldExplainsRejection` pins both directions). Two properties are +carried this way: this one, to break an import cycle, and capability's +`extends`, whose schema has no single Go shape. The oracle compiles `pattern` with **ECMA-262** semantics, via `dlclark/regexp2`, rather than the RE2 that Go's `regexp` and therefore the @@ -267,38 +281,41 @@ Keywords that would change a schema's *shape* rather than merely constrain it — currently `patternProperties` — fail generation outright, because no correct Go type can be produced for them. -### Known upstream limitation - -The official preprocessor produces schemas with dangling references, and -this SDK reproduces that behaviour deliberately. - -`preprocess_schemas.py:245` (`flatten_entity_reference` in python-sdk) -deep-copies `ucp.json#/$defs/entity` into `capability.json`, -`payment_handler.json` and `service.json` without rebasing the entity's -document-relative `$ref`s. The entity body contains -`"version": {"$ref": "#/$defs/version"}`; once copied, that pointer resolves -against its new host, which defines no such `$def`. The result is 24 -dangling references, and 9 of 145 schemas that no conforming JSON Schema -validator can compile — the three hosts themselves plus everything -transitively referencing them, including `ucp.json`. - -The spec's source schemas are correct — the defect is introduced by -preprocessing. - -`ucp-go` mirrors it on purpose: `cmd/ucpgen/preprocess/document.go`'s -`flattenEntityRef` is a faithful port, and byte-for-byte parity with the -Python preprocessor is an enforced invariant (`TestPreprocessMatchesGoldens`). -Diverging unilaterally would break the parity that makes the committed -goldens trustworthy. The emitted models are unaffected: `ResolveRef` carries -a narrow, documented fallback that resolves these references against -`ucp.json`, which is where they were written. - -The conformance harness skips the affected schemas by name and counts them, -rather than passing over them silently. Its tally reports all nine. It used -to report four: the other five were skipped a step earlier for conditional -keywords, and only became visible once phase 6 implemented those — a small -instance of the pattern this repository keeps running into, where one gap -hides another and the count looks healthier than the coverage is. +### Resolved upstream: dangling entity references + +Reported as [python-sdk#72](https://github.com/Universal-Commerce-Protocol/python-sdk/issues/72) and **fixed** in python-sdk `d650f0b` ([PR #79](https://github.com/Universal-Commerce-Protocol/python-sdk/pull/79)). Recorded because the mechanism generalizes. + +`flatten_entity_reference` deep-copied `ucp.json#/$defs/entity` into +`capability.json`, `payment_handler.json` and `service.json`. The entity +body contains `"version": {"$ref": "#/$defs/version"}` — a *document-relative* +pointer. Copied into a host that defines no such `$def`, it resolved to +nothing: 24 dangling references, and 9 of 145 schemas that no conforming +validator could compile. + +The spec's source schemas were correct. The defect was introduced by +preprocessing, one layer above where it showed. + +A dangling `$ref` does not fail loudly — a generator types the field as +`Any`. python-sdk's released package therefore accepted any value for +`version` on every model derived from the entity, where the spec requires +`^\d{4}-\d{2}-\d{2}$`. The visible symptom was not a crash but a check +that had silently stopped happening. + +The fix resolves the entity's own local references **once, at extraction, +while it still sits in `ucp.json`**, so every copy made afterwards is +self-contained. `flatten_entity_reference` itself never changed. Rebasing +the pointer to `ucp.json#/$defs/version` instead — the obvious repair — +closes a cycle, because `ucp.json` already references into all three hosts; +`datamodel-codegen` responds by collapsing the package into one private +module and renaming every colliding class. + +`ucp-go` ports the fix as `preprocess.ResolveLocalRefs`, called from +`Preprocess` at the same point. `ResolveRef` previously carried a narrow +fallback that resolved these references against `ucp.json`, which is why the +emitted models were never affected; the corpus now contains no unresolvable +local reference at all, so that fallback is deleted rather than left to rot. +`TestResolveRefDoesNotRescueDanglingLocalRefs` pins the stricter rule: a +local `$ref` resolves in its own document or fails. ### Resolved upstream: the `ucp` metadata union @@ -312,7 +329,7 @@ Upstream now synthesizes the union with `anyOf`, which is what it always meant: **The emitter still guards the general case.** When a `oneOf`'s members are structurally identical, it stops enforcing exclusivity for that union and says so in the generated doc comment. Nothing in the current corpus trips it; `TestUnsatisfiableOneOfDegradesToAnyOf` keeps it honest. -**How it was found, which is the part worth keeping.** Not by the differential harness — that could not have caught it. `ucp.json` is among the schemas the oracle cannot compile, for the dangling-reference reason above, so it is skipped before any verdict is compared. It surfaced when the example in this README was run and printed an error instead of a result. Pydantic's `Union` resolves to the first matching member rather than enforcing `oneOf`, which is why the Python SDK never saw it. +**How it was found, which is the part worth keeping.** Not by the differential harness — at the time it could not have caught it. `ucp.json` was among the schemas the oracle could not compile, for the dangling-reference reason above, so it was skipped before any verdict was compared. One upstream defect hid the other from the tool built to find it. It surfaced when the example in this README was run and printed an error instead of a result. Pydantic's `Union` resolves to the first matching member rather than enforcing `oneOf`, which is why the Python SDK never saw it. ## Regenerating diff --git a/capability.go b/capability.go index b37d611..8c9118e 100644 --- a/capability.go +++ b/capability.go @@ -6,6 +6,8 @@ package ucp import ( "encoding/json" "errors" + "regexp" + "sync" ) // CapabilityBase is generated from capability.json. @@ -25,7 +27,7 @@ type CapabilityBase struct { // Not enforced yet (phase 4): format. Spec *string `json:"spec,omitzero"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -99,6 +101,8 @@ func (v CapabilityBase) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_CapabilityBase_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *CapabilityBase) Validate() error { if v.present != nil { @@ -106,8 +110,8 @@ func (v *CapabilityBase) Validate() error { return errors.New("version: required property is missing") } } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_CapabilityBase_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } @@ -129,7 +133,7 @@ type CapabilityBusinessSchema struct { // Not enforced yet (phase 4): format. Spec *string `json:"spec,omitzero"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -203,6 +207,8 @@ func (v CapabilityBusinessSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_CapabilityBusinessSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *CapabilityBusinessSchema) Validate() error { if v.present != nil { @@ -210,8 +216,8 @@ func (v *CapabilityBusinessSchema) Validate() error { return errors.New("version: required property is missing") } } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_CapabilityBusinessSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } @@ -233,7 +239,7 @@ type CapabilityPlatformSchema struct { // Not enforced yet (phase 4): format. Spec string `json:"spec"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -307,6 +313,8 @@ func (v CapabilityPlatformSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_CapabilityPlatformSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *CapabilityPlatformSchema) Validate() error { if v.present != nil { @@ -320,8 +328,8 @@ func (v *CapabilityPlatformSchema) Validate() error { return errors.New("version: required property is missing") } } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_CapabilityPlatformSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } @@ -343,7 +351,7 @@ type CapabilityResponseSchema struct { // Not enforced yet (phase 4): format. Spec *string `json:"spec,omitzero"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -417,6 +425,8 @@ func (v CapabilityResponseSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_CapabilityResponseSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *CapabilityResponseSchema) Validate() error { if v.present != nil { @@ -424,8 +434,8 @@ func (v *CapabilityResponseSchema) Validate() error { return errors.New("version: required property is missing") } } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_CapabilityResponseSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } diff --git a/cmd/ucpgen/emit/resolve.go b/cmd/ucpgen/emit/resolve.go index 4e99c96..e7ac6ea 100644 --- a/cmd/ucpgen/emit/resolve.go +++ b/cmd/ucpgen/emit/resolve.go @@ -8,9 +8,6 @@ import ( const defsFragmentPrefix = "/$defs/" -// ucpRootSchema is the document that owns the shared entity definition. -const ucpRootSchema = "ucp.json" - // ResolveRef maps a $ref appearing in schema `from` to the Go type it // denotes. Three forms occur in the normalized spec: a bare cross-file path // ("types/line_item.json"), a cross-file path with a $defs fragment @@ -42,22 +39,6 @@ func ResolveRef(idx *TypeIndex, from, ref string) (TypeRef, error) { } got, ok := idx.Lookup(target, def) - - // A purely local ref that does not resolve in its own document is an - // entity-inlining artifact: flattening ucp.json#/$defs/entity into a - // schema copies the entity's body, including refs it wrote relative to - // ucp.json, which then dangle in their new home. Across the whole - // corpus this is exactly `#/$defs/version` in capability.json, - // payment_handler.json and service.json, all resolvable in ucp.json. - // The python generator resolves them the same way. The fallback is - // deliberately narrow: local refs only, and only after the in-document - // lookup has already failed. - if !ok && filePart == "" && def != "" && target != ucpRootSchema { - if fromUCP, okUCP := idx.Lookup(ucpRootSchema, def); okUCP { - return fromUCP, nil - } - } - if !ok { where := target if def != "" { diff --git a/cmd/ucpgen/emit/resolve_test.go b/cmd/ucpgen/emit/resolve_test.go index 66748d1..dcb1509 100644 --- a/cmd/ucpgen/emit/resolve_test.go +++ b/cmd/ucpgen/emit/resolve_test.go @@ -59,10 +59,17 @@ func TestResolveRefUnknownTarget(t *testing.T) { } } -func TestResolveRefEntityInliningFallback(t *testing.T) { - // Inlining ucp.json#/$defs/entity copies refs the entity wrote relative - // to ucp.json, so they dangle in the destination document. Corpus-wide - // this is exactly "#/$defs/version" in three files. +func TestResolveRefDoesNotRescueDanglingLocalRefs(t *testing.T) { + // python-sdk#72: inlining ucp.json#/$defs/entity used to copy refs the + // entity had written relative to ucp.json, leaving 24 of them dangling + // in capability.json, payment_handler.json and service.json. We carried + // a narrow fallback that resolved those against ucp.json. + // + // python-sdk d650f0b (PR #79) resolves the entity's own local refs + // before inlining it, so the corpus no longer contains a single + // unresolvable local ref and the fallback is gone. This pins that: a + // local ref must resolve in its OWN document or fail. Silently reaching + // into ucp.json would resolve a name the document never declared. idx, err := BuildTypeIndex(map[string]map[string]any{ "ucp.json": { "title": "UCP Metadata", @@ -76,15 +83,15 @@ func TestResolveRefEntityInliningFallback(t *testing.T) { if err != nil { t.Fatal(err) } - got, err := ResolveRef(idx, "capability.json", "#/$defs/version") + if _, err := ResolveRef(idx, "capability.json", "#/$defs/version"); err == nil { + t.Error("a local ref absent from its own document must error, not resolve against ucp.json") + } + // The same name still resolves in the document that actually declares it. + got, err := ResolveRef(idx, "ucp.json", "#/$defs/version") if err != nil { - t.Fatalf("dangling local ref should fall back to ucp.json: %v", err) + t.Fatalf("ucp.json declares version: %v", err) } if got.Name != "UCPVersion" { t.Errorf("resolved to %q, want UCPVersion", got.Name) } - // The fallback must not mask a genuinely unknown name. - if _, err := ResolveRef(idx, "capability.json", "#/$defs/nonexistent"); err == nil { - t.Error("a name absent from both documents must still error") - } } diff --git a/cmd/ucpgen/preprocess/pipeline.go b/cmd/ucpgen/preprocess/pipeline.go index 6a9929a..e0ac26d 100644 --- a/cmd/ucpgen/preprocess/pipeline.go +++ b/cmd/ucpgen/preprocess/pipeline.go @@ -31,6 +31,12 @@ func Preprocess(set *SchemaSet) error { if len(entityDef) == 0 { return fmt.Errorf("entity definition not found: ucp.json must define $defs.entity") } + // Resolve the entity's own same-document refs once, here, while it is + // still in ucp.json and they still mean what they say. Every copy made + // below is then self-contained. Deep-copied first so ucp.json's own + // $defs.entity keeps its refs (preprocess_schemas.py, python-sdk#72). + entityDef = CopyTree(entityDef).(map[string]any) + ResolveLocalRefs(entityDef, ucp, nil) renames := map[string]map[string]string{} for _, rel := range set.Paths() { diff --git a/cmd/ucpgen/preprocess/refs.go b/cmd/ucpgen/preprocess/refs.go index c0ac224..9f8e18f 100644 --- a/cmd/ucpgen/preprocess/refs.go +++ b/cmd/ucpgen/preprocess/refs.go @@ -53,3 +53,65 @@ func ResolveLocalRef(ref string, root map[string]any) (map[string]any, error) { } return obj, nil } + +// ResolveLocalRefs recursively inlines same-document ("#/…") $refs inside +// fragment, in place, resolving each against root. It is the port of +// python-sdk's resolve_local_refs (d650f0b, PR #79, fixing python-sdk#72). +// +// It exists because entity inlining copies a definition's body into other +// documents. Any "#/…" ref inside that body is resolved against whatever +// document it currently sits in, so copying it silently re-points it — +// upstream left 24 refs to "#/$defs/version" dangling in capability.json, +// payment_handler.json and service.json. Resolving the body's own refs +// once, while it still sits in ucp.json, makes it self-contained and safe +// to copy anywhere. +// +// Three details are faithful to python rather than to Go taste, because +// goldens are byte-compared against that implementation's output: +// +// - Keys alongside the $ref override the resolved target's keys, so a +// local "description" survives inlining. +// - An unresolvable ref is left untouched, not an error. python's +// resolve_local_ref returns None there and the caller skips it. +// - After substitution the walk continues into the new contents with the +// CALLER's seen set, not the extended one. That is python's control +// flow. It means a ref blocked as cyclic on the way down can be +// resolved again on the way out, so a genuinely cyclic local ref would +// recurse without bound — in python too. The entity body has no cycles, +// and a cycle would hang the upstream preprocessor first, so mirroring +// the behaviour keeps parity rather than quietly diverging from it. +func ResolveLocalRefs(fragment any, root map[string]any, seen map[string]bool) { + switch t := fragment.(type) { + case map[string]any: + if ref, ok := t["$ref"].(string); ok && strings.HasPrefix(ref, "#/") && !seen[ref] { + // ErrRefNotObject is skipped along with ErrRefNotFound: python + // would deep-copy the non-object and then fail assigning into + // it, so no corpus can rely on that path succeeding. + if target, err := ResolveLocalRef(ref, root); err == nil { + resolved := CopyTree(target).(map[string]any) + next := make(map[string]bool, len(seen)+1) + for k := range seen { + next[k] = true + } + next[ref] = true + ResolveLocalRefs(resolved, root, next) + for k, v := range t { + if k != "$ref" { + resolved[k] = v + } + } + clear(t) + for k, v := range resolved { + t[k] = v + } + } + } + for _, v := range t { + ResolveLocalRefs(v, root, seen) + } + case []any: + for _, item := range t { + ResolveLocalRefs(item, root, seen) + } + } +} diff --git a/cmd/ucpgen/preprocess/refs_test.go b/cmd/ucpgen/preprocess/refs_test.go index 68789e6..2f05949 100644 --- a/cmd/ucpgen/preprocess/refs_test.go +++ b/cmd/ucpgen/preprocess/refs_test.go @@ -42,3 +42,81 @@ func TestResolveLocalRefErrorClasses(t *testing.T) { t.Errorf("terminal non-object target: err = %v, want ErrRefNotObject", err) } } + +func TestResolveLocalRefsInlinesEntityBody(t *testing.T) { + // The real case (python-sdk#72): the entity body carries a pointer that + // only means anything inside ucp.json, and gets copied into documents + // that define no such $def. Resolving it here makes the body portable. + root := map[string]any{ + "$defs": map[string]any{ + "version": map[string]any{ + "type": "string", + "pattern": `^\d{4}-\d{2}-\d{2}$`, + }, + "entity": map[string]any{ + "type": "object", + "properties": map[string]any{ + "version": map[string]any{ + "$ref": "#/$defs/version", + "description": "Entity version in YYYY-MM-DD format.", + }, + }, + }, + }, + } + entity := CopyTree(root["$defs"].(map[string]any)["entity"]).(map[string]any) + ResolveLocalRefs(entity, root, nil) + + got := entity["properties"].(map[string]any)["version"].(map[string]any) + if _, still := got["$ref"]; still { + t.Fatalf("$ref should be gone after inlining, got %v", got) + } + if got["pattern"] != `^\d{4}-\d{2}-\d{2}$` || got["type"] != "string" { + t.Errorf("target body not inlined: %v", got) + } + // Keys written alongside the $ref win over the target's, so a local + // description is not lost to the shared definition's. + if got["description"] != "Entity version in YYYY-MM-DD format." { + t.Errorf("sibling key lost: %v", got["description"]) + } + // The source document keeps its own $ref: only the copy is flattened. + src := root["$defs"].(map[string]any)["entity"].(map[string]any) + srcVer := src["properties"].(map[string]any)["version"].(map[string]any) + if srcVer["$ref"] != "#/$defs/version" { + t.Errorf("ucp.json's own entity was mutated: %v", srcVer) + } +} + +func TestResolveLocalRefsLeavesUnresolvableRefsAlone(t *testing.T) { + // python's resolve_local_ref returns None and the caller skips. An + // external ref is not ours to resolve at this stage, and a missing + // local one is left for the later cross-file pass to report. + frag := map[string]any{ + "a": map[string]any{"$ref": "other.json#/$defs/thing"}, + "b": map[string]any{"$ref": "#/$defs/absent"}, + } + ResolveLocalRefs(frag, map[string]any{"$defs": map[string]any{}}, nil) + + if got := frag["a"].(map[string]any)["$ref"]; got != "other.json#/$defs/thing" { + t.Errorf("external ref was touched: %v", got) + } + if got := frag["b"].(map[string]any)["$ref"]; got != "#/$defs/absent" { + t.Errorf("unresolvable local ref was touched: %v", got) + } +} + +func TestResolveLocalRefsWalksArrays(t *testing.T) { + root := map[string]any{ + "$defs": map[string]any{"money": map[string]any{"type": "number"}}, + } + frag := map[string]any{ + "allOf": []any{ + map[string]any{"$ref": "#/$defs/money"}, + }, + } + ResolveLocalRefs(frag, root, nil) + got := frag["allOf"].([]any)[0].(map[string]any) + if got["type"] != "number" { + t.Errorf("ref inside an array was not resolved: %v", got) + } +} diff --git a/conformance/differential_test.go b/conformance/differential_test.go index 2d9c1a8..fe178ef 100644 --- a/conformance/differential_test.go +++ b/conformance/differential_test.go @@ -2,12 +2,16 @@ package conformance import ( "encoding/json" + "errors" "fmt" "path/filepath" + "reflect" "sort" "strings" "testing" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/chaz8081/ucp-go/cmd/ucpgen/emit" "github.com/chaz8081/ucp-go/cmd/ucpgen/preprocess" "github.com/chaz8081/ucp-go/shopping/types" @@ -172,6 +176,7 @@ func TestDifferentialAgreement(t *testing.T) { // tallied as a skip, and counting it here as well would let the same // target be reported as both exercised and skipped. var mismatches []string + rawGaps := map[string]int{} total, comparedTypes, comparedFiles := 0, 0, 0 for _, tg := range targets { compiled, err := oracle.Compile(tg.oracleID) @@ -218,6 +223,22 @@ func TestDifferentialAgreement(t *testing.T) { } sdkOK := sdkErr == nil if oracleOK != sdkOK { + // A field the emitter had to carry as json.RawMessage is + // opaque to Validate, so the SDK cannot reject a payload + // that is invalid only inside such a field. That is a real + // gap, but a known one with a named cause, and it must not + // be confused with a wrong or missing check. + // + // Attribution is proved rather than assumed: drop the raw + // fields from the payload and ask the oracle again. Only if + // it then accepts was the raw field the whole reason for + // the disagreement. Anything else stays a mismatch. + if !oracleOK && sdkOK { + if f, ok := rawFieldExplainsRejection(compiled, v, inst); ok { + rawGaps[tg.location+"."+f]++ + continue + } + } why := "accepted it" if sdkErr != nil { why = sdkErr.Error() @@ -237,6 +258,12 @@ func TestDifferentialAgreement(t *testing.T) { for _, reason := range sortedKeys(skipped) { t.Logf("skipped %3d targets: %s", skipped[reason], reason) } + // Reported every run, never silently absorbed: these are payloads the + // oracle rejects and the SDK cannot, because the offending field is + // carried as raw JSON. The count is the size of the gap. + for _, loc := range sortedKeys(rawGaps) { + t.Logf("unvalidated %3d payloads: %s is raw JSON, so its contents are never checked", rawGaps[loc], loc) + } if len(mismatches) > 0 { sort.Strings(mismatches) shown := mismatches @@ -373,3 +400,84 @@ func FuzzReverseDomainNameAgreement(f *testing.F) { } }) } + +// rawFieldExplainsRejection reports whether every reason the oracle +// rejected inst lies inside a field that model carries as json.RawMessage. +// +// The emitter falls back to raw JSON in two situations: a property whose +// type would create an import cycle (shopping/types.ErrorResponseBase.UCP +// would have to name the root package, which already imports this one), and +// a property whose schema has no single Go shape (capability's `extends` is +// a string or an array of strings). In both cases Validate has nothing to +// look at, so a payload invalid only inside that field is accepted. +// +// Attribution is read off the oracle's own error locations rather than +// guessed. Deleting the field and revalidating does not work: these +// properties are required, so removing one trades the real complaint for a +// missing-property complaint and every disagreement would look explained. +// Instead every leaf cause must point inside a raw field. A single leaf +// anywhere else means the SDK missed something it could have caught, and +// the payload stays a mismatch. +func rawFieldExplainsRejection(compiled *jsonschema.Schema, model any, inst any) (string, bool) { + raw := rawJSONProperties(model) + if len(raw) == 0 { + return "", false + } + var verr *jsonschema.ValidationError + if err := compiled.Validate(inst); !errors.As(err, &verr) { + return "", false + } + hit := map[string]bool{} + var walk func(*jsonschema.ValidationError) bool + walk = func(e *jsonschema.ValidationError) bool { + if len(e.Causes) > 0 { + for _, c := range e.Causes { + if !walk(c) { + return false + } + } + return true + } + // A leaf at the document root blames the instance as a whole, not + // any one property, so it is never attributable to a raw field. + if len(e.InstanceLocation) == 0 || !raw[e.InstanceLocation[0]] { + return false + } + hit[e.InstanceLocation[0]] = true + return true + } + if !walk(verr) || len(hit) == 0 { + return "", false + } + names := sortedKeys(hit) + return strings.Join(names, "+"), true +} + +// rawJSONProperties returns the JSON names of a model's json.RawMessage +// fields. The open-object catch-all (Extra map[string]json.RawMessage, tagged +// "-") is not one of them: it holds properties the schema never named, and +// the oracle judges those under additionalProperties, which the SDK does +// enforce. +func rawJSONProperties(model any) map[string]bool { + t := reflect.TypeOf(model) + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil + } + rawType := reflect.TypeOf(json.RawMessage(nil)) + out := map[string]bool{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Type != rawType { + continue + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + out[name] = true + } + return out +} diff --git a/conformance/rawgap_test.go b/conformance/rawgap_test.go new file mode 100644 index 0000000..150cfb3 --- /dev/null +++ b/conformance/rawgap_test.go @@ -0,0 +1,136 @@ +package conformance + +import ( + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// A field the emitter carries as json.RawMessage is invisible to Validate, +// so the differential harness attributes such disagreements to a named gap +// instead of reporting them as missing checks. That attribution is only +// safe if it is narrow: it must fire when the raw field is the entire +// reason the oracle rejected, and never when anything else is also wrong. +// Otherwise it would quietly absorb exactly the bugs the harness exists to +// find — the same "a skip is not a neutral act" failure that hid the +// unsatisfiable ucp union behind an uncompilable schema. + +type gapModel struct { + Name string `json:"name"` + UCP json.RawMessage `json:"ucp"` + Extra map[string]json.RawMessage `json:"-"` +} + +type noRawModel struct { + Name string `json:"name"` +} + +func gapSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + var doc any + if err := json.Unmarshal([]byte(`{ + "type": "object", + "required": ["name", "ucp"], + "properties": { + "name": {"type": "string", "minLength": 2}, + "ucp": {"type": "object", "required": ["version"]} + } + }`), &doc); err != nil { + t.Fatal(err) + } + c := newCompiler() + if err := c.AddResource("mem://gap.json", doc); err != nil { + t.Fatal(err) + } + s, err := c.Compile("mem://gap.json") + if err != nil { + t.Fatal(err) + } + return s +} + +func instance(t *testing.T, raw string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatal(err) + } + return v +} + +func TestRawFieldExplainsRejection(t *testing.T) { + schema := gapSchema(t) + + cases := []struct { + name string + model any + payload string + want string + wantOK bool + }{ + { + name: "only the raw field is wrong", + model: new(gapModel), + payload: `{"name":"ok","ucp":{}}`, + want: "ucp", + wantOK: true, + }, + { + name: "a non-raw field is also wrong", + model: new(gapModel), + payload: `{"name":"x","ucp":{}}`, + wantOK: false, + }, + { + name: "only a non-raw field is wrong", + model: new(gapModel), + payload: `{"name":"x","ucp":{"version":"2026-04-08"}}`, + wantOK: false, + }, + { + name: "the model carries nothing as raw JSON", + model: new(noRawModel), + payload: `{"name":"ok","ucp":{}}`, + wantOK: false, + }, + { + name: "a root-level complaint is never attributable", + // A missing required property is reported at the document root, + // not inside the property, so it must not be charged to the raw + // field that happens to be absent. + model: new(gapModel), + payload: `{"name":"ok"}`, + wantOK: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + inst := instance(t, tc.payload) + if schema.Validate(inst) == nil { + t.Fatal("payload must be rejected by the oracle for this test to mean anything") + } + got, ok := rawFieldExplainsRejection(schema, tc.model, inst) + if ok != tc.wantOK { + t.Fatalf("attributed=%v want %v (field %q)", ok, tc.wantOK, got) + } + if ok && got != tc.want { + t.Errorf("attributed to %q, want %q", got, tc.want) + } + }) + } +} + +func TestRawJSONPropertiesIgnoresTheOpenObjectCatchAll(t *testing.T) { + // Extra holds properties the schema never named. The oracle judges those + // under additionalProperties, which the SDK does enforce, so counting + // Extra as a raw field would excuse real failures. + got := rawJSONProperties(new(gapModel)) + if !got["ucp"] { + t.Error("ucp is json.RawMessage and must be reported") + } + if len(got) != 1 { + t.Errorf("got %v, want only ucp: Extra is tagged \"-\" and is not a named property", got) + } +} diff --git a/goldens/2026-04-08.provenance.txt b/goldens/2026-04-08.provenance.txt index aad435d..b25524d 100644 --- a/goldens/2026-04-08.provenance.txt +++ b/goldens/2026-04-08.provenance.txt @@ -1,3 +1,3 @@ spec: release/2026-04-08 @ a2d8bf0b8f5a6fc790f677899c2c7da0684fe33d -python-sdk: 35af25c884376b519b9fb1181b281b04444c957b -generated: 2026-08-15T04:12:59Z +python-sdk: d650f0b7018d8fdf72d7a86daae8869943f43a64 +generated: 2026-08-21T00:57:29Z diff --git a/goldens/2026-04-08/capability.json b/goldens/2026-04-08/capability.json index fc4f117..f90a71d 100644 --- a/goldens/2026-04-08/capability.json +++ b/goldens/2026-04-08/capability.json @@ -39,8 +39,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -88,8 +89,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -138,8 +140,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -190,8 +193,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ diff --git a/goldens/2026-04-08/payment_handler.json b/goldens/2026-04-08/payment_handler.json index 1d046ce..911b56f 100644 --- a/goldens/2026-04-08/payment_handler.json +++ b/goldens/2026-04-08/payment_handler.json @@ -30,8 +30,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -71,8 +72,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -113,8 +115,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -157,8 +160,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ diff --git a/goldens/2026-04-08/service.json b/goldens/2026-04-08/service.json index 62e614e..ead8603 100644 --- a/goldens/2026-04-08/service.json +++ b/goldens/2026-04-08/service.json @@ -37,8 +37,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -79,8 +80,9 @@ "const": "rest" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -120,8 +122,9 @@ "const": "mcp" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -161,8 +164,9 @@ "const": "a2a" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -200,8 +204,9 @@ "const": "embedded" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -248,8 +253,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -291,8 +297,9 @@ "const": "rest" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -333,8 +340,9 @@ "const": "mcp" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -375,8 +383,9 @@ "const": "a2a" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -416,8 +425,9 @@ "const": "embedded" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -466,8 +476,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -510,8 +521,9 @@ "const": "rest" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -550,8 +562,9 @@ "const": "mcp" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -590,8 +603,9 @@ "const": "a2a" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -628,8 +642,9 @@ "const": "embedded" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ @@ -676,8 +691,9 @@ "type": "string" }, "version": { - "$ref": "#/$defs/version", - "description": "Entity version in YYYY-MM-DD format." + "description": "Entity version in YYYY-MM-DD format.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" } }, "required": [ diff --git a/payment_handler.go b/payment_handler.go index e111ef0..d5be901 100644 --- a/payment_handler.go +++ b/payment_handler.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "github.com/chaz8081/ucp-go/shopping/types" + "regexp" + "sync" ) // PaymentHandlerBase is generated from payment_handler.json. @@ -26,7 +28,7 @@ type PaymentHandlerBase struct { // Not enforced yet (phase 4): format. Spec *string `json:"spec,omitzero"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -100,6 +102,8 @@ func (v PaymentHandlerBase) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_PaymentHandlerBase_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *PaymentHandlerBase) Validate() error { if v.present != nil { @@ -113,14 +117,14 @@ func (v *PaymentHandlerBase) Validate() error { if v.AvailableInstruments != nil && len(v.AvailableInstruments) < 1 { return errors.New("available_instruments: has fewer than minItems 1") } + if !pattern_PaymentHandlerBase_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") + } for i := range v.AvailableInstruments { if err := v.AvailableInstruments[i].Validate(); err != nil { return err } } - if err := v.Version.Validate(); err != nil { - return err - } return nil } @@ -141,7 +145,7 @@ type PaymentHandlerBusinessSchema struct { // Not enforced yet (phase 4): format. Spec *string `json:"spec,omitzero"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -215,6 +219,8 @@ func (v PaymentHandlerBusinessSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_PaymentHandlerBusinessSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *PaymentHandlerBusinessSchema) Validate() error { if v.present != nil { @@ -228,14 +234,14 @@ func (v *PaymentHandlerBusinessSchema) Validate() error { if v.AvailableInstruments != nil && len(v.AvailableInstruments) < 1 { return errors.New("available_instruments: has fewer than minItems 1") } + if !pattern_PaymentHandlerBusinessSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") + } for i := range v.AvailableInstruments { if err := v.AvailableInstruments[i].Validate(); err != nil { return err } } - if err := v.Version.Validate(); err != nil { - return err - } return nil } @@ -256,7 +262,7 @@ type PaymentHandlerPlatformSchema struct { // Not enforced yet (phase 4): format. Spec string `json:"spec"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -330,6 +336,8 @@ func (v PaymentHandlerPlatformSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_PaymentHandlerPlatformSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *PaymentHandlerPlatformSchema) Validate() error { if v.present != nil { @@ -349,14 +357,14 @@ func (v *PaymentHandlerPlatformSchema) Validate() error { if v.AvailableInstruments != nil && len(v.AvailableInstruments) < 1 { return errors.New("available_instruments: has fewer than minItems 1") } + if !pattern_PaymentHandlerPlatformSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") + } for i := range v.AvailableInstruments { if err := v.AvailableInstruments[i].Validate(); err != nil { return err } } - if err := v.Version.Validate(); err != nil { - return err - } return nil } @@ -377,7 +385,7 @@ type PaymentHandlerResponseSchema struct { // Not enforced yet (phase 4): format. Spec *string `json:"spec,omitzero"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -451,6 +459,8 @@ func (v PaymentHandlerResponseSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_PaymentHandlerResponseSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *PaymentHandlerResponseSchema) Validate() error { if v.present != nil { @@ -464,13 +474,13 @@ func (v *PaymentHandlerResponseSchema) Validate() error { if v.AvailableInstruments != nil && len(v.AvailableInstruments) < 1 { return errors.New("available_instruments: has fewer than minItems 1") } + if !pattern_PaymentHandlerResponseSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") + } for i := range v.AvailableInstruments { if err := v.AvailableInstruments[i].Validate(); err != nil { return err } } - if err := v.Version.Validate(); err != nil { - return err - } return nil } diff --git a/service.go b/service.go index aab6eea..40467d8 100644 --- a/service.go +++ b/service.go @@ -6,6 +6,8 @@ package ucp import ( "encoding/json" "errors" + "regexp" + "sync" ) // ServiceBase is generated from service.json. @@ -29,7 +31,7 @@ type ServiceBase struct { // Transport protocol for this service binding. Transport string `json:"transport"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -104,6 +106,8 @@ func (v ServiceBase) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_ServiceBase_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *ServiceBase) Validate() error { if v.present != nil { @@ -117,8 +121,8 @@ func (v *ServiceBase) Validate() error { if v.Transport != "rest" && v.Transport != "mcp" && v.Transport != "a2a" && v.Transport != "embedded" { return errors.New("transport: not one of the permitted values") } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_ServiceBase_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } @@ -148,7 +152,7 @@ type ServiceBusinessSchema struct { // Transport protocol for this service binding. Transport string `json:"transport"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -223,6 +227,8 @@ func (v ServiceBusinessSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_ServiceBusinessSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *ServiceBusinessSchema) Validate() error { if v.present != nil { @@ -236,8 +242,8 @@ func (v *ServiceBusinessSchema) Validate() error { if v.Transport != "rest" && v.Transport != "mcp" && v.Transport != "a2a" && v.Transport != "embedded" { return errors.New("transport: not one of the permitted values") } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_ServiceBusinessSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } @@ -267,7 +273,7 @@ type ServicePlatformSchema struct { // Transport protocol for this service binding. Transport string `json:"transport"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -342,6 +348,8 @@ func (v ServicePlatformSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_ServicePlatformSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *ServicePlatformSchema) Validate() error { if v.present != nil { @@ -358,8 +366,8 @@ func (v *ServicePlatformSchema) Validate() error { if v.Transport != "rest" && v.Transport != "mcp" && v.Transport != "a2a" && v.Transport != "embedded" { return errors.New("transport: not one of the permitted values") } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_ServicePlatformSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil } @@ -389,7 +397,7 @@ type ServiceResponseSchema struct { // Transport protocol for this service binding. Transport string `json:"transport"` // Entity version in YYYY-MM-DD format. - Version UCPVersion `json:"version"` + Version string `json:"version"` // Extra holds properties the schema does not name. The schema is // open (additionalProperties is not false), so extension keys are @@ -464,6 +472,8 @@ func (v ServiceResponseSchema) MarshalJSON() ([]byte, error) { return json.Marshal(merged) } +var pattern_ServiceResponseSchema_Version = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile("^\\d{4}-\\d{2}-\\d{2}$") }) + // Validate reports the first constraint violation, or nil. func (v *ServiceResponseSchema) Validate() error { if v.present != nil { @@ -477,8 +487,8 @@ func (v *ServiceResponseSchema) Validate() error { if v.Transport != "rest" && v.Transport != "mcp" && v.Transport != "a2a" && v.Transport != "embedded" { return errors.New("transport: not one of the permitted values") } - if err := v.Version.Validate(); err != nil { - return err + if !pattern_ServiceResponseSchema_Version().MatchString(v.Version) { + return errors.New("version: does not match pattern") } return nil }