Skip to content

feat!: align embedded protocol errors with UCP error conventions#325

Merged
igrigorik merged 14 commits intoUniversal-Commerce-Protocol:mainfrom
westeezy:feat/ecp-error-conformance
Apr 8, 2026
Merged

feat!: align embedded protocol errors with UCP error conventions#325
igrigorik merged 14 commits intoUniversal-Commerce-Protocol:mainfrom
westeezy:feat/ecp-error-conformance

Conversation

@westeezy
Copy link
Copy Markdown
Contributor

@westeezy westeezy commented Mar 31, 2026

Aligns ECP error and success responses with UCP's transport-agnostic conventions. What started as moving delegation errors from JSON-RPC error to result expanded into broader envelope, error model, and schema alignment.

UCP envelope on all ECP results

Every ECP request result now uses oneOf [success, error_response] with result.ucp.status as 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-RPC error field is reserved for transport-level failures only.

Decouple ucp.status from severity

Previously the spec coupled ucp.status: "error" to severity: "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.status is now a pure shape discriminator; severity independently prescribes the action.

Schema refactoring

  • ucp.json: Added $defs/success and $defs/error — shared building blocks replacing inline allOf + status const patterns
  • embedded.openrpc.json: Extracted components/schemas for ucp_success, payment_instruments, payment_instrument_selection
  • Removed embedded_error_code.json, embedded_message_error.json, embedded_error_response.json — three files duplicating the error chain for different examples on a freeform string. ECP error codes are documented in spec prose.

Other fixes

  • ec.ready checkout: Replaced $ref to full checkout.json with inline partial type matching what ec.ready actually promises (display-only host hints)
  • ec.ready failure lifecycle: Host MUST tear down on handshake error
  • Notification cleanup: Reverted oneOf [resource, error_response] on messages.change notifications back to plain $ref — session errors go through ec.error / ep.cart.error

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing
    functionality to not work as expected, including removal of schema files
    or fields
    )
  • Documentation update

Is this a Breaking Change or Removal?

If you checked "Breaking change" above, or if you are removing any schema
files or fields:

  • I have added ! to my PR title (e.g., feat!: remove field).
  • I have added justification below.
## Breaking Changes / Removal Justification

JSON-RPC 2.0 reserves the top-level error field for transport-level failures (parse errors, method not found, invalid params). Delegation outcomes like "user cancelled" or "payment method not supported" are application-level results — the RPC call succeeded, but the outcome was negative.

The previous design conflated these two failure planes, causing two problems:

  1. Middleware can't distinguish transport failures from domain outcomes.
  2. Inconsistency with other UCP transports — REST and MCP return domain errors inside the response body, not via transport error mechanisms.

Moving delegation errors to result.error restores correct JSON-RPC layering: error means the message itself failed; result.error means the delegation produced a negative outcome.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@westeezy westeezy requested review from a team as code owners March 31, 2026 23:36
@westeezy westeezy requested review from jingyli and richmolj March 31, 2026 23:36
@westeezy
Copy link
Copy Markdown
Contributor Author

westeezy commented Mar 31, 2026

Alternative worth exploring

This PR uses the standard error_response shape, but ECP's success responses nest under checkout while error_response does not — making type-safe discrimination less clean than in REST/MCP. It also puts ucp.version on host responses where the version semantics are ambiguous as what would happen if it differed from the initially negotiated version in the url ec_version.

A lighter shape that reuses Message but drops the ucp wrapper could sidestep both issues at the cost of symmetry could be:

{ "status": "error", "messages": [{ "type": "error", "code": "abort_error", "content": "...", "severity": "recoverable" }]

In this case result.status === "error" becomes a clean discriminator, and we remove the ambiguous version metadata from host-originated errors. However then ECP will differ from the transport error shape.

Comment thread source/services/shopping/embedded.openrpc.json
Comment thread source/services/shopping/embedded.openrpc.json
Copy link
Copy Markdown
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

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

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 version
  • ec.payment.*: { ucp, checkout } — partial update with envelope
  • ec.fulfillment.*: same
  • ec.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.

Comment thread source/schemas/shopping/types/error_code.json Outdated
@westeezy
Copy link
Copy Markdown
Contributor Author

westeezy commented Apr 2, 2026

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

Comment thread docs/specification/embedded-checkout.md
Comment thread docs/specification/embedded-checkout.md Outdated
Comment thread source/schemas/shopping/types/embedded_message_error.json Outdated
Comment thread source/services/shopping/embedded.openrpc.json
Comment thread source/services/shopping/embedded.openrpc.json
Comment thread docs/specification/embedded-checkout.md
westeezy and others added 8 commits April 6, 2026 09:43
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.
@igrigorik igrigorik force-pushed the feat/ecp-error-conformance branch from 10b54bd to 08d9828 Compare April 6, 2026 17:14
  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.
@igrigorik igrigorik changed the title feat!(embedded): align delegation errors with UCP error conventions feat!: align embedded prorotocol errors with UCP error conventions Apr 6, 2026
@igrigorik igrigorik requested review from igrigorik and jingyli April 6, 2026 18:23
  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.
@igrigorik igrigorik force-pushed the feat/ecp-error-conformance branch from e4f2bb7 to c26768e Compare April 6, 2026 19:30
@igrigorik igrigorik requested a review from a team as a code owner April 6, 2026 19:30
@igrigorik igrigorik requested review from damaz91 and wry-ry April 6, 2026 19:30
  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.
@igrigorik igrigorik changed the title feat!: align embedded prorotocol errors with UCP error conventions feat!: align embedded protocol errors with UCP error conventions Apr 6, 2026
@mmohades
Copy link
Copy Markdown

mmohades commented Apr 7, 2026

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:

  • Matches MCP & REST exactly: checkout-mcp.md already explicitly separates Protocol Errors (JSON-RPC error) from Business Outcomes (JSON-RPC result). Doing the same here means developers only need to learn one mental model for all of UCP.
  • Reuses the MCP error schema: Following the established data.code and data.content pattern for errors ensures our API surface remains 100% consistent (as shown for MCP here).
  • Keeps capability specs clean: A blocked popup or a cancelled sheet is failure by the Host to fulfill the requested interaction, not a commerce business logic failure.

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 error objects by the Host when it fails to fulfill a request (such as a delegation or auth request) initiated by the Business.

Code Message data.code Meaning
-32000 "Unauthorized" "unauthorized" The request lacks valid authentication credentials or authorization to perform the operation (e.g., host origin validation failed).
-32000 "Request timed out" "timeout" The server or host did not fulfill the request within the allowed time period (e.g., network timeout during handshake).
-32000 "User aborted" "aborted" The client or user intentionally terminated the operation before it could be completed (e.g., closed the interface).
-32000 "Window open rejected" "rejected" The host understood the request but refused to fulfill or authorize the action due to local policy (e.g., blocked window navigation).
-32000 "Not allowed" "not_allowed" The requested operation cannot be performed in the current execution context (e.g., sending start before completing handshake).
-32601 "Method not found" N/A The method specified is not recognized or supported by the host.
-32603 "Internal error" "internal" An unexpected condition was encountered that prevented the request from being fulfilled.

@igrigorik @westeezy, and others please share your thoughts!

@igrigorik
Copy link
Copy Markdown
Contributor

@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 result is for.

For auth, ec.auth is UCP application-level protocol for credential exchange. It's business logic that happens to use JSON-RPC as transport, not a JSON-RPC authentication mechanism. Auth outcomes (timeout, unsupported type, success) are results of that business logic executing, so they belong in result with severity guiding the next step.

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.

@mmohades
Copy link
Copy Markdown

mmohades commented Apr 7, 2026

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:

  1. Explicitly document this mental model: Let's add a short paragraph to the Error Handling section explaining this boundary. Without clarifying that we treat interactive/auth outcomes as application results, developers coming from traditional REST/RPC backgrounds might be confused as to why a failure returns a successful JSON RPC result envelope.
  2. Be explicit with protocol-level boundaries: When a request can't be safely ingested or authorized (e.g., malformed JSON, unknown methods, or failed origin validation), let's explicitly return a top-level JSON-RPC error. This creates a clear signal for developers that the execution failed at the protocol boundary and never reached the business logic layer.
  3. 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.

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."
    }
  }
}
Code Message data.code Meaning
-32700 "Parse error" N/A Invalid JSON was received by the host.
-32600 "Invalid Request" "invalid_request" The JSON sent is not a valid Request object or lacks valid origin (e.g., host origin validation failed).
-32601 "Method not found" N/A The method specified is not recognized or supported by the host.
-32602 "Invalid params" "invalid_params" Invalid method parameter(s) were provided.
-32603 "Internal error" "internal" An unexpected condition was encountered that prevented the request from being fulfilled.

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"
      }
    ]
  }
}
Code Severity Trigger / Scenario
abort recoverable The buyer closed the sheet, declined the prompt, or cancelled the flow.
not_supported unrecoverable The requested auth, payment method change, credential, etc is not supported.
timeout recoverable The request processing didn't finish in the expected time.
not_allowed recoverable The request is not allowed in the current context (e.g. ec.start sent before handshake completed.)
window_open_rejected unrecoverable Host policy or environment restrictions blocked the requested navigation/window.

Copy link
Copy Markdown
Contributor

@jingyli jingyli left a comment

Choose a reason for hiding this comment

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

Thanks @westeezy @igrigorik, the main constructs LGTM! Left some style-related comments + 2 structural ones related to:

  1. 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?
  2. By removing error_response from xx.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 via xx.error as 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Naive QQ: Should this be **MUST** instead? Is it **SHOULD** only because we anticipate severity: "requires_escalation" to be a legitimate alternative..?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread docs/specification/embedded-cart.md

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The host MUST tear down the embedded context and MAY redirect the buyer to
continue_url if 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:

  1. Host responds with error_response
  2. Embedded Cart echoes with ep.cart.error containing a possible continue_url
  3. Upon receiving 2), host proceed with tear down & redirect
  4. 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree with @igrigorik in this regards. The continue_url should already be available and in terminal errors I wouldn't expect step 2.

Comment thread docs/specification/embedded-checkout.md
Comment thread docs/specification/embedded-checkout.md
"type": "object",
"description": "Partial checkout update with fulfillment address selection.",
"properties": {
"fulfillment": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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..?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Comment thread source/services/shopping/embedded.openrpc.json
@igrigorik
Copy link
Copy Markdown
Contributor

@jingyli responded to your questions, ptal.

@mmohades:

  1. Document the mental model: The two-layer model (transport errors vs business outcomes) is defined in overview.md and referenced by each transport spec. For embedded, docs: extract shared EP core, group transports in nav #339 adds this to the shared embedded-protocol.md Response Handling section — same pattern checkout-mcp and catalog-mcp already use.
  2. Protocol error boundaries: Also in docs: extract shared EP core, group transports in nav #339embedded-protocol.md now has a Transport Errors section with a concrete -32601 example and pointer to the error code registry in overview.md.
  3. Direction: as @jingyli noted above, the direction isn't uniformly inverted — ec.error / ep.cart.error flow business->host. The error model is direction-agnostic; what matters is transport vs application, not who sends it. The error code tables in docs: extract shared EP core, group transports in nav #339 cover both shared codes (abort_error, security_error, etc.) and capability-specific codes (not_allowed_error, window_open_rejected_error in checkout).

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.

Copy link
Copy Markdown
Contributor

@jingyli jingyli left a comment

Choose a reason for hiding this comment

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

Thanks @igrigorik! My outstanding questions are resolved between #339 and inline responses here.

@westeezy
Copy link
Copy Markdown
Contributor Author

westeezy commented Apr 8, 2026

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.

@igrigorik
Copy link
Copy Markdown
Contributor

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.

@igrigorik igrigorik merged commit 3fe51c6 into Universal-Commerce-Protocol:main Apr 8, 2026
11 checks passed
@jingyli jingyli added the enhancement New feature or request label Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants