Skip to content

feat: extension-defined Actions primitive#582

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

feat: extension-defined Actions primitive#582
igrigorik wants to merge 7 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
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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants