fix: read the shipping address where UCP puts it, and type cart messages - #159
Conversation
…messages Two defects that between them made a conformant UCP agent unable to buy anything, both found by the functional store suite. **No address could be set.** The guest-address resolver read only `fulfillment.shipping_address`, which is not a property of `checkout.create`, `checkout.update` or `checkout.complete` in any UCP version. The protocol puts the address in `fulfillment.methods[].destinations[]`, and the plugin never looked there — `grep -rn destinations src/` found nothing — so completion always refused with "Checkout session is missing fulfillment.shipping_address" no matter what the agent sent. It now reads a destination, preferring `selected_destination_id` over the first entry, accepting both branches of the oneOf (a shipping_destination carries the postal address inline, a retail_location nests it under `address`) and mapping schema.org's names — `street_address`, `postal_code`, `address_locality`, `address_country` — onto the Shopware address. The old shape still works. The violation paths named `$.checkout_session.fulfillment.shipping_address` too, so the one message that says what is missing pointed at a field nothing could fill. They now name the property an agent can set. **A cart message failed the response schema.** `mapCartMessages()` emitted `type: cart_error`. `types/message.json` is a oneOf whose three branches pin `type` with a const of `error`, `warning` or `info`, so a fourth spelling matched no branch and the WHOLE response failed with `$ must match exactly one allowed schema` — which the executor then reports to the agent as a server error even though its request was fine and the write had succeeded. Not a corner case: a successful `discount.apply` leaves `promotion-discount-added` on the cart (`PromotionCartAddedInformationError`, LEVEL_NOTICE, persistent), so applying a valid code failed by construction, and every later cart or checkout response carried the same poison. Shopware's three error levels map onto UCP's three types, so the level is the mapping; `getMessageKey()` stays the code, since error_code, warning_code and info_code are all freeform. `severity` is set for `error` only, where the schema requires it, and `recoverable` is the honest value: the platform can change the cart and retry. Measured against GeneratedSchemaValidator: `cart_error` and an `error` without a severity are rejected; `info`, `warning` and `error` + `severity` pass. UcpResponseSchemaTest's cart fixture now carries the promotion notice a real cart would, so all seven cart and checkout operations cover this rather than `discount.apply` alone — the fixture being error-free is why `cart_error` survived a test suite that validates against the real schemas.
Lifted from #131 by @relativvv, which found this from the other side while running an agent-only shop: an address that was present but malformed returned `null`, fell through to the stored session address, and resurfaced two steps later as a message about a different field. The agent had no way to learn that what it sent was wrong. `postal_address` marks nothing required, so an incomplete one is schema-valid — this layer is the only place that can catch it. The distinction that matters is **attempted** versus **absent**, and it is not "is the key there". A destination carrying only an `id` is not a broken address: `shipping_destination` requires `id` and nothing else, so selecting a destination the business already offered looks exactly like that, and it must keep falling through to the stored address. So any postal field present is read as intent, and then the missing ones are named. `address_region` and `extended_address` count as evidence too — a destination naming those and no street is unambiguously a botched address rather than a selection. Violations name the element actually read rather than assuming `[0]`, since `selected_destination_id` can point anywhere in the list: $.fulfillment.methods[1].destinations[1].street_address is required $.fulfillment.methods[1].destinations[1].postal_code is required and a retail location gets the nested path, `destinations[0].address.*`. The legacy `shipping_address` container is rejected on presence alone — that key exists for nothing but an address, so no field-level evidence is needed — and its message points at `fulfillment.methods[].destinations[]`, which is where UCP actually puts the address. #131's own error text taught the off-spec shape instead.
|
Added That PR found the same defect from the other end (an agent-only shop, where a malformed address was silently dropped and resurfaced later as a misleading "shipping_address is required"). Its container choice was off-spec — One subtlety presence-of-key does not cover, and the reason this is not a straight port: a destination carrying only an Violations name the element actually read, because and a retail location gets Six new tests; suite is 549 → 555, One thing from #131 that could NOT be liftedIts billing-address rationale is correct and UCP agrees — final class PaymentInstrument
{
public function __construct(
public readonly string $type,
public readonly string $handlerId,
public readonly array $credential = [],
) {}
}No |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f419186316
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| $first ??= [$index, $destination]; | ||
| } | ||
|
|
||
| return $first; |
There was a problem hiding this comment.
Reject unmatched selected_destination_id
When selected_destination_id is present but does not match any destination, this falls through to return $first, so a schema-valid but stale/typoed selection silently stores a different address than the one the buyer chose. In that case the checkout can complete with the first destination instead of failing fast; only use the first destination when no selection was supplied, and reject an unmatched selected id.
Useful? React with 👍 / 👎.
| $normalized = [ | ||
| 'street' => $street, | ||
| 'zipcode' => $zipcode, | ||
| 'city' => $city, | ||
| ]; |
There was a problem hiding this comment.
Preserve address_region for state-required countries
For destinations in countries where Shopware requires a state/province during registration, the agent can supply the UCP address_region field, but this normalized address drops it before GuestCustomerContextProvisioner calls the Store API register route. Those guest checkouts still fail because no countryStateId is passed even though the UCP address included the required region; resolve/pass the region (or reject it with a targeted validation error) before saving the guest address.
Useful? React with 👍 / 👎.
Why is this change necessary?
Two defects on the shared UCP checkout path. Both were found by the store suite in
shopware/shopware-mcp-evals, which drives the plugin's tools end to end.Scope, up front — this is guest checkout, and it is not MCP-specific
CheckoutGuestAddressPayloadResolversits inShopwareCheckoutAdapter, which implements the SDK'sCheckoutAdapterInterfaceand is driven byShoppingOperationExecutor. That serves every transport —CheckoutController(REST),A2aController, embedded, and the MCP tools alike. So nothing here is MCP-only; MCP is just where our suite hit it.And it only bites guest checkout.
GuestCustomerContextProvisioner::ensureGuestCustomer()opens with:From the code there are exactly three ways to place an order, and two of them never reach this resolver:
fulfillment.shipping_addresswith Shopware field names (street/zipcode/city)mainreads today. Works.fulfillment.methods[].destinations[]mainnever looks there. Address dropped, completion refuses.If you have been placing UCP orders happily, you are almost certainly on path A or B — and that is consistent with this PR rather than evidence against it. The bug is path C, which is the only path a third-party agent implementing UCP from the published spec can take.
1. A guest agent could not set a shipping address, so it could not place an order
CheckoutGuestAddressPayloadResolverread onlyfulfillment.shipping_address. That is not a property ofcheckout.create,checkout.updateorcheckout.completein any UCP version — searched all three generated schemas. The protocol puts the address infulfillment.methods[].destinations[], and the plugin never looked there:grep -rn destinations src/returned nothing.So a conformant payload was accepted, silently dropped, and completion refused two steps later with
naming a field an agent had no way to fill. That message cost a full day: it reads as a schema defect, and the conclusion drawn from it — that UCP has no usable address channel and the
destinationsoneOfis unsatisfiable — was wrong. See "How was it found".Worth stating plainly: path B is undocumented.
grep -rn shipping_address README.md docs/returns nothing, and the key is in no UCP schema, so the only way to discover that shape is to read this plugin's source.2. A cart message failed the response schema, so applying a valid discount code failed by construction
ShopwareDataMapper::mapCartMessages()emittedtype: cart_error.types/message.jsonis aoneOfwhose three branches pintypewith aconstoferror,warningorinfo, so a fourth spelling matches no branch and the whole response fails with$ must match exactly one allowed schema.ShoppingOperationExecutor::response()validates before returning, so the agent gets a server error even though its request was fine and the write had already succeeded.Not a corner case. A successful
discount.applyleavespromotion-discount-addedon the cart —PromotionCartAddedInformationError,LEVEL_NOTICE,isPersistent: true— so the response was invalid whenever the code worked, and every later cart or checkout response carried the same poison. This one is transport-independent and hits logged-in customers too.What does this change do?
Reads the destination. Walks
fulfillment.methods[], prefers the destination whoseidmatchesselected_destination_idand otherwise takes the first, and accepts both branches of theoneOf: ashipping_destinationcarries the postal address inline, aretail_locationnests it underaddress. Field names are schema.org's, astypes/postal_address.jsonspecifies —street_address,postal_code,address_locality,address_country, withextended_addressappended to the street.FulfillmentSelection::$extraalready holds the rawfulfillmentpayload (HttpPayloadMapper::toFulfillment()passes it through whole), so no SDK change was needed.Path B still works. Nothing conformant sends it, but it costs three lines and no existing integration breaks.
Repoints the violation paths.
GuestCustomerAddressResolver's three messages named$.checkout_session.fulfillment.shipping_address. They now name$.fulfillment.methods[0].destinations[0].street_addressand friends — a property an agent can actually set.Types the cart messages. Shopware's three error levels line up with UCP's three message types, so the level is the mapping:
Error::getLevel()typeseverityLEVEL_ERRORerrorrecoverableLEVEL_WARNINGwarningLEVEL_NOTICEinfogetMessageKey()stays thecode:error_code.json,warning_code.jsonandinfo_code.jsonare all freeform strings by specification.severityis set forerroronly, where the schema requires it, andrecoverableis the honest value — the platform can change the cart or the code and retry. Arequires_*severity would contributestatus: requires_escalationand stall a checkout the agent could have fixed itself.Rejects a partial address instead of dropping it —
f4191863, lifted from #131 by Robin Schulte (@relativvv), now closed in favour of this PR.postal_addressmarks nothing required, so an incomplete one is schema-valid and this layer is the only place that can catch it.The trigger is any postal field present, not the key exists: a destination carrying only an
idis not a broken address —shipping_destinationrequiresidand nothing else, so selecting a destination the business already offered looks exactly like that, and it must keep falling through to the stored session address. Violations name the element actually read, sinceselected_destination_idcan point anywhere in the list:and a retail location gets
destinations[0].address.*. The legacy container is rejected on presence alone and its message points atfulfillment.methods[].destinations[].How to test
Measured against the real validator rather than reasoned about.
GeneratedSchemaValidatoron the message item ofcart.get.response:{type: cart_error, …}{type: info, content, code}{type: warning, content, code}{type: error, content, code, severity}{type: error, content, code}Unit suite:
UcpResponseSchemaTest's cart fixture now carries the promotion notice a real cart would, so all seven cart and checkout operations cover this rather thandiscount.applyalone — the fixture being error-free is precisely whycart_errorsurvived a suite that validates against the real schemas.CheckoutGuestAddressPayloadResolverTestis new and covers: inline shipping destination, nested retail location,selected_destination_idpreference, extended address, a method with no usable address, the legacy shape, id-only selection falling through, and every partial-address rejection path.End to end on a local trunk lane,
shopware/shopware-mcp-evals, guest checkout with a conformant payload:Before:
20 passed, 2 failed, 1 skipped. After:22 passed, 1 failed, 0 skipped.discount-applypasses,checkout-completeplaces a real order, andorder-getun-skips because an order finally exists. Its remaining failure is a separate defect — see below.The harness needed a fix of its own to exercise this: it was omitting
destinationsentirely, on the strength of the wrong reading of theoneOf. Paired with shopware/shopware-mcp-evals#10.How was it found
The store suite could reach
checkout-completefor the first time, and it refused with the message above.shipping_addressreally is absent from all three request schemas, so the natural conclusion was that the schema was at fault. Three destination shapes were measured againstGeneratedSchemaValidatorand all three failed, which looked like confirmation that theoneOfwas unsatisfiable.It is not. Branch 0,
shipping_destination, is anallOfofpostal_addressand{properties: {id}, required: [id]}— it requires anid. Branch 1,retail_location, requiresidandname:destinations[0]id, matches neither branch{id, name, address}{id, name}{id, …postal address}The fourth combination was never tried.
nameis what pulls an object intoretail_locationas well and makes it ambiguous. So the address channel worked all along and the plugin was not reading it — and the error message that should have said so named a field that does not exist.The discount failure surfaced in the same run, as the first green
discount.applythe suite had ever produced.Not addressed here
order.getcannot read back a guest order — the last failing check.ShopwareOrderGateway::requireContextToken()takes the incoming store-api context token, but the guest branch needs the token the checkout session was stored under, since that is whereorderId,orderDeepLinkCode, the buyer email and the address live. A fresh session has no way to supply it, soguestOrderRequest()returns null andOrderRouteanswers 403 "Customer is not logged in." Previously invisible:order-getskipped for want of an order id. Independently hit from the other end in fix: never report unpaid orders as completed; advertise delegated payment handlers #130, whose follow-up notes thecheckout.completereplay path needs the same token. How an agent authenticates a guest order read is a design question, not a patch.payment.instruments[].billing_address.Ucp\Sdk\Model\Checkout\PaymentInstrumentcarries onlytype,handlerIdandcredential, and the adapter only sees that typed model — there is no raw-payload path asFulfillmentSelection::$extraprovides for fulfillment. So an agent can send a conformant billing address and the plugin cannot read it. That matters because a digital cart has no shipping destination, making the payment instrument's billing address UCP's only address for it — the valid half of fix: accept standard UCP postal-address fields and reject malformed addresses loudly #131's rationale. Needs an SDK change first.payment_handlers: {}— fixed at the root in fix: never report unpaid orders as completed; advertise delegated payment handlers #130 (the tokenization gate no delegated handler can satisfy), so not duplicated here.{id, name}matches bothoneOfbranches, so a pickup destination cannot be expressed at all. This reads the shape when it arrives; making it expressible is an upstream schema question.