feat!: align embedded protocol errors with UCP error conventions#325
Conversation
Alternative worth exploringThis PR uses the standard error_response shape, but ECP's success responses nest under checkout while A lighter shape that reuses Message but drops the ucp wrapper could sidestep both issues at the cost of symmetry could be:
In this case |
igrigorik
left a comment
There was a problem hiding this comment.
Thinking & writing out loud...
UCP's promise is to be transport-agnostic. REST and MCP deliver on this and ECP (unfortunately) is the outlier where this promise is broken. As a north star, we should work towards fixing this; we should prefer steps that get us closer to transport-agnostic promise instead of away or preserving status quo.
This PR is a step in the right direction. Moving delegation errors into result using error_response is correct, but it only fixes the error side, which creates the highlighted asymmetric discriminator: ucp.status exists on errors but not on success. And so, why not take the next logical step and complete it: adducp to the success side too.
// success
{ "result": {
"ucp": { "version": "2026-01-15", "status": "success" },
"checkout": { ... } }
}}
// error
{ "result": {
"ucp": { "version": "2026-01-15", "status": "error" },
"messages": [{ ... }]
}}With that, ucp.status is a consistent discriminator on both branches, matching the same shape we have with REST and MCP. This applies to all delegation results:
ec.ready:{ ucp, upgrade?, checkout? }— the handshake ack now explicitly confirms the negotiated versionec.payment.*:{ ucp, checkout }— partial update with envelopeec.fulfillment.*: sameec.window.open_request:{ ucp }— the empty ack becomes a protocol-level confirmation instead of a structureless{}
I see all of the above as strict improvements. The version echo is the host explicitly confirming the negotiated ec_version -- similarly, explicit confirmation is a feature.
|
@igrigorik I am aligned on that. We should be consistent across the transports. I think we should add language around when a version change is acceptable such as only during handshake, but none the less the UCP envelope being consistent feels correct and it feels like we should make the change all at once rather than piecemeal. |
Delegation error responses in the Embedded Checkout Protocol used a flat
`{ code, message }` shape inside JSON-RPC `error` objects, inconsistent
with UCP's convention of reserving transport-level error mechanisms for
transport failures only. This change moves delegation errors into
`result` using the standard `error_response` shape shared by REST and
MCP transports.
- Update delegation method results to `oneOf` with `error_response.json`
`$ref` for error branches
- Add `ec.fulfillment.change` notification and `ec.fulfillment.address_change_request`
delegation method to OpenRPC
- Add delegation error codes to `error_code.json`
Add ucp metadata with status discriminator to all delegation result
branches, making ECP consistent with REST and MCP transports.
result.ucp.status ("success" | "error") is now the discriminator on
both branches of every delegation oneOf.
- Add ucp envelope (version + status: "success") to success results
for ec.ready, ec.payment.*, ec.fulfillment.*, ec.window.open_request
- Create EP-specific error schema chain: embedded_error_response.json
→ embedded_message_error.json → embedded_error_code.json, keeping
DOMException-inspired codes (abort_error, security_error, etc.) out
of the shared error_code.json
- Add version-binding spec language: ec_version is negotiated at
session init, confirmed by host in ec.ready, and MUST NOT change
for the session lifetime
- Fix ec.window.open_request error example that was still using
JSON-RPC error field instead of result with error_response
The spec previously coupled `ucp.status: "error"` to
`severity: "unrecoverable"` — the error processing algorithm
short-circuited on status before consulting severity. This coupling
was accurate for REST/MCP where error_response replaces a missing
resource, but breaks for ECP where a delegation error occurs within
a live session (e.g., user cancels a payment sheet) and the checkout
resource continues to exist.
`ucp.status` and `severity` are independent signals:
- `ucp.status` is the shape discriminator: success payload or error
payload. It tells you what to parse.
- `severity` is the action prescriber: retry, hand off, or start
over. It tells you what to do.
Error processing algorithm change:
Before: IF ucp.status = "error" → hardcoded RETRY, RETURN
(severity was never consulted)
After: PARTITION all errors by severity, including unrecoverable.
IF unrecoverable → RETRY with new resource, RETURN
IF recoverable → attempt to fix, update, re-evaluate
(same outcome for REST/MCP — unrecoverable errors still
hit RETRY — but reached through severity, not status)
This enables ECP delegation errors to use `recoverable` severity
(e.g., abort_error) without violating the error processing contract.
checkout.md:
- Reframe ucp.status as shape discriminator in Error Handling intro
- MUST use unrecoverable → SHOULD when no resource exists
We introduced embedded_error_code.json, embedded_message_error.json,
and embedded_error_response.json to isolate ECP-specific error codes from
the shared error_code.json examples. In practice, the three files
duplicate every field of their parent schemas to change one thing: the
examples array on a freeform string field.
This duplication carries real cost:
- Any change to message_error.json or error_response.json silently
drifts from the embedded copies
- The examples are not consumed by the doc build (error codes table in
embedded-checkout.md is hand-written markdown, and the schema macros
don't extract examples)
- error_code.json is type: "string" with no enum — validation is
identical regardless of which schema is referenced
ECP error codes are documented in the spec prose (the Error Codes table
in embedded-checkout.md). That is the source of truth, not schema
examples. Keep it simple, delete the three files and reference
error_response.json directly from the OpenRPC.
The host sent the error and owns the embedded context, so the host
is responsible for teardown. The embedded checkout has no reason to
echo the error back — the host already has full context.
Three normative statements added after the ec.ready error example:
- Host MUST tear down the embedded context
- Embedded Checkout MUST NOT send further messages after a
handshake error
The allOf pattern composing base + status const was inlined in error_response.json and repeated 5x in embedded.openrpc.json. These are fundamental UCP building blocks — promote them to shared $defs alongside base and version. ucp.json: - Add $defs/success: base + status "success" - Add $defs/error: base + status "error" error_response.json: - Replace inline allOf with $ref to $defs/error embedded.openrpc.json: - ucp_success component now refs $defs/success
Fulfillment's ECP methods (ec.fulfillment.change and
ec.fulfillment.address_change_request) were defined in both
fulfillment.json and embedded.openrpc.json — the extension copies
were stale (no ucp envelope, no error_response branch).
The right ownership: the extension defines its transport methods,
the transport imports them. This mirrors UCP's composition model
where capabilities extend the protocol without modifying core
files. Payment methods live in embedded.openrpc.json because
payment is core to checkout.
fulfillment.json:
- Update embedded.methods with ucp envelope ($defs/success),
error_response branch, oneOf pattern
- Convert methods from array to named map for stable $ref targets
- Fix $ref paths relative to fulfillment.json
embedded.openrpc.json:
- Replace inline fulfillment method definitions with $refs to
fulfillment.json#/embedded/methods/*
Rebase onto main (which now includes PR Universal-Commerce-Protocol#244: embedded cart + reauth) caused repeated conflicts in embedded.openrpc.json — both PRs heavily restructured the same file. We resolved by accepting main's version during rebase and reapplying our changes in a single pass onto the new base. Changes reapplied to embedded.openrpc.json: - Add components/schemas: ucp_success ($ref to ucp.json#/$defs/success), payment_instruments (base), payment_instrument_selection (extends base with selected_instrument_id) - ec.ready: oneOf [success, error_response], ucp envelope, inline partial checkout (not $ref to checkout.json), credential preserved from PR Universal-Commerce-Protocol#244 - ec.payment.instruments_change_request: oneOf, ucp envelope, payment_instrument_selection component - ec.payment.credential_request: oneOf, ucp envelope, payment_instruments component (no selected_instrument_id) - ec.window.open_request: oneOf, ucp envelope, error_response - Fulfillment methods: $ref to fulfillment.json#/embedded/methods/* - x-delegations: add fulfillment.address_change Note: ep.cart.* methods from PR Universal-Commerce-Protocol#244 do not yet have the ucp envelope treatment — flagged as follow-up.
10b54bd to
08d9828
Compare
The canonical pattern for ECP results — every request uses oneOf [ucp_success, error_response] with ucp.status as discriminator — was established for checkout delegation methods. PR Universal-Commerce-Protocol#244 (embedded cart + reauth) landed concurrently and introduced cart and auth methods that predate this convention. This commit brings them into alignment. embedded.openrpc.json — request results: ep.cart.ready: Add oneOf [ucp_success + upgrade + credential, error_response]. Cart ready has no delegation initial state (unlike ec.ready which seeds payment/fulfillment) but the handshake can still fail (origin validation, state violation). Credential field from PR Universal-Commerce-Protocol#244 preserved. ec.auth + ep.cart.auth: Add oneOf [ucp_success + credential, error_response]. Both are request/response methods — host returns auth credential on success, error_response on failure. Schemas are identical; only the method name and capability scope differ. embedded.openrpc.json — notification params: ec.messages.change: Revert oneOf [checkout.json, error_response] to plain $ref: checkout.json. ep.cart.messages.change: Same — revert to plain $ref: cart.json. PR Universal-Commerce-Protocol#244 added oneOf on these notifications to allow error_response as an alternative to the resource. This conflates two concerns: state changes (messages within the resource updated) and resource destruction (resource no longer exists). These are different events — state changes flow through the notification, resource destruction flows through ec.error / ep.cart.error. Notifications carry the resource; session errors use the dedicated error channel. embedded-cart.md — spec prose: Response Handling: Rewrite to match checkout spec — both success and error outcomes go in result, JSON-RPC error is reserved for transport failures. Add ucp envelope examples and discriminator language. ep.cart.ready: Add ucp to Result Payload docs and both examples (credential and upgrade). Add failure lifecycle: host MUST tear down on handshake error, Embedded Cart MUST NOT send further messages. ep.cart.auth: Add ucp to Result Payload docs and success example. Fix prose from "respond with either an error, or the authorization data" to "respond with a result containing either the authorization data or an error_response".
The error conformance work updated the Error Codes section, Response Handling summary, and OpenRPC schemas — but missed per-method examples deep in the delegation and auth sections. These were authored by the original PR commit and PR Universal-Commerce-Protocol#244 respectively, and were never swept for the two-layer error model. Four categories of stale patterns fixed: Response Handling error example (line 458): Still showed { "error": {...} } directly below our own discriminator language about result.ucp.status. Replaced with the correct { "result": { "ucp": { "status": "error" }, "messages": [...] } } shape. Cart spec (embedded-cart.md) already had the correct version from our earlier cart alignment commit — this was checkout-only drift. ec.auth success example + prose (lines 733, 738-750): Prose said "respond with either an error, or the authorization data" — the pre-two-layer framing. Success example omitted ucp envelope. Cart's ep.cart.auth already had both corrected. Fixed to match: prose says "respond with a result containing either the authorization data or an error_response", success example includes ucp envelope, Result Payload documents ucp as required field. Three delegation error examples: ec.payment.instruments_change_request (line 1239) ec.payment.credential_request (line 1339) ec.fulfillment.address_change_request (line 1516) All three still used the original flat shape: { "error": { "code": "abort_error", "message": "..." } } Replaced with the canonical error_response shape inside result: { "result": { "ucp": { "status": "error" }, "messages": [{ "type": "error", "code": "abort_error", "content": "...", "severity": "recoverable" }] } } Note: "message" field renamed to "content" to match message_error schema. severity: "recoverable" for abort_error per the severity contract established in the Error Codes section.
The schema_fields macro in main.py renders $ref properties as hyperlinks. When error_response.json changed from inline allOf to $ref: ucp.json#/$defs/error, the macro could no longer expand the ucp sub-properties (version, status) — breaking the #ucp-error anchor on 4 pages. When a property $refs a ucp.json $def, resolve it inline so the allOf rendering path handles it. This mirrors the existing special case for $defs/version (line 652) but generalized to all ucp $defs.
e4f2bb7 to
c26768e
Compare
Remove the stale copy from the auth section. Restore the ec.ready error example and teardown paragraph in the correct location — after the ec.ready success examples, before Authentication.
|
Thanks so much Westin for putting this together to finally fix errors in EP! :) I suggest a structural tweak to our error handling to match the other transports: let's treat transport-level failures as JSON-RPC protocol errors instead of wrapping them as UCP application outcomes. Why this is better:
So for example, instead of this: {
"jsonrpc": "2.0",
"id": "auth_1",
"result": {
"ucp": { "status": "error" },
"messages": [{ "type": "error", "code": "abort_error" }]
}
}We'd do {
"jsonrpc": "2.0",
"id": "auth_1",
"error": {
"code": -32000,
"message": "User aborted",
"data": {
"code": "aborted",
"content": "The user closed the payment selection interface."
}
}
}
Then update error codes in overview.md with:Embedded Protocol Errors: These errors are returned as JSON-RPC
@igrigorik @westeezy, and others please share your thoughts! |
|
@mmohades I think this takes us in the wrong direction. The separation we enforce is transport errors signal that the message itself couldn't be processed (malformed JSON, unknown method, invalid params); application errors capture results of executed business logic. A delegation with an aborted outcome is not a protocol error, it's a valid outcome: "I presented the payment sheet, the buyer declined." The host successfully processed the request and reported the result. That's what JSON-RPC For auth, Examples of transport errors would be calling missing / unsupported methods, malformed JSONRPC payload, etc. I pushed f1d2798 to address this in the stacked PR, because it allows us to avoid duplicating this across cart+checkout. |
|
Thanks Ilya! I see that we have different mental models here :D My initial thinking was rooted in how the rest of UCP works. In all other transports like REST and MCP, "Application Errors" strictly map to commerce domain errors (like invalid_address or insufficient_inventory) which are computed and returned by the Business. However, in the Embedded Protocol, the direction is inverted: the Host is responding with the result of an environment/UI interaction. From that perspective, viewing an aborted sheet as "I successfully presented the UI, and the valid outcome was the buyer declining" makes sense. It’s very much a GraphQL-style mental model where JSON-RPC acts purely as the delivery envelope, and I'm happy to align with that for interactive outcomes! That said, to avoid any confusion for developers crossing between these transports, I think we should call out:
Protocol Errors{
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32600,
"message": "Invalid Request",
"data": {
"code": "invalid_request",
"content": "Host origin validation failed during handshake."
}
}
}
Application Errors{
"jsonrpc": "2.0",
"id": "req_2",
"result": {
"status": "error",
"messages": [
{
"type": "error",
"code": "abort",
"content": "The buyer closed the payment selection sheet.",
"severity": "recoverable"
}
]
}
}
|
jingyli
left a comment
There was a problem hiding this comment.
Thanks @westeezy @igrigorik, the main constructs LGTM! Left some style-related comments + 2 structural ones related to:
- Should delegation response echo back the entire checkout object? If not, should we clarify why (is it because host wouldn't hold/be responsible for the other business-only fields) and clean up the comment in the examples?
- By removing
error_responsefromxx.messages.change, how will business notify host on actually unrecoverable errors where the resource is no longer being maintained on their end..? Are we expecting them to send that viaxx.erroras well?
Can approve from my end once the 2 points above are resolved!
Also a fly-by comment to @mmohades's note above, specifically on this:
Highlight the unique direction of Embedded errors: Adding a quick note that outcome errors in ECP/ECaP are returned by the Host (versus the Business in REST/MCP) will make the spec much easier to digest contextually.
I think there is actually an exception to this point, namely xx.error messages like https://ucp.dev/draft/specification/embedded-cart/#epcarterror where outcome errors are also being sent in the direction of Embedded Cart/Checkout -> host.
| no longer exists, the response contains `ucp.status: "error"` with | ||
| `messages` describing the failure — no resource is included in the | ||
| response body. Error responses MUST use `severity: "unrecoverable"`. | ||
| response body. When no resource exists to act on, messages SHOULD use |
There was a problem hiding this comment.
Naive QQ: Should this be **MUST** instead? Is it **SHOULD** only because we anticipate severity: "requires_escalation" to be a legitimate alternative..?
There was a problem hiding this comment.
requires_escalation is a checkout status, not a severity — the severity values are recoverable, requires_buyer_input, requires_buyer_review, unrecoverable. When no resource exists, unrecoverable is correct, because there's nothing to recover against.
We're using SHOULD instead of MUST because error_response is also used in ECP delegation results where the session is still alive and recoverable is valid (e.g., user cancelled a payment sheet). The constraint is accurate for REST/MCP but too strict as a universal rule. The error processing algorithm is now updated to route on severity instead of short-circuiting on ucp.status — so the behavior is the same for REST/MCP (unrecoverable -> retry with new resource), but ECP delegation errors can flow through severity-based routing correctly.
|
|
||
| If the host cannot complete the handshake (e.g., origin validation failure or | ||
| protocol state violation), it **MUST** respond with an `error_response` result. | ||
| When the host responds with an error, the session cannot proceed. The host |
There was a problem hiding this comment.
The host MUST tear down the embedded context and MAY redirect the buyer to
continue_urlif present. The Embedded Cart MUST NOT send further
messages after receiving a handshake error.
I wonder if this is missing a step in-between? In my mind, the following makes slightly more sense:
- Host responds with
error_response - Embedded Cart echoes with
ep.cart.errorcontaining a possiblecontinue_url - Upon receiving 2), host proceed with tear down & redirect
- Embedded Cart MUST NOT send further messages
Without step 2), I don't think there is any opportunity for host to ever receive the continue_url from business for handoff.
There was a problem hiding this comment.
The host initiated the embedded flow, it already has the cart/checkout response from the previous step, including continue_url. A handshake failure should be terminal: host sends error response, tears down. To recover, the host can retry the embedded session or fall back to continue_url from the original cart/checkout response. No echo needed — the host has full context from before it loaded the iframe
There was a problem hiding this comment.
I agree with @igrigorik in this regards. The continue_url should already be available and in terminal errors I wouldn't expect step 2.
| "type": "object", | ||
| "description": "Partial checkout update with fulfillment address selection.", | ||
| "properties": { | ||
| "fulfillment": { |
There was a problem hiding this comment.
To confirm on the expectation here: Does this mean in these delegation responses, we would only get back the delta data (i.e. updated fulfillment) and won't actually get the entire checkout object back again?
This feels like it differs from other transport and also differs from what the example in https://ucp.dev/draft/specification/embedded-checkout/#ecfulfillmentchange mentions..?
There was a problem hiding this comment.
This is consistent with other delegation responses in embedded, wihch return only the delta.
The spec prose already describes this: "The Embedded Checkout MUST treat this update as a PUT-style change by entirely replacing the existing state for the provided fields." The example in the spec matches; it shows only checkout.fulfillment, not the full checkout object.
There was a problem hiding this comment.
@jingyli I think this also may answer your question in another comment of should the delegations echo back the entire checkout object and my response would be no as this precedent exists.
|
@jingyli responded to your questions, ptal.
In short, I believe we have right end-state once we land this and 339. I'm sure further improvements can be made, but I don't think we should block our version cut and release; I think we're in good state to land and iterate in followup PRs. |
jingyli
left a comment
There was a problem hiding this comment.
Thanks @igrigorik! My outstanding questions are resolved between #339 and inline responses here.
|
Appreciate the callout to better docs. I gave #339 a read and an approval as I felt it did address the potential for confusion. If we view these two PRs both landing I am hopeful the documentation comments are addressed. I don't see anything that I feel would remain open or unaccounted for based on the comments thus far but please correct me if I am wrong. My stance at the moment is this feels like between the two PRs are are in a good spot to merge. |
|
Thanks all! Landing this so we can unblock and merge 339. @mmohades if you feel there are still gaps to improve, let's spin up a new issue/PR and iterate. |
Aligns ECP error and success responses with UCP's transport-agnostic conventions. What started as moving delegation errors from JSON-RPC
errortoresultexpanded into broader envelope, error model, and schema alignment.UCP envelope on all ECP results
Every ECP request result now uses
oneOf [success, error_response]withresult.ucp.statusas the discriminator — same pattern as REST and MCP. Applies to all checkout delegation methods (ec.ready,ec.auth,ec.payment.*,ec.fulfillment.*,ec.window.*) and cart methods (ep.cart.ready,ep.cart.auth). The JSON-RPCerrorfield is reserved for transport-level failures only.Decouple
ucp.statusfrom severityPreviously the spec coupled
ucp.status: "error"toseverity: "unrecoverable". This was accurate for REST/MCP (error_response replaces a missing resource) but breaks for ECP where delegation errors occur within a live session.ucp.statusis now a pure shape discriminator;severityindependently prescribes the action.Schema refactoring
ucp.json: Added$defs/successand$defs/error— shared building blocks replacing inlineallOf + status constpatternsembedded.openrpc.json: Extractedcomponents/schemasforucp_success,payment_instruments,payment_instrument_selectionembedded_error_code.json,embedded_message_error.json,embedded_error_response.json— three files duplicating the error chain for differentexampleson a freeform string. ECP error codes are documented in spec prose.Other fixes
$refto fullcheckout.jsonwith inline partial type matching what ec.ready actually promises (display-only host hints)oneOf [resource, error_response]onmessages.changenotifications back to plain$ref— session errors go throughec.error/ep.cart.errorType of change
Please delete options that are not relevant.
functionality to not work as expected, including removal of schema files
or fields)
Is this a Breaking Change or Removal?
If you checked "Breaking change" above, or if you are removing any schema
files or fields:
!to my PR title (e.g.,feat!: remove field).Checklist