Skip to content

fix: read the shipping address where UCP puts it, and type cart messages - #159

Merged
Björn Meyer (BrocksiNet) merged 2 commits into
mainfrom
fix/ucp-fulfillment-destinations-and-cart-messages
Aug 5, 2026
Merged

fix: read the shipping address where UCP puts it, and type cart messages#159
Björn Meyer (BrocksiNet) merged 2 commits into
mainfrom
fix/ucp-fulfillment-destinations-and-cart-messages

Conversation

@BrocksiNet

@BrocksiNet Björn Meyer (BrocksiNet) commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

CheckoutGuestAddressPayloadResolver sits in ShopwareCheckoutAdapter, which implements the SDK's CheckoutAdapterInterface and is driven by ShoppingOperationExecutor. 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:

if (null !== $context->getCustomer()) {
    return $context;          // no address is read from the request at all
}

From the code there are exactly three ways to place an order, and two of them never reach this resolver:

path affected? why
A. Logged-in / linked customer no Returns at the line above. Shopware uses the customer's stored default addresses; the UCP request needs no address at all.
B. Guest + fulfillment.shipping_address with Shopware field names (street/zipcode/city) no Exactly what main reads today. Works.
C. Guest + spec-conformant fulfillment.methods[].destinations[] yes main never 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

CheckoutGuestAddressPayloadResolver read only fulfillment.shipping_address. That is not a property of checkout.create, checkout.update or checkout.complete in any UCP version — searched all three generated schemas. The protocol puts the address in fulfillment.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

Checkout session is missing fulfillment.shipping_address;
set it on checkout create or update before completion.
($.checkout_session.fulfillment.shipping_address is required)

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 destinations oneOf is 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() 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 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.apply leaves promotion-discount-added on 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 whose id matches selected_destination_id and otherwise takes the first, and accepts both branches of the oneOf: a shipping_destination carries the postal address inline, a retail_location nests it under address. Field names are schema.org's, as types/postal_address.json specifies — street_address, postal_code, address_locality, address_country, with extended_address appended to the street.

FulfillmentSelection::$extra already holds the raw fulfillment payload (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_address and 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() UCP type severity
LEVEL_ERROR error recoverable
LEVEL_WARNING warning
LEVEL_NOTICE info

getMessageKey() stays the code: error_code.json, warning_code.json and info_code.json are all freeform strings by specification. severity is set for error only, where the schema requires it, and recoverable is the honest value — the platform can change the cart or the code and retry. A requires_* severity would contribute status: requires_escalation and stall a checkout the agent could have fixed itself.

Rejects a partial address instead of dropping itf4191863, lifted from #131 by Robin Schulte (@relativvv), now closed in favour of this PR. postal_address marks 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 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 session address. Violations name the element actually read, 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 destinations[0].address.*. The legacy container is rejected on presence alone and its message points at fulfillment.methods[].destinations[].

How to test

Measured against the real validator rather than reasoned about. GeneratedSchemaValidator on the message item of cart.get.response:

message verdict
{type: cart_error, …} INVALID — matches no branch
{type: info, content, code} VALID
{type: warning, content, code} VALID
{type: error, content, code, severity} VALID
{type: error, content, code} INVALID — severity is required

Unit suite:

composer test    # 549 -> 555 tests, green
composer cs
composer phpstan

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 precisely why cart_error survived a suite that validates against the real schemas. CheckoutGuestAddressPayloadResolverTest is new and covers: inline shipping destination, nested retail location, selected_destination_id preference, 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:

SW_BASE_URL=http://trunk.localhost:8088 \
UCP_PROFILE_URI=http://localhost:8000/.well-known/ucp \
UCP_JOURNEY_PROMO_CODE=WELCOME10 \
  python -m functional.runner --endpoint store --allow-mutations

Before: 20 passed, 2 failed, 1 skipped. After: 22 passed, 1 failed, 0 skipped. discount-apply passes, checkout-complete places a real order, and order-get un-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 destinations entirely, on the strength of the wrong reading of the oneOf. Paired with shopware/shopware-mcp-evals#10.

How was it found

The store suite could reach checkout-complete for the first time, and it refused with the message above. shipping_address really is absent from all three request schemas, so the natural conclusion was that the schema was at fault. Three destination shapes were measured against GeneratedSchemaValidator and all three failed, which looked like confirmation that the oneOf was unsatisfiable.

It is not. Branch 0, shipping_destination, is an allOf of postal_address and {properties: {id}, required: [id]} — it requires an id. Branch 1, retail_location, requires id and name:

destinations[0] verdict why
bare postal address INVALID no id, matches neither branch
address without names INVALID same
{id, name, address} INVALID matches both branches
{id, name} INVALID matches both
{id, …postal address} VALID branch 0 only

The fourth combination was never tried. name is what pulls an object into retail_location as 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.apply the suite had ever produced.

Not addressed here

  • order.get cannot 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 where orderId, orderDeepLinkCode, the buyer email and the address live. A fresh session has no way to supply it, so guestOrderRequest() returns null and OrderRoute answers 403 "Customer is not logged in." Previously invisible: order-get skipped 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 the checkout.complete replay path needs the same token. How an agent authenticates a guest order read is a design question, not a patch.
  • The SDK drops payment.instruments[].billing_address. Ucp\Sdk\Model\Checkout\PaymentInstrument carries only type, handlerId and credential, and the adapter only sees that typed model — there is no raw-payload path as FulfillmentSelection::$extra provides 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.
  • Discovery advertises 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.
  • Retail locations remain unusable in practice. {id, name} matches both oneOf branches, so a pickup destination cannot be expressed at all. This reads the shape when it arrives; making it expressible is an upstream schema question.

…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.
@BrocksiNet

Copy link
Copy Markdown
Contributor Author

Added f4191863reject a partial address instead of dropping it, lifted from #131 by Robin Schulte (@relativvv), which is now closed in favour of this PR.

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 — fulfillment.shipping_address exists nowhere in the 2026-04-08 schemas — but the silent-drop finding is independent of that and correct: postal_address marks nothing required, so an incomplete one is schema-valid and this layer is the only place that can catch it.

One subtlety presence-of-key does not cover, and the reason this is not a straight port: 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 has to keep falling through to the stored session address. The trigger is therefore any postal field present rather than the key exists — with address_region and extended_address counting as evidence, since a destination naming those and no street is unambiguously a botched address.

Violations name the element actually read, because 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 destinations[0].address.*. The legacy shipping_address container is rejected on presence alone and its message points at fulfillment.methods[].destinations[].

Six new tests; suite is 549 → 555, cs and phpstan clean, and the store journey still reads 22 passed, 1 failed, 0 skipped — the happy path is unaffected by the new rejection.

One thing from #131 that could NOT be lifted

Its billing-address rationale is correct and UCP agrees — billing_address is a postal_address on payment.instruments[], and for a digital cart with nothing to ship that is the only address the protocol offers. But the SDK drops it:

final class PaymentInstrument
{
    public function __construct(
        public readonly string $type,
        public readonly string $handlerId,
        public readonly array $credential = [],
    ) {}
}

No billing_address, and the adapter only sees this typed model — there is no raw-payload path like FulfillmentSelection::$extra. So an agent can send a conformant billing address today and the plugin cannot read it. Needs an SDK change first; adding it to this PR's "Not addressed here".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +185 to +189
$normalized = [
'street' => $street,
'zipcode' => $zipcode,
'city' => $city,
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@BrocksiNet
Björn Meyer (BrocksiNet) merged commit d20a87d into main Aug 5, 2026
21 checks passed
@BrocksiNet
Björn Meyer (BrocksiNet) deleted the fix/ucp-fulfillment-destinations-and-cart-messages branch August 5, 2026 12:57
Björn Meyer (BrocksiNet) added a commit that referenced this pull request Aug 5, 2026
Brings in #159 and #160. Merged rather than rebased so the original history stays
intact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants