Skip to content

feat: extension-defined Actions primitive#582

Open
igrigorik wants to merge 13 commits into
mainfrom
feat/actions
Open

feat: extension-defined Actions primitive#582
igrigorik wants to merge 13 commits into
mainfrom
feat/actions

Conversation

@igrigorik

@igrigorik igrigorik commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

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-owned config. 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 actions when none are outstanding. While the same work persists, it keeps the same key and id; a replacement receives a new id.

required means 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:

$.actions['com.example.identity.student_verification'][0]

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

  • Core Protocol
  • Capability
  • Documentation

   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.
@igrigorik igrigorik self-assigned this Jul 12, 2026
@igrigorik
igrigorik requested review from a team as code owners July 12, 2026 04:57
@igrigorik igrigorik added the TC review Ready for TC review label Jul 12, 2026
@raginpirate

Copy link
Copy Markdown
Contributor

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.

  1. Is each Action key a single concrete Action type?

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
contract/type. In other words: multiple instances under that key are multiple instances of the same Action type.

If that is not the intention, I think we’re missing a common type/code discriminator on the instance itself. Example:
one payment handler may need both device data collection and a 3DS challenge. Are those intended to be:

  "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,
I don’t see how the platform routes generically without inspecting opaque config.

If it’s the former, I think we should explicitly call out the tradeoff. There was TC concern around bloating negotiation
for Actions, and this feels adjacent to why delegated identity providers are not modelled as independently negotiated
services. Maybe that tradeoff is still right here, but I want to make sure we’re choosing it intentionally.

  1. Transition-blocking state feels less precise than feat: Introduce Actions and apply it to Checkout #553.

I like that this keeps Actions from becoming a separate lifecycle, but I’m trying to understand the edge where an Action
blocks the last attempted transition, not just eventual checkout completion.

Example: Platform sends Update Checkout to set field X. Business says: “to accept that field update, first complete
CAPTCHA.” In #553, severity: blocking gave a generic machine signal: the attempted transition is blocked; resolve this
Action, then retry/change/escalate that transition.

In this PR, I see required: true, plus resource status/messages. But how does the platform know this Action is
specifically required to resolve the last attempted state transition, versus generally required before completion? Is
the expectation that the extension defines that entirely? Should the platform retry the same Update after processing the
Action, or Get Checkout and infer state? If messages are supposed to carry that nuance, I worry we’re moving machine
control-flow semantics into explanatory text.

  1. Security/trust baseline seems to have lost some useful granularity from feat: Introduce Actions and apply it to Checkout #553.

I agree that the concrete extension should own its trust model, but I think core should still keep a few baseline MUSTs.
#553 had useful guardrails like:

  • arbitrary URLs in config are not loadable just because they are URLs;
  • the Action spec must define origin/trust validation;
  • platforms must not interpolate opaque config into executable contexts;
  • completion signals are not authoritative unless independently verifiable;
  • unsupported/abandoned/expired/failed behavior must be defined.

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! 🚀

@damaz91 damaz91 added status:needs-triage Signal that the PR is ready for human triage and removed status:needs-triage Signal that the PR is ready for human triage labels Jul 14, 2026
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.
@igrigorik igrigorik added this to the Working Draft milestone Jul 14, 2026
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.
@igrigorik igrigorik changed the title feat: extension-defined Actions with Cart and Checkout feat: extension-defined Actions primitive Jul 15, 2026
Comment thread docs/specification/checkout.md Outdated
@gforgab

gforgab commented Jul 17, 2026

Copy link
Copy Markdown

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 complete_in_progress the PSP hands back a render artifact (QR image + copy-paste "copia e cola" string + short expiry). It fits as a pure-render Action: config is optional and extension-owned, nothing to load/submit in-band, and it resolves exactly through the path this PR already prescribes — Platform renders, then polls Get Checkout until completed, never re-driving Complete. No mode/execution/executor needed, which lines up with the "no generic machinery" stance here.

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 config): since "the active Action-type contract defines which config fields a Platform processes and what they mean," the declaring extension is the right home for image / code / instructions_url, and the contract must explicitly mark which are renderable vs. loadable. Reading that as intended? For a display Action specifically that means the extension pins image to data:/https: shown as an image only (never HTML/script), code as display text, and instructions_url to an origin/scheme allowlist — exactly the per-type machinery you're pushing down to the extension.

2. Work that outlives the session. This primitive ties Actions to the checkout lifecycle — ready_for_complete, completed, and canceled all state "no required Action remains outstanding," and resolution is polling Get Checkout. That fits seconds-scale Pix cleanly. It intentionally does not fit the same family that settles in hours-to-days (boleto, cash vouchers), which outlives the checkout session and has no in-band re-drive. We read that as out of scope here by design, and are scoping it to a separate order-level EP resolved through the existing Order Event Webhook (dev.ucp.shopping.order) rather than stretching actions. Flagging so the boundary is explicit — does that match your intent?

(Also relevant to your own open point on transition-blocking precision: for a pure-render Action the required: true + recoverable error Message with path is what disambiguates "gated on out-of-band payment" from "generally required before completion" — a second, non-3DS data point that the Message/path mechanism carries that gating fine without a dedicated field.)

   An Action can report completion before the Business reflects its result,
   leaving the Platform at risk of repeating external work, resubmitting
   Complete Checkout, or checking state indefinitely.

   Make Business-reported Checkout state authoritative: use bounded Get
   Checkout requests through expires_at, prevent unsafe Action retries, and
   order fallback, continue_url handoff, and cancellation. Extend the
   authoring guide so concrete Action contracts define timeout, backoff,
   safe-retry, and failure behavior without adding generic polling fields.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@gforgab nice, this is great example and application of action primitive!

Your trust reading is right: the Pix contract can require its QR/code fields and define which values are inert text versus loadable content, along with schemes, origins, expiry, and fallback. But there is also a meta question here around presentation: in theory the whole experience can be a URL that platform can load, or it can be a more structured contract that platform can render in 1P surface. Actions, as a primitive, can support both.

This primitive ties Actions to the checkout lifecycle — ready_for_complete, completed, and canceled all state "no required Action remains outstanding," and resolution is polling Get Checkout. That fits seconds-scale Pix cleanly. It intentionally does not fit the same family that settles in hours-to-days (boleto, cash vouchers), which outlives the checkout session and has no in-band re-drive.

Correct.

Comment thread docs/specification/catalog/index.md Outdated

@raginpirate raginpirate left a comment

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.

Thanks for the hard work aligning here @igrigorik
While giving this a deep think I ran into two edges I want to make sure we're on the same page about before merging this:

  • the operations allowed against a checkout in complete_in_progress, which influences how much actions can really do.
  • the need for required; if I might be missing the complexity reduction that this bool brings us now that we have a great approach leveraging messages when possible.

Comment thread docs/documentation/schema-authoring.md
Comment thread source/schemas/common/types/actions.json Outdated
Comment thread docs/specification/checkout.md Outdated
Comment thread docs/specification/catalog/index.md Outdated
Comment thread docs/specification/checkout.md
@gforgab

gforgab commented Jul 20, 2026

Copy link
Copy Markdown

Thanks @igrigorik! On the presentation meta-question — for Pix we'd deliberately pick the structured render contract over a load-a-URL experience, and I think it's a useful data point for why Actions supporting both matters:

  • The Pix artifact is inherently non-web: a QR image + a copy-paste "copia e cola" string the buyer takes to their bank app. The optimal UX is the platform rendering it natively (image + a copy button), which works identically on a web surface, a native app, or a conversational/voice agent that can't load a frame at all.
  • A load-a-URL variant would re-introduce the loadable-content trust surface the contract is meant to constrain, and it degrades or breaks on headless/1P surfaces — the exact case where the structured contract shines.

Concretely, the Pix config would define inert, platform-rendered fields (image as data:/https: shown as an image only, code as display text, expires_at) with no HTML/script/frame to load and nothing executable; the only loadable field would be an origin-allowlisted instructions_url fallback. Resolution itself stays the out-of-band poll on Get Checkout.

The existing worked example (Student Verification's verification_url) already covers the URL-in-config shape, so Pix's additive value is being a pure-render, no-loadable-URL example — happy to contribute it as one if useful. (I won't presume how 3DS/DDC lands, since that shape is still being worked in #517/#458.) I'm also following the thread on whether required stays for the first release, since it touches how a render Action signals gating.

@drewolson-google drewolson-google left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM after our discussing in the Tech Council. Looks like there are some failing checks, but otherwise I'm aligned with these changes.

   Catalog operations do not share a resource lifetime, making the common
   cross-response ID rule ambiguous.

   Define uniqueness within each response, retain stable IDs across
   representations of the same parent resource, and allow independent Catalog
   calls to reuse IDs without implying correlation.
   The per-instance required flag did not represent a distinction used by any
   current Action and made gating easy to conflate with Message severity.
   Accepted asynchronous completion also left Update availability ambiguous,
   risking a full replacement racing Business order processing.

   Make every emitted Action gate its Action-type-defined effect, leave Messages
   to explain responses and operation outcomes, and remove required from the
   common schema and adopting capability examples.

   Treat complete_in_progress as frozen for new Update and Complete operations.
   Keep Get Checkout authoritative, preserve replay-protected duplicate responses,
   and align the core, REST, MCP, OpenAPI, and OpenRPC contracts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants