From e099f7a5932fe74c557f9a0fe75694d1c410b505 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Fri, 11 Sep 2026 01:18:27 -0400 Subject: [PATCH] fix: derive the discovery profile ucp member from ucp.json business_schema UcpDiscoveryProfileSchema.ucp (UcpSchema) was a hand written projection node carrying the withdrawn 2026-01-11 discovery shape: capabilities as a flat array and required, services as a record of single objects, and no payment_handlers. ucp.json#/$defs/base declares every registry as an object keyed by reverse domain name whose values are entity arrays and requires only version; #/$defs/business_schema additionally requires services and payment_handlers and adds supported_versions. The published package therefore rejected the business profile example that the specification publishes (overview/index.md, def=business_schema) on both capabilities and services, while UcpProfileDocumentSchema in the same file, which is derived from the schema, accepted it. Derive the node from business_schema the same way the response envelope is derived, through buildResponseEnvelopeSchema plus the required list and properties of the overlay. Registry items use the per entity response compat shape, as the envelope does. No title is set so the type keeps its property derived name and the UcpSchema export is unchanged. Two properties were being widened past what the schema declares, and both are corrected here. The envelope builder collapsed the map_order $ref to a string, which rejected the second business profile example in the overview (the block with map_order.payment_handlers); map_order is now a record of string arrays per ucp.json#/$defs/map_order, so UcpResponseSchema and UcpCheckoutResponseSchema change on that one line as well. supported_versions was typed as a record of any and accepted a number where the overlay declares a profile URI; its values are now typed as strings. Compile time breaking for consumers of the discovery type: Ucp.capabilities changes from CapabilityDiscovery[] to Record and Ucp.services from Record to Record. No exported name is added or removed. Tests: tests/discovery-profile-shape.test.js parses the business profile example from release/2026-08-25 (tests/fixtures/business_profile_2026-08-25.json) through UcpDiscoveryProfileSchema, and pins the record shape of capabilities and services, the presence of payment_handlers, the base and business_schema required sets, the map_order shape, and the supported_versions value type. The two existing tests that used the empty legacy fixture now use the spec shape. --- scripts/project-current-ucp-schemas.mjs | 74 ++++++-- src/spec_generated.ts | 162 +++++++++++------- tests/discovery-profile-shape.test.js | 122 +++++++++++++ .../fixtures/business_profile_2026-08-25.json | 131 ++++++++++++++ tests/spec-constraints.test.js | 6 +- 5 files changed, 418 insertions(+), 77 deletions(-) create mode 100644 tests/discovery-profile-shape.test.js create mode 100644 tests/fixtures/business_profile_2026-08-25.json diff --git a/scripts/project-current-ucp-schemas.mjs b/scripts/project-current-ucp-schemas.mjs index 3f5ac42..4f5cee1 100644 --- a/scripts/project-current-ucp-schemas.mjs +++ b/scripts/project-current-ucp-schemas.mjs @@ -828,6 +828,21 @@ function buildResponseEnvelopeSchema(ucpSchema, extraRequired = []) { type: "object", additionalProperties: { type: "array", items: { $ref: compat } }, }; + } else if ( + typeof schema?.$ref === "string" && + schema.$ref.startsWith("#/$defs/") && + ucpSchema.$defs[schema.$ref.slice("#/$defs/".length)]?.type === "object" + ) { + // An object valued local def (map_order): keep its map shape instead of + // collapsing the $ref to a string, which rejected the map_order example + // in the overview. + const def = ucpSchema.$defs[schema.$ref.slice("#/$defs/".length)]; + properties[name] = { + type: "object", + additionalProperties: def.additionalProperties + ? toCompatLeaf(def.additionalProperties) + : true, + }; } else { properties[name] = toCompatLeaf(schema); } @@ -841,6 +856,49 @@ function buildResponseEnvelopeSchema(ucpSchema, extraRequired = []) { }; } +// The `ucp` member of the discovery profile, DERIVED from ucp.json#/$defs/business_schema +// (allOf: base + overlay). base contributes every registry (services, +// capabilities, payment_handlers) as an object keyed by reverse domain name +// whose values are entity arrays, plus version/status/map_order, requiring +// only version; the business overlay adds required services and +// payment_handlers, and supported_versions. Registry items map to the +// per-entity RESPONSE compat shape exactly as the response envelope does. +// Replaces the hand written node that carried the withdrawn 2026-01-11 shape +// (capabilities as a flat array and required, services as single objects, no +// payment_handlers), which rejected the business profile example the specification +// publishes. No title: the type keeps the property-derived name (UcpSchema), so +// the existing export survives. +function buildBusinessProfileUcpSchema(ucpSchema) { + const overlay = ucpSchema.$defs.business_schema.allOf[1]; + const envelope = buildResponseEnvelopeSchema( + ucpSchema, + overlay.required ?? [] + ); + const { title: _title, ...derived } = envelope; + const properties = { ...derived.properties }; + for (const [name, schema] of Object.entries(overlay.properties ?? {})) { + // Registry properties are already modeled by the envelope (the overlay only + // re-points their item refs); carry the additions of the overlay through. + if (name in properties) continue; + // An open map with a typed value (supported_versions: version -> profile + // URI) keeps that value type; toCompatLeaf would widen it to any. + properties[name] = + schema?.type === "object" && + schema.additionalProperties && + typeof schema.additionalProperties === "object" + ? { + type: "object", + additionalProperties: toCompatLeaf(schema.additionalProperties), + } + : toCompatLeaf(schema); + } + return { + ...derived, + required: [...new Set(derived.required)], + properties, + }; +} + function writeCompatibilityDiscoverySchemas() { const ucpSchema = loadRootSchema("ucp.json"); const paymentHandlerSchema = loadRootSchema("payment_handler.json"); @@ -1012,21 +1070,7 @@ function writeCompatibilityDiscoverySchemas() { type: "array", items: { $ref: "signing_key.json" }, }, - ucp: { - type: "object", - required: ["capabilities", "services", "version"], - properties: { - capabilities: { - type: "array", - items: { $ref: "capability.json" }, - }, - services: { - type: "object", - additionalProperties: { $ref: "ucp_service.json" }, - }, - version: clone(version), - }, - }, + ucp: buildBusinessProfileUcpSchema(ucpSchema), }, }; diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 3f1224f..2d9e367 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -3,6 +3,14 @@ import * as z from "zod"; export const UseSchema = z.enum(["enc", "sig"]); export type Use = z.infer; +export const TransportSchema = z.enum(["a2a", "embedded", "mcp", "rest"]); +export type Transport = z.infer; + +export const UcpCheckoutResponseStatusSchema = z.enum(["error", "success"]); +export type UcpCheckoutResponseStatus = z.infer< + typeof UcpCheckoutResponseStatusSchema +>; + // Content format, default = plain. export const ContentTypeSchema = z.enum(["markdown", "plain"]); @@ -43,14 +51,6 @@ export type CheckoutResponseStatus = z.infer< typeof CheckoutResponseStatusSchema >; -export const TransportSchema = z.enum(["a2a", "embedded", "mcp", "rest"]); -export type Transport = z.infer; - -export const UcpCheckoutResponseStatusSchema = z.enum(["error", "success"]); -export type UcpCheckoutResponseStatus = z.infer< - typeof UcpCheckoutResponseStatusSchema ->; - // Adjustment status. export const AdjustmentStatusSchema = z.enum([ @@ -233,6 +233,33 @@ export type NetworkTokenCredentialType = z.infer< export const PanCredentialTypeSchema = z.enum(["pan"]); export type PanCredentialType = z.infer; +export const CapabilityDiscoverySchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), + name: z.string(), + schema: z.string().url(), + spec: z.string().url(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type CapabilityDiscovery = z.infer; + export const SigningKeySchema = z.object({ alg: z.string().optional(), crv: z.string().optional(), @@ -263,7 +290,7 @@ export type ConstraintsProperty = z.infer; export const ConstraintExpressionPropertySchema = ConstraintsPropertySchema; export type ConstraintExpressionProperty = ConstraintsProperty; -export const CapabilityDiscoverySchema = z.object({ +export const CapabilityResponseSchema = z.object({ config: z.record(z.string(), z.any()).optional(), extends: z .union([ @@ -283,12 +310,23 @@ export const CapabilityDiscoverySchema = z.object({ ), ]) .optional(), - name: z.string(), - schema: z.string().url(), - spec: z.string().url(), + id: z.string().optional(), + schema: z.string().url().optional(), + spec: z.string().url().optional(), version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }); -export type CapabilityDiscovery = z.infer; +export type CapabilityResponse = z.infer; + +export const ServiceResponseSchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + endpoint: z.string().url().optional(), + id: z.string().optional(), + schema: z.string().url().optional(), + spec: z.string().url().optional(), + transport: TransportSchema, + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type ServiceResponse = z.infer; export const A2ASchema = z.object({ endpoint: z @@ -712,44 +750,6 @@ export type TotalLine = z.infer; export const TotalLineClassSchema = TotalLineSchema; export type TotalLineClass = TotalLine; -export const CapabilityResponseSchema = z.object({ - config: z.record(z.string(), z.any()).optional(), - extends: z - .union([ - z - .array( - z - .string() - .regex( - /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ - ) - ) - .min(1), - z - .string() - .regex( - /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ - ), - ]) - .optional(), - id: z.string().optional(), - schema: z.string().url().optional(), - spec: z.string().url().optional(), - version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), -}); -export type CapabilityResponse = z.infer; - -export const ServiceResponseSchema = z.object({ - config: z.record(z.string(), z.any()).optional(), - endpoint: z.string().url().optional(), - id: z.string().optional(), - schema: z.string().url().optional(), - spec: z.string().url().optional(), - transport: TransportSchema, - version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), -}); -export type ServiceResponse = z.infer; - export const EventLineItemSchema = z.object({ id: z.string(), quantity: z.number().int().gte(1).lte(9007199254740991), @@ -2136,13 +2136,6 @@ export type AvailablePaymentInstrument = z.infer< typeof AvailablePaymentInstrumentSchema >; -export const UcpSchema = z.object({ - capabilities: z.array(CapabilityDiscoverySchema), - services: z.record(z.string(), UcpServiceSchema), - version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), -}); -export type Ucp = z.infer; - export const LineItemCreateRequestSchema = z.object({ item: ItemCreateRequestSchema, quantity: z.number().int().gte(1).lte(9007199254740991), @@ -2388,6 +2381,55 @@ export type PaymentHandlerResponse = z.infer< typeof PaymentHandlerResponseSchema >; +export const UcpSchema = z.object({ + capabilities: z + .record(z.string(), z.array(CapabilityResponseSchema)) + .refine( + (value) => + Object.keys(value).every((key) => + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/.test( + key + ) + ), + { message: "Record keys must match the required pattern (propertyNames)" } + ) + .optional(), + map_order: z.record(z.string(), z.array(z.string())).optional(), + payment_handlers: z + .record(z.string(), z.array(PaymentHandlerResponseSchema)) + .refine( + (value) => + Object.keys(value).every((key) => + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/.test( + key + ) + ), + { message: "Record keys must match the required pattern (propertyNames)" } + ), + services: z + .record(z.string(), z.array(ServiceResponseSchema)) + .refine( + (value) => + Object.keys(value).every((key) => + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/.test( + key + ) + ), + { message: "Record keys must match the required pattern (propertyNames)" } + ), + status: UcpCheckoutResponseStatusSchema.optional(), + supported_versions: z + .record(z.string(), z.string()) + .refine( + (value) => + Object.keys(value).every((key) => /^\d{4}-\d{2}-\d{2}$/.test(key)), + { message: "Record keys must match the required pattern (propertyNames)" } + ) + .optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type Ucp = z.infer; + export const CheckoutCreateRequestSchema = z.object({ attribution: z.record(z.string(), z.string()).optional(), buyer: BuyerClassSchema.optional(), @@ -2420,7 +2462,7 @@ export const UcpCheckoutResponseSchema = z.object({ { message: "Record keys must match the required pattern (propertyNames)" } ) .optional(), - map_order: z.string().optional(), + map_order: z.record(z.string(), z.array(z.string())).optional(), payment_handlers: z .record(z.string(), z.array(PaymentHandlerResponseSchema)) .refine( @@ -2462,7 +2504,7 @@ export const UcpResponseSchema = z.object({ { message: "Record keys must match the required pattern (propertyNames)" } ) .optional(), - map_order: z.string().optional(), + map_order: z.record(z.string(), z.array(z.string())).optional(), payment_handlers: z .record(z.string(), z.array(PaymentHandlerResponseSchema)) .refine( diff --git a/tests/discovery-profile-shape.test.js b/tests/discovery-profile-shape.test.js new file mode 100644 index 0000000..db5dc6c --- /dev/null +++ b/tests/discovery-profile-shape.test.js @@ -0,0 +1,122 @@ +// Fidelity tests for the discovery profile projection (UcpDiscoveryProfileSchema +// and its `ucp` member, UcpSchema). +// +// ucp.json#/$defs/base declares every registry (services, capabilities, +// payment_handlers) as an object keyed by reverse domain name whose values are +// arrays of entities, and requires only `version`; #/$defs/business_schema (the +// shape a business publishes at /.well-known/ucp) additionally requires +// services and payment_handlers. Before the fix the hand written projection +// carried the withdrawn 2026-01-11 shape: capabilities as a flat array (and +// required), services as a record of single objects, no payment_handlers. It +// REJECTED the business profile example the specification publishes while +// the derived UcpProfileDocumentSchema in the same file accepted it. +// +// tests/fixtures/business_profile_2026-08-25.json is the business profile +// example from docs/specification/overview/index.md at release/2026-08-25 +// (the block annotated `ucp:example schema=profile def=business_schema`), with +// the `{{ ucp_version }}` macro rendered as 2026-08-25. It is JSON equal to +// that block; whitespace follows the repository prettier configuration. + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { + UcpDiscoveryProfileSchema, + UcpSchema, +} = require("./.dist/spec_generated.js"); + +const accepts = (schema, value) => schema.safeParse(value).success === true; +const rejects = (schema, value) => schema.safeParse(value).success === false; + +const specProfile = JSON.parse( + fs.readFileSync( + path.join(__dirname, "fixtures", "business_profile_2026-08-25.json"), + "utf8" + ) +); + +test("UcpDiscoveryProfileSchema accepts the business profile example from the specification", () => { + const result = UcpDiscoveryProfileSchema.safeParse(specProfile); + assert.ok( + result.success, + result.success ? "" : JSON.stringify(result.error.issues, null, 2) + ); +}); + +test("UcpSchema models capabilities as a record of entity arrays, not a flat array", () => { + const ucp = specProfile.ucp; + assert.ok(accepts(UcpSchema, ucp)); + const flat = { + ...ucp, + capabilities: Object.entries(ucp.capabilities).flatMap(([name, entries]) => + entries.map((entry) => ({ name, ...entry })) + ), + }; + assert.ok(rejects(UcpSchema, flat)); +}); + +test("UcpSchema models each services entry as an array of service entities", () => { + const ucp = specProfile.ucp; + const singleObject = { + ...ucp, + services: Object.fromEntries( + Object.entries(ucp.services).map(([name, entries]) => [name, entries[0]]) + ), + }; + assert.ok(rejects(UcpSchema, singleObject)); +}); + +test("UcpSchema carries payment_handlers, which the business profile requires", () => { + const { payment_handlers, ...withoutHandlers } = specProfile.ucp; + assert.ok(rejects(UcpSchema, withoutHandlers)); + const parsed = UcpSchema.parse(specProfile.ucp); + assert.deepEqual(parsed.payment_handlers, payment_handlers); +}); + +test("UcpSchema does not require capabilities (base requires only version)", () => { + const { capabilities, ...withoutCapabilities } = specProfile.ucp; + assert.ok(accepts(UcpSchema, withoutCapabilities)); +}); + +test("UcpSchema requires services, per business_schema", () => { + const { services, ...withoutServices } = specProfile.ucp; + assert.ok(rejects(UcpSchema, withoutServices)); +}); + +test("UcpSchema models map_order as a record of key arrays (ucp.json#/$defs/map_order)", () => { + // The second business profile example in the overview carries + // map_order.payment_handlers; the envelope builder used to collapse the + // map_order $ref to a string and rejected it. + const ucp = specProfile.ucp; + const ordered = { + ...ucp, + map_order: { payment_handlers: Object.keys(ucp.payment_handlers) }, + }; + assert.ok(accepts(UcpSchema, ordered)); + assert.ok(rejects(UcpSchema, { ...ucp, map_order: "payment_handlers" })); +}); + +test("UcpSchema types supported_versions values as strings, not any", () => { + // business_schema declares supported_versions as version key to profile URI. + // toCompatLeaf widened the value to any, so a number passed. + const ucp = specProfile.ucp; + assert.ok( + accepts(UcpSchema, { + ...ucp, + supported_versions: { + "2026-04-08": "https://example.com/.well-known/ucp", + }, + }) + ); + assert.ok( + rejects(UcpSchema, { ...ucp, supported_versions: { "2026-04-08": 1 } }) + ); + assert.ok( + rejects(UcpSchema, { + ...ucp, + supported_versions: { "not-a-version": "https://e.example" }, + }) + ); +}); diff --git a/tests/fixtures/business_profile_2026-08-25.json b/tests/fixtures/business_profile_2026-08-25.json new file mode 100644 index 0000000..1c49dee --- /dev/null +++ b/tests/fixtures/business_profile_2026-08-25.json @@ -0,0 +1,131 @@ +{ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "rest", + "endpoint": "https://business.example.com/ucp/v1", + "schema": "https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json" + }, + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "mcp", + "endpoint": "https://business.example.com/ucp/mcp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/mcp.openrpc.json" + }, + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "a2a", + "endpoint": "https://business.example.com/.well-known/agent-card.json" + }, + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "embedded", + "schema": "https://ucp.dev/2026-08-25/services/shopping/embedded.openrpc.json" + } + ] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/shopping/checkout", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/shopping/extensions/fulfillment", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/shopping/extensions/discount", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.common.identity_linking": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/common/identity-linking/", + "schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking.json", + "config": { + "providers": { + "com.example.idp": [ + { + "type": "oauth2", + "auth_url": "https://accounts.example.com/" + } + ] + }, + "scopes": { + "dev.ucp.shopping.order:read": {}, + "dev.ucp.shopping.order:manage": {} + } + } + } + ] + }, + "payment_handlers": { + "com.example.processor_tokenizer": [ + { + "id": "processor_tokenizer", + "version": "2026-08-25", + "spec": "https://example.com/specs/payments/processor_tokenizer", + "schema": "https://example.com/specs/payments/merchant_tokenizer.json", + "available_instruments": [ + { + "type": "card", + "constraints": { + "properties": { + "brand": { + "enum": ["visa", "mastercard", "amex"] + } + } + } + } + ], + "config": { + "type": "CARD", + "tokenization_specification": { + "type": "PUSH", + "parameters": { + "token_retrieval_url": "https://api.psp.example.com/v1/tokens" + } + } + } + } + ] + } + }, + "keys": [ + { + "kid": "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U", + "kty": "OKP", + "crv": "Ed25519", + "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", + "use": "sig", + "alg": "EdDSA" + }, + { + "kid": "business_2025", + "kty": "EC", + "crv": "P-256", + "x": "qIVYZVLCrPZHGHjP17CTW0_-D9Lfw0EkjqF7xB4FivA", + "y": "Mc4nN9LTDOBhfoUeg8Ye9WedFRhnZXZJA12Qp0zZ6F0", + "use": "sig", + "alg": "ES256" + } + ] +} diff --git a/tests/spec-constraints.test.js b/tests/spec-constraints.test.js index 74e27d3..cfcb06b 100644 --- a/tests/spec-constraints.test.js +++ b/tests/spec-constraints.test.js @@ -608,7 +608,9 @@ test("CapabilityDiscoverySchema accepts valid extends names", () => { }); test("UcpSchema enforces the discovery version pattern", () => { - const discovery = { capabilities: [], services: {} }; + // Spec shape (ucp.json#/$defs/business_schema): registries are records keyed + // by reverse domain name, services and payment_handlers are required. + const discovery = { services: {}, payment_handlers: {} }; assert.ok(rejects(UcpSchema, { ...discovery, version: "not-a-date" })); assert.ok(accepts(UcpSchema, { ...discovery, version: "2026-04-08" })); }); @@ -938,8 +940,8 @@ test("UcpDiscoveryProfileSchema emits keys array per RFC 7517", () => { assert.ok( accepts(UcpDiscoveryProfileSchema, { ucp: { - capabilities: [], services: {}, + payment_handlers: {}, version: "2026-08-25", }, keys: [{ kid: "key-1", kty: "OKP" }],