Skip to content

feat: add state-bound Offer extension for Cart proposals - #773

Open
arjun2075 wants to merge 4 commits into
Universal-Commerce-Protocol:mainfrom
arjun2075:feat/offer-state-bound-cart-738
Open

feat: add state-bound Offer extension for Cart proposals#773
arjun2075 wants to merge 4 commits into
Universal-Commerce-Protocol:mainfrom
arjun2075:feat/offer-state-bound-cart-738

Conversation

@arjun2075

Copy link
Copy Markdown

Summary

Implements the smallest structural/transport portion of #738:

  • adds a state-bound Offer extension for Cart (dev.ucp.shopping.offer);
  • represents the Business-computed proposed target line_items;
  • binds the Offer to applies_to.cart_id + applies_to.state_ref;
  • identifies the Offer operationally using id + revision;
  • adds a reference-based apply_offer operation;
  • keeps REST and MCP semantics aligned;
  • reuses normal UCP idempotency;
  • keeps the returned Cart authoritative.

Why

UCP can represent a Cart and let the Platform update it, but has no explicit
representation for:

given this exact Cart state, the Business proposes this resulting Cart state.

This turns the design settled in #702 into an executable schema/transport slice.

The extension follows the discount.json composition pattern ($defs + allOf
over cart.json) rather than modifying the Cart schema. proposed_update.line_items
reuses existing Cart full-replacement semantics, so there is no second mutation
vocabulary.

apply_offer deliberately diverges from the discount precedent, which routes
application through the normal cart update request. A discount code is just a
string, so there is nothing precomputed to echo. An Offer's value is that the
Business already computed the resulting state, so requiring the Platform to
resubmit those line_items would duplicate authoritative state and create a
drift case between the stored proposal and the resubmitted one. The Platform
references offer_id + revision; the Business applies the exact stored revision.

Key invariant

applicable =
    source state still matches
    AND not expired
    AND Business can still honor the proposal

These are conjunctive, so a stale-but-unexpired Offer fails closed. Applying one
Offer moves the Cart to a new state and makes sibling Offers bound to the previous
state stale.

Runtime conditions are expressed as normative operation semantics rather than
forced into JSON Schema, since they depend on live Cart state.

Scope deliberately deferred

This PR does not implement:

state_ref is intentionally opaque and implementation-assigned. If another
extension needs the same state identity, that seems like the right point to
introduce a shared Cart primitive rather than inventing one here.

Testing

Commands actually run, with results:

Command Result
ucp-schema lint source/ 125 files checked, all passed
uv run python scripts/validate_examples.py --schema-base source/schemas/ 343 passed, 0 failed, 0 errors
uv run python scripts/test_validate_examples.py 52 passed, 0 failed
uv run yamllint -c .github/linters/.yamllint.yml . pre-existing line-length warnings only
cspell lint (3 changed files) 0 issues
DOCS_MODE=spec uv run mkdocs build --strict 47 warnings, byte-identical to main baseline; no new warnings

Additional local verification (ad-hoc, not committed): resolved every $ref in
both service documents; confirmed the extension composes against cart.json and
validates a full Offer example; confirmed each decision-relevant field is required,
that an empty state_ref is rejected, that presentation is optional, and that
impact.total_delta accepts negative values; confirmed REST and MCP take the same
{id, offer_id, revision} inputs, both require idempotency, and neither carries a
request body.

Relationship

Implements the structural/transport portion of #738. Design history in #702.

@google-cla

google-cla Bot commented Aug 26, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Adds the structural/transport slice of the Offer design from Universal-Commerce-Protocol#738:

* `dev.ucp.shopping.offer` extends Cart via the standard `$defs`/`allOf`
  extension-composition pattern, following `discount.json` precedent;
* an available Offer carries the Business-computed `proposed_update.line_items`
  using existing Cart full-replacement semantics, rather than a second
  mutation vocabulary;
* each Offer is bound to the state it was generated against through
  `applies_to.cart_id` and an opaque, implementation-assigned `state_ref`,
  so a stale-but-unexpired Offer fails closed;
* `id` + `revision` is the operational identity of an immutable Offer artifact;
* `impact.total_delta` uses `signed_amount` so a proposal may lower the total;
* `apply_offer` applies a stored proposal by reference over both REST and MCP,
  reusing UCP's existing idempotency mechanism and returning the
  authoritative Cart.
@arjun2075
arjun2075 force-pushed the feat/offer-state-bound-cart-738 branch from 50dd5e9 to 73285e4 Compare August 26, 2026 11:06
@arjun2075
arjun2075 marked this pull request as ready for review August 26, 2026 11:11
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Aug 26, 2026
@carolinerg1 carolinerg1 added status:under-review gov:needs-tc-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 27, 2026
Incorporates the actionable review feedback from Universal-Commerce-Protocol#738 without expanding
the surface area of this PR. Description-only changes; no schema shape,
endpoint, or parameter changes.

* `state_ref` now specifies exact opaque comparison semantics: the
  Business compares using exact token-value equality, and implementations
  MUST NOT parse, normalize, canonicalize, or semantically compare the
  value. JSON serialization/escaping is deliberately kept out of the
  state identity contract.
* The three Offer invalidation reasons are machine-distinct via
  `messages[].code` — `offer_source_state_mismatch`, `offer_expired`,
  and `offer_unavailable` — and MUST NOT be collapsed into a single
  generic invalid-offer outcome, so an agent can pick the right recovery
  (re-evaluate the Cart, discard the Offer, or refresh alternatives).
  These use ordinary UCP business-outcome messages rather than new
  transport-level error machinery.
* Idempotent replay is distinguished from a new application attempt: a
  replay returns the cached original result without re-executing the
  Offer or re-evaluating `state_ref`, while any new logical attempt MUST
  evaluate the Offer against the Cart's current state. This preserves
  normal UCP idempotency semantics instead of making a retry fail stale.

REST and MCP describe identical semantics, differing only in existing
transport terminology.
@arjun2075
arjun2075 force-pushed the feat/offer-state-bound-cart-738 branch from 63078ad to 015d4c8 Compare August 28, 2026 18:01
@arjun2075

Copy link
Copy Markdown
Author

Hi maintainers — following up on this Offer extension PR. The CLA check is passing, and the PR is currently awaiting the required governance approvals.

The scope is limited to the state-bound Offer schema and REST/MCP apply_offer operation from #738. Commercial-intervention provenance (#724) and the broader lifecycle contract (#788) remain separate.

Could someone from the TC take an initial pass, or point me to the appropriate reviewer? Feedback on the reference-based application operation and source-state validity semantics would be particularly helpful. Happy to address changes or narrow the scope further.

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.

3 participants