feat: extension-defined Actions primitive#582
Conversation
Add Actions as the machine-readable representation of outstanding work that a
Platform can perform while a Cart or Checkout progresses. Actions are
response-only, keyed by the active extension that defines their behavior, and
carry a small common instance envelope: a stable ID, whether the instance is
required, and optional extension-owned configuration.
For example, a student-verification extension can return an Action alongside a
provisional discount and an explanatory Message:
{
"actions": {
"com.example.identity.student_verification": [{
"id": "verify-student-1",
"required": true,
"config": {
"verification_url": "https://business.example.com/verify/abc"
}
}]
},
"messages": [{
"type": "info",
"code": "eligibility_accepted",
"content": "Student discount applied provisionally. Verify your status."
}]
}
The Action identifies the outstanding work and provides its structured
configuration. Messages remain orthogonal: they explain resource state and
provide buyer-facing context, but do not define how the Action is processed or
determine its outcome.
Integrate Actions with the core Cart and Checkout models. Cart remains
statusless and permits unrelated mutations while an Action is outstanding.
Checkout uses its existing lifecycle: required Actions can keep a checkout
incomplete, ready-for-complete checkouts have none, and Complete Checkout can
return complete_in_progress with a required Action and no order. After
processing it, the Platform polls Get Checkout for the authoritative result
rather than issuing another Complete request.
Represent each response as a complete snapshot of outstanding Action instances.
Keep IDs stable for the same obligation, assign replacements new IDs, and let
each defining extension own configuration, processing, trust, fallback, and
outcome semantics.
Reuse existing capability negotiation and allOf composition so concrete
extensions register and validate their own Action keys without introducing a
separate Action registry, execution protocol, or generic state machine.
|
Thanks for the deep thinking on actions Ilya! I like how you're approaching this, but I've got a few questions to see if theres still some rough edges we need to polish.
My read is that: "actions": {
"com.example.identity.student_verification": [...]
} means com.example.identity.student_verification is not just the owner namespace, it is the concrete Action If that is not the intention, I think we’re missing a common type/code discriminator on the instance itself. Example: "actions": {
"com.example.payments.device_data_collection": [...],
"com.example.payments.three_ds_challenge": [...]
} as separately negotiated extensions, or one payment extension with multiple action types inside it? If it’s the latter, If it’s the former, I think we should explicitly call out the tradeoff. There was TC concern around bloating negotiation
I like that this keeps Actions from becoming a separate lifecycle, but I’m trying to understand the edge where an Action Example: Platform sends Update Checkout to set field X. Business says: “to accept that field update, first complete In this PR, I see required: true, plus resource status/messages. But how does the platform know this Action is
I agree that the concrete extension should own its trust model, but I think core should still keep a few baseline MUSTs.
Wondering if it still makes sense to include these, especially the one on unsupported runtime negotiations. I imagine this will come up when URLs are rejected due to an unsupported 3rd party delegate; I think we need language on that similar to how we handle window.open requests for EC. Appreciate your impact here! 🚀 |
The original Actions contract equated each map key with the extension that
defined it. That framing implied a one-to-one relationship between extensions
and Actions, making it unclear how one negotiated extension could expose
several distinct runtime contracts such as device-data collection and a 3DS
challenge.
Define each reverse-domain map key as a concrete Action type declared by an
active extension. An extension may use its own name for a single Action type or
declare several distinct types within a namespace controlled by its schema
authority. Negotiating the extension activates the complete contract it
declares.
For example, one `com.example.payment.authentication` extension can declare
two independently dispatched Action types:
{
"actions": {
"com.example.payment.authentication.device_data_collection": [{
"id": "collect-device-data-1",
"required": true
}],
"com.example.payment.authentication.three_ds_challenge": [{
"id": "challenge-cardholder-1",
"required": true
}]
}
}
This preserves direct dispatch and homogeneous instance arrays without adding
a per-instance type discriminator. Existing allOf composition lets the
declaring extension contribute each type key and validate its config, while
existing reverse-domain governance provides namespace ownership without an
Action-specific registry.
Clarify that the map defines no processing order across Action types. JSON
preserves order within each type's array, but the declaring extension decides
whether that order has processing meaning and defines any cross-type
sequencing.
Align Cart and Checkout terminology around Action-defined gated effects and
update the Student Verification example to show that an extension may use its
own name for its single Action type.
This changes the semantic definition, not the wire shape: Action instances
remain response-only objects containing id, required, and optional config;
non-empty arrays, stable identity, parent lifecycle, and idempotency rules are
unchanged.
A required Action describes a gate on the effect defined by its Action type,
but it does not say whether a particular operation was accepted. Adding a
separate blocking severity would conflate those dimensions: the same Action can
remain required while also explaining why one attempted effect was not applied.
Use the existing Message contract to express that relationship. A Business
returns the current resource with a recoverable error Message whose RFC 9535
path selects the related Action occurrence. Info and warning Messages may use
the same path relationship to explain outstanding work without reporting an
operation failure.
For example:
{
"status": "incomplete",
"actions": {
"com.example.identity.student_verification": [{
"id": "verify-student-1",
"required": true,
"config": {
"verification_url": "https://business.example.com/verify/abc"
}
}]
},
"messages": [{
"type": "error",
"code": "eligibility_verification_required",
"severity": "recoverable",
"path": "$.actions['com.example.identity.student_verification'][0]",
"content": "Verification is required before this effect can be applied."
}]
}
This keeps the signals orthogonal: the Action defines outstanding work and
requiredness, the Message reports the response-scoped operation outcome, and
the parent resource status remains authoritative for lifecycle.
Clarify the Checkout consequences. If a required Action prevents Complete
Checkout from being accepted, the Business returns the current Checkout with
status incomplete and a recoverable error pointing to the Action. A
complete_in_progress response instead means Complete Checkout was accepted for
asynchronous processing, including when that accepted flow waits for a required
Action.
Processing an Action does not automatically replay the earlier request. The
Platform obtains the latest Checkout through Get Checkout or a subsequent
Update response, and any later re-drive is a new operation governed by the
existing idempotency rules.
Broaden recoverable errors to include conditions resolved through a related
Action, not only input modification. Keep Message paths as ordinary positional
RFC 9535 locations in the returned response without adding naming rules,
stable-index guarantees, or an Action-specific Message type.
The wire shape remains unchanged: Actions still contain id, required, and
optional config, and Messages retain their existing type, code, severity,
content, and optional path fields.
Negotiating an extension confirms support for its complete Action contract, but it does not make every future config value or delegate trustworthy. Without an explicit boundary, open config could be mistaken for executable instructions and surface completion for a Business result. Require Platforms to process executable behavior only as defined by the active Action-type contract, allow stricter runtime policy and instance rejection, and keep later Business resource state authoritative. Carry #553's concrete security and fallback concerns into conditional Action-type authoring guidance. Extension specifications define executable fields, trust anchors, interaction controls, completion observation, failure behavior, and fallback only where relevant. This preserves the existing wire shape without introducing a generic executor, callback, or state model.
Expose response-only Actions on Catalog Search, batch Lookup, and successful Get Product responses so negotiated extensions can gate or explain catalog effects without introducing a Catalog workflow. Keep existing product payload requirements intact, allow Businesses to return zero, some, or all otherwise relevant results, and require a fresh Catalog operation after Action processing. Clarify the shared Action, Message, identity, sequencing, and trust rules while preserving Checkout as the lifecycle authority.
| Action according to its Action type's contract, the Platform **SHOULD** use | ||
| [Get Checkout](#get-checkout) or a subsequent | ||
| [Update Checkout](#update-checkout) response to obtain the latest Checkout. | ||
| * When Complete Checkout returns `complete_in_progress`, the Platform processes |
There was a problem hiding this comment.
The PR adds a case where complete_in_progress means the Business is waiting on
the Platform rather than working internally. The accept-time rejection is
covered ("the Business MUST return the current Checkout with status: incomplete
and a recoverable error Message"), but I could not find the path for a required
Action that is declined, abandoned, or fails after complete_in_progress was
entered.
Reading the rest of the spec, the exits look like Cancel Checkout (which SHOULD
succeed from any non-terminal state) or expires_at (default 6h TTL, and
canceled can occur from any state). Both work.
Should this section state that explicitly? Something like: if the Platform
declines or cannot complete a required Action in complete_in_progress, it
SHOULD Cancel Checkout rather than continue polling, and the session otherwise
expires per expires_at. Without it, a Platform's only documented move in that
state is Get Checkout, which never terminates on its own.
|
Reviewing this as a concrete non-3DS / async consumer — we're modeling Pix (Brazil, via Mercado Pago) and it drops into this primitive with zero schema change, which is a good signal for the shape. At Two things worth confirming: 1. Trust boundary for render fields. The Trust and Execution Boundaries section resolves a concern we'd raised earlier (render fields sitting in opaque 2. Work that outlives the session. This primitive ties Actions to the checkout lifecycle — (Also relevant to your own open point on transition-blocking precision: for a pure-render Action the |
This PR introduces actions, a shared response-only shape for outstanding extension-defined work. Cart, Checkout, and Catalog adopt the shape without adding an Actions capability, registry, service, or lifecycle.
Each reverse-domain map key identifies a concrete Action type declared by an active negotiated extension. Its value is a non-empty array of instances with
id,required, and optional extension-ownedconfig. One extension may declare multiple Action types. Negotiating its version activates the complete contract, so related types can be negotiated and versioned together through existing extension machinery.Example
This Checkout response composes a line, provisional Discount, required Student Verification Action, and correlated Message:
{ "ucp": { "version": "2026-01-01", "status": "success", "payment_handlers": {} }, "id": "chk_student_1", "status": "incomplete", "currency": "USD", "context": { "eligibility": ["org.example.student"] }, "line_items": [...], "discounts": { "applied": [ { "title": "Student 10% Off", "amount": 500, "automatic": true, "provisional": true, "eligibility": "org.example.student", "allocations": [ {"path": "$.line_items[0]", "amount": 500} ] } ] }, "totals": [ {"type": "subtotal", "amount": 5000}, {"type": "items_discount", "amount": -500}, {"type": "total", "amount": 4500} ], "links": [ {"type": "terms_of_service", "url": "https://business.example.com/terms"} ], "actions": { "com.example.identity.student_verification": [ { "id": "verify-student-1", "required": true, "config": { "verification_url": "https://business.example.com/verify/abc" } } ] }, "messages": [ { "type": "info", "code": "eligibility_accepted", "content": "Student discount applied provisionally. Verify your student status to keep it.", "path": "$.actions['com.example.identity.student_verification'][0]" } ] }The active "Student Verification" extension defines the Action type, validates
config, and specifies how the Platform processes it. The Message explains the current response and points to the exact Action occurrence; it does not define processing or lifecycle.Common behavior
A newly processed successful response includes every outstanding Action and omits
actionswhen none are outstanding. While the same work persists, it keeps the same key andid; a replacement receives a newid.requiredmeans the Action gates the effect defined by its type. It does not say that an operation failed, determine the parent lifecycle, or define how many Catalog products are returned. Messages correlate outcomes with Actions using positional RFC 9535 paths such as:A recoverable error Message pointing to an Action says the requested effect was not applied because of it; info and warning Messages may point to an Action without reporting failure.
Category