Skip to content

fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation - #165

Merged
Björn Meyer (BrocksiNet) merged 2 commits into
mainfrom
fix/ucp-order-permalink-clean
Aug 6, 2026
Merged

fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation#165
Björn Meyer (BrocksiNet) merged 2 commits into
mainfrom
fix/ucp-order-permalink-clean

Conversation

@BrocksiNet

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

Copy link
Copy Markdown
Contributor

This is Robin Schulte (@relativvv)'s fix from #151, rebased onto current main so 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.

#151 showed 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 of main — an admin-UI rewrite, an OpenAI export template, docs — all of which are already on main.


The bug

types/order_confirmation.json requires both id and permalink_url. The SDK model drops a null one:

return array_filter(['id' => $this->id, 'permalink_url' => $this->permalinkUrl], fn($v) => $v !== null);

ShopwareDataMapper passed the nullable continue URL as the permalink, so on a sales channel without a continueUrlTemplate the field was omitted, branch 0 (Checkout) of the response's root oneOf failed, and the SDK rejected the shop's own response:

FAIL shopware-ucp-checkout-complete: validation: Validation failed for schema
     "checkout.complete.response". ($ must match exactly one allowed schema.)

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.get has no id to read. Severity is higher than the message suggests.

The fix

OrderPermalinkBuilder produces an absolute {baseUri}/ucp/v1/orders/{id}, threaded through CheckoutCompleter and ShopwareCheckoutAdapter into ShopwareDataMapper::toCompletedCheckout(). A permalink is not a continue URL and should not depend on optional configuration.

Verified on a trunk lane, with no continueUrlTemplate configured

The lane had to be made honest first — clearing the template moved the MCP evals store suite from 34 passed / 1 failed to 30 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:

checkout.complete   order.permalink_url = http://trunk.localhost:8088/ucp/v1/orders/{orderId}
order.get           permalink_url       = http://trunk.localhost:8088/account/order/{orderId}

Store suite: 34 passed, 1 failed, 0 skipped
  PASS order-get (guest)      ← the typed refusal from #162, asserted as the correct answer
  PASS order-get (customer)   ← a real read-back, passing for the first time
  FAIL a signed-in buyer can place a second order   ← #163, unrelated, expected

Plugin gates on the lane: 565 unit tests green, cs clean, phpstan 93 findings — identical to the baseline count, nothing new.


What I changed, and why each wants Robin Schulte (@relativvv)'s call

1. Dropped the CheckoutStatus $status parameter. The original commit says "Stacked on #130 (shares the toCompletedCheckout signature)", and its signature was:

toCompletedCheckout(…, ?string $continueUrl = null, CheckoutStatus $status = CheckoutStatus::Completed, ?string $orderPermalinkUrl = null)

$status is #130's, not the permalink's, and #130 is not on main. Here it is:

toCompletedCheckout(…, ?string $continueUrl = null, ?string $orderPermalinkUrl = null)

Question: should #130 own that signature change and add $status on 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 teststestUnpaidPlacedOrderIsReportedCompleteInProgress and testPaidPlacedOrderIsReportedCompleted. 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 (OrderPermalinkBuilderTest plus 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.get returns permalink_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 use deepLinkCode for guests, or point at the UCP endpoint like the completion one does?

Context

…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.
@BrocksiNet Björn Meyer (BrocksiNet) changed the title fix(ucp): always emit absolute order.permalink_url fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation Aug 6, 2026

@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: 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".

Comment thread src/Ucp/Checkout/OrderPermalinkBuilder.php Outdated
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.
@BrocksiNet
Björn Meyer (BrocksiNet) merged commit f7877e4 into main Aug 6, 2026
21 checks passed
@BrocksiNet
Björn Meyer (BrocksiNet) deleted the fix/ucp-order-permalink-clean branch August 6, 2026 12:25
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".
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.

3 participants