fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation - #165
Merged
Conversation
…outs pass SDK response validation
The UCP checkout/order response schema requires order.permalink_url to be a
non-null absolute URI (format: uri). ShopwareDataMapper passed the nullable
continue URL as the permalink, so on sales channels without a continueUrlTemplate
the field was omitted and the shop's OWN checkout response failed the SDK
response validator with an opaque '$ must match exactly one allowed schema'
error on GET and complete — blocking every UCP checkout that reaches an order.
Add OrderPermalinkBuilder ({baseUri}/ucp/v1/orders/{id}) and thread an explicit
order permalink through CheckoutCompleter and ShopwareCheckoutAdapter into
ShopwareDataMapper::toCompletedCheckout. Points at the UCP order endpoint so the
link stays machine-resolvable for headless/agent sales channels.
Stacked on #130 (shares the toCompletedCheckout signature). Rebase onto main
after #130 merges.
Björn Meyer (BrocksiNet)
requested review from
Lukas Rump (lukasrump) and
Robin Schulte (relativvv)
August 6, 2026 07:17
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ae1a2ccd5
ℹ️ 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".
Answers the review question on the permalink's target, and unifies the three
different answers the plugin gave for one field.
`/ucp/v1/orders/{id}`, which the previous commit emitted at completion, cannot
be opened. Measured as a browser sends it:
422 {"messages":[{"code":"invalid_request",
"content":"$.headers.ucp-agent is required"}]}
It is an API endpoint, and a guest could not authenticate it even with the
header: completion rotates the Shopware context token and the response never
hands the successor back. `order.get` was broken a second way, and that one
predates this branch — `ShopwareOrderAdapter` built
`/account/order/{orderId}`, but that route resolves a **deep-link code**, so an
order id matches nothing. Both spellings render the same guest form, which is
why it looked fine. And `OrderStateSubscriber` used the configured continue URL,
a third answer.
All three now build Shopware's own order page addressed by deep-link code, which
is the one URL that works for every buyer. Checked against core (trunk) rather
than assumed:
* `AccountOrderPageLoader::load()` refuses only when there is neither a
customer NOR a `deepLinkCode`, then filters on the code with no branching on
who is logged in;
* every core order-state mail links exactly this way —
rawUrl('frontend.account.order.single.page', {'deepLinkCode': …}, domain) —
to guests and registered customers alike, because the sender cannot know
which the recipient is.
Confirmed on a lane by submitting the guest form (email + postcode) at both
spellings for the same guest order:
/account/order/{deepLinkCode} -> order page, order number shown
/account/order/{orderId} -> back to the credentials form, no order
A continue URL is deliberately not preferred over it: it templates
`{checkoutId}`, so the lane's default resolves to /checkout/confirm and shows a
spent checkout after completion rather than the order. The builder falls back to
the order list when `deep_link_code` is null, since `permalink_url` is required
and a URL built from an id cannot resolve.
Headless channels are the case this trades away: they have no storefront order
page. Their answer should be a configured URL rather than an unopenable API
endpoint, which is a separate discussion — the endpoint served neither case.
Jonas Elfering (keulinho)
approved these changes
Aug 6, 2026
Björn Meyer (BrocksiNet)
added a commit
that referenced
this pull request
Aug 6, 2026
* chore: require ucp-php-sdk 0.0.5 0.0.5 is published, and this plugin depends on what it contains. #165 passes an explicit `order.permalink_url` into `OrderConfirmation`, which 0.0.5 makes a required constructor argument — a plugin on core 0.0.4 keeps working, but the guarantee the model now enforces only exists from 0.0.5, and the response-schema failure that motivated #165 is only diagnosable with 0.0.5's oneOf messages. All five declarations move together, because a version this repository states in more than one file is a version it can disagree with itself about — the trap AGENTS.md records after `ci.yml` and `bin/ci-smoke.sh` each kept their own copy, and after a bump to `ci.yml` alone turned `shopware-matrix (trunk)` red: composer.json >=0.0.4 <0.1.0 -> >=0.0.5 <0.1.0 .github/workflows/ci.yml two forced path-repo versions bin/ci-smoke.sh two more, for the deployed smoke stack Verified from the registry rather than assumed, since CI resolves path repos and would not have noticed a missing tag: ucp-php-sdk/core 0.0.5 php ^8.1, ext-filter, ext-iconv, ext-mbstring, ext-openssl ucp-php-sdk/symfony-bundle 0.0.5 ext-openssl, core >=0.0.5 <0.1.0 and by resolving the new constraint against Packagist in a scratch project, which installs core 0.0.5 alongside the bundle. On a trunk lane at 0.0.5: 567 plugin tests green, `cs` clean, and the MCP evals store suite at 34 passed / 1 failed (the one failure is agentic-commerce#163, asserted deliberately). * docs: stop stating the SDK floor in prose that cannot be re-resolved Codex caught this on #167, and it is the trap that PR is about, one level up: `README.md` and `AGENTS.md` both stated the requirement as `>=0.0.2 <0.1.0` — the floor from #157, three bumps ago. My sweep for other declarations looked for `0.0.4` and so walked straight past the two places that were even more wrong. Naming a current value in prose is the defect, not the specific stale digits: a maintainer following the release guidance could conclude 0.0.2 is still supported, or use the obsolete bound during the next coordinated bump. So rather than editing the number and leaving the next reader the same trap, both places now say `composer.json` is the authority on the lower bound and quote the current value as a reference point. `composer.json`, `ci.yml` and `bin/ci-smoke.sh` still have to move together — those are declarations Composer reads, and no prose can replace them. The caret illustration stays, since the whole rule was learned from it, but as history: "the plugin's *original* `^0.0.2` never picked up 0.0.3".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is Robin Schulte (@relativvv)'s fix from #151, rebased onto current
mainso the diff is the fix. He is the commit author; #151 is closed in favour of this. Nothing of the fix changed — see What I changed below for the two editorial decisions I had to make, both of which want his call.#151showed 2,079 additions / 261 deletions across 49 files. This shows 131 / 7 across 9. The difference was branch history: its branch carried commits from #147, #148, #149, #156, #154, #155 and #157 plus a merge ofmain— an admin-UI rewrite, an OpenAI export template, docs — all of which are already onmain.The bug
types/order_confirmation.jsonrequires bothidandpermalink_url. The SDK model drops a null one:ShopwareDataMapperpassed the nullable continue URL as the permalink, so on a sales channel without acontinueUrlTemplatethe field was omitted, branch 0 (Checkout) of the response's rootoneOffailed, and the SDK rejected the shop's own response:The order is placed. Only the response fails — so the agent is told its purchase failed and never learns the order id, after which
order.gethas no id to read. Severity is higher than the message suggests.The fix
OrderPermalinkBuilderproduces an absolute{baseUri}/ucp/v1/orders/{id}, threaded throughCheckoutCompleterandShopwareCheckoutAdapterintoShopwareDataMapper::toCompletedCheckout(). A permalink is not a continue URL and should not depend on optional configuration.Verified on a trunk lane, with no
continueUrlTemplateconfiguredThe lane had to be made honest first — clearing the template moved the MCP evals store suite from
34 passed / 1 failedto30 passed, 3 failed, 2 skipped, byte-identical to CI. That single config value was the whole reason this never reproduced locally.With this branch, same configuration:
Plugin gates on the lane: 565 unit tests green,
csclean,phpstan93 findings — identical to the baseline count, nothing new.What I changed, and why each wants Robin Schulte (@relativvv)'s call
1. Dropped the
CheckoutStatus $statusparameter. The original commit says "Stacked on #130 (shares thetoCompletedCheckoutsignature)", and its signature was:$statusis #130's, not the permalink's, and #130 is not onmain. Here it is:Question: should #130 own that signature change and add
$statuson top of this, or would you rather this PR carried it? I took the narrower reading so the permalink fix can merge without waiting on the payment-state discussion, but it is your call which PR owns the signature.2. Dropped #130's two payment-status tests —
testUnpaidPlacedOrderIsReportedCompleteInProgressandtestPaidPlacedOrderIsReportedCompleted. They assert #130's behaviour, not the permalink's, and they cannot compile without$status. They should travel with #130.All four of the original permalink test additions are intact (
OrderPermalinkBuilderTestplus the mapper, completer and adapter cases). Two of them I had to re-seat by hand: the conflict boundary had spliced them into the middle of a helper method, so if the placement looks different from what you wrote, that is why.One thing I did not verify
order.getreturnspermalink_url = {baseUri}/account/order/{orderId}. Shopware's guest order page resolves a deep-link code, not an order id, so that URL may not open for a guest — while the completion permalink points at the UCP endpoint and is unaffected. I have not tested it either way. Given the spec calls the permalink "the authoritative reference for the full order experience", it seems worth a look: should the order-view permalink usedeepLinkCodefor guests, or point at the UCP endpoint like the completion one does?Context
order-getline above is a PASS rather than a mystery — it makes the refusal typed.oneOffailures now name the branch and the field, andOrderConfirmation::$permalinkUrlbecomes required so the invalid object cannot be built. That PR does not replace this one — the business still has to supply the URL.