From d3953a772c4fb1d93a47ce02864e0665dbd6abcc Mon Sep 17 00:00:00 2001 From: Deepanshu Pal <40927968+DeepanshuPal@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:21:19 +0530 Subject: [PATCH 1/4] Warn on definitely undeclared checkout fulfillment methods Added warning for unsupported fulfillment methods in checkout. --- src/cli.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index dc668d8..5139195 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { Cli, middleware, z } from 'incur' import { buildCta } from './cli/cta.js' import { type DoctorDeps, runDoctor } from './cli/doctor.js' +import { unsupportedFulfillmentMethods } from './cli/fulfillment-warning.js' import { buildOperationInput } from './cli/input.js' import { buildProfileCli, type ProfileCliDependencies } from './cli/profile.js' import { buildProfileSwitchCta, localProfilesSpeaking } from './cli/profile-hint.js' @@ -32,7 +33,7 @@ import { listProfiles, readUserProfile } from './core/profile-store.js' import { describeProxyState } from './core/proxy.js' import { SUPPORTED_VERSIONS } from './core/releases.js' import { acceptsHttpsUrl, parseHttpsUrl } from './core/url.js' -import { setVerboseWriter, vlog } from './core/verbose.js' +import { setVerboseWriter, uwarn, vlog } from './core/verbose.js' import { ErrorCodes, UcpError } from './lib/errors.js' import { omitUndefined } from './lib/omit-undefined.js' import type { CtaBlock, Transport } from './lib/types.js' @@ -526,6 +527,13 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { ...(c.options.dryRun ? { dryRun: true } : {}), _onDiscover: (d) => { discovered = d + if (bodyKey === 'checkout') { + for (const method of unsupportedFulfillmentMethods(d, merged)) { + uwarn( + `checkout requested fulfillment method "${method}" is not in the merchant's declared allows_method_combinations; the merchant may drop it. The call will still be sent. Check the returned fulfillment and buyer state.`, + ) + } + } }, }) // Dry-run short-circuits the regular envelope: no escalation/CTA From 9ba1e32ec6dc0a2d7f1943ad5b9882c683dd1dcd Mon Sep 17 00:00:00 2001 From: Deepanshu Pal <40927968+DeepanshuPal@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:22:50 +0530 Subject: [PATCH 2/4] Test warning-before-dispatch for unsupported fulfillment method --- src/cli.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/cli.test.ts b/src/cli.test.ts index b6991e3..c73cc74 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2080,3 +2080,59 @@ describe('isSkillsAddInvocation — incur alias awareness', () => { expect(isSkillsAddInvocation(['cart', 'create'])).toBe(false) }) }) + +describe('checkout fulfillment declaration warning', () => { + it('warns before dispatch without blocking a pickup checkout update', async () => { + const events: string[] = [] + const cli = createUcpCli({ + resolveSession: passthroughSession, + updateCheckout: async (_business, input, options) => { + events.push('discover') + options._onDiscover?.({ + profile: { + ucp: { + capabilities: { + 'dev.ucp.shopping.fulfillment': [ + { + version: '2026-08-25', + config: { allows_method_combinations: [['shipping']] }, + }, + ], + }, + }, + }, + protocol: { version: '2026-08-25' }, + negotiated: {}, + } as never) + events.push('dispatch') + expect(input).toMatchObject({ + id: 'checkout-1', + checkout: { fulfillment: { methods: [{ type: 'pickup' }] } }, + }) + return { fulfillment: { methods: [] } } + }, + }) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + events.push('warning') + expect(String(chunk)).toContain('pickup') + expect(String(chunk)).toContain('merchant may drop it') + return true + }) + try { + const { output, exitCode } = await serveCli(cli, [ + 'checkout', + 'update', + 'checkout-1', + '--business', + 'https://shop.example.com', + '--input', + JSON.stringify({ fulfillment: { methods: [{ type: 'pickup' }] } }), + ]) + expect(exitCode, output).toBe(0) + expect(JSON.parse(output).result.fulfillment.methods).toEqual([]) + expect(events).toEqual(['discover', 'warning', 'dispatch']) + } finally { + stderr.mockRestore() + } + }) +}) From 78f08b9485d708bc018192ba9ef2bf580af2adff Mon Sep 17 00:00:00 2001 From: Deepanshu Pal <40927968+DeepanshuPal@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:23:29 +0530 Subject: [PATCH 3/4] Detect definitely undeclared checkout fulfillment methods --- src/cli/fulfillment-warning.ts | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/cli/fulfillment-warning.ts diff --git a/src/cli/fulfillment-warning.ts b/src/cli/fulfillment-warning.ts new file mode 100644 index 0000000..b4a0e6e --- /dev/null +++ b/src/cli/fulfillment-warning.ts @@ -0,0 +1,44 @@ +// Advisory only: discovery predicts merchant capabilities, but the server's +// response is authoritative. Never suppress or rewrite a checkout call here. +import type { DiscoveredBusiness } from '../core/discover.js' + +const FULFILLMENT = 'dev.ucp.shopping.fulfillment' + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +/** Return only method types definitely absent from an unambiguous declaration. */ +export function unsupportedFulfillmentMethods( + discovered: DiscoveredBusiness, + input: Record, +): string[] { + const checkout = record(input.checkout) + const fulfillment = record(checkout?.fulfillment) + const methods = record(fulfillment)?.methods + if (!Array.isArray(methods)) return [] + + const entries = record(discovered.profile.ucp.capabilities)?.[FULFILLMENT] + if (!Array.isArray(entries)) return [] + const matching = entries.filter((entry) => record(entry)?.version === discovered.protocol.version) + // Several matching entries can disagree. Do not claim absence in that case. + if (matching.length !== 1) return [] + const combinations = record(record(matching[0])?.config)?.allows_method_combinations + if (!Array.isArray(combinations) || combinations.length === 0) return [] + const supported = new Set() + for (const combination of combinations) { + if (!Array.isArray(combination) || combination.length === 0) return [] + for (const type of combination) { + if (typeof type !== 'string' || type.length === 0) return [] + supported.add(type) + } + } + const requested = new Set() + for (const method of methods) { + const type = record(method)?.type + if (typeof type === 'string' && type.length > 0 && !supported.has(type)) requested.add(type) + } + return [...requested] +} From 5394e3e4d60ccd4894411a75f216ff9fe672676c Mon Sep 17 00:00:00 2001 From: Deepanshu Pal <40927968+DeepanshuPal@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:24:08 +0530 Subject: [PATCH 4/4] Test fulfillment warning certainty boundaries --- src/cli/fulfillment-warning.test.ts | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/cli/fulfillment-warning.test.ts diff --git a/src/cli/fulfillment-warning.test.ts b/src/cli/fulfillment-warning.test.ts new file mode 100644 index 0000000..407beb5 --- /dev/null +++ b/src/cli/fulfillment-warning.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveredBusiness } from '../core/discover.js' +import { unsupportedFulfillmentMethods } from './fulfillment-warning.js' + +const fulfillment = 'dev.ucp.shopping.fulfillment' +const combination = (methods: unknown) => ({ + version: '2026-08-25', + config: { allows_method_combinations: methods }, +}) +function discovery(entries: unknown): DiscoveredBusiness { + return { + profile: { ucp: { capabilities: { [fulfillment]: entries } } }, + protocol: { version: '2026-08-25' }, + } as DiscoveredBusiness +} +function checkout(types: unknown[]): Record { + return { checkout: { fulfillment: { methods: types.map((type) => ({ type })) } } } +} + +describe('unsupportedFulfillmentMethods', () => { + it('identifies pickup absent from shipping-only combinations, without flagging shipping', () => { + expect( + unsupportedFulfillmentMethods( + discovery([combination([['shipping']])]), + checkout(['shipping', 'pickup', 'pickup']), + ), + ).toEqual(['pickup']) + }) + it('uses the union of declared combinations, including mixed choices', () => { + const d = discovery([combination([['shipping'], ['shipping', 'pickup']])]) + expect(unsupportedFulfillmentMethods(d, checkout(['pickup', 'shipping']))).toEqual([]) + expect(unsupportedFulfillmentMethods(d, checkout(['courier', 'pickup']))).toEqual(['courier']) + }) + it('does not infer an unsupported method from absent, malformed, ambiguous, or other-version config', () => { + for (const entries of [ + undefined, + [], + [combination([])], + [combination([['shipping'], [1]])], + [combination([['shipping']]), combination([['pickup']])], + [{ version: '2026-04-08', config: { allows_method_combinations: [['shipping']] } }], + ]) { + expect(unsupportedFulfillmentMethods(discovery(entries), checkout(['pickup']))).toEqual([]) + } + }) + it('ignores non-checkout payloads', () => { + expect( + unsupportedFulfillmentMethods(discovery([combination([['shipping']])]), { + cart: { fulfillment: { methods: [{ type: 'pickup' }] } }, + }), + ).toEqual([]) + }) +})