feat: add state-bound Offer extension for Cart proposals - #773
Conversation
|
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.
50dd5e9 to
73285e4
Compare
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.
63078ad to
015d4c8
Compare
|
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. |
Summary
Implements the smallest structural/transport portion of #738:
Offerextension for Cart (dev.ucp.shopping.offer);line_items;applies_to.cart_id+applies_to.state_ref;id+revision;apply_offeroperation;Why
UCP can represent a Cart and let the Platform update it, but has no explicit
representation for:
This turns the design settled in #702 into an executable schema/transport slice.
The extension follows the
discount.jsoncomposition pattern ($defs+allOfover
cart.json) rather than modifying the Cart schema.proposed_update.line_itemsreuses existing Cart full-replacement semantics, so there is no second mutation
vocabulary.
apply_offerdeliberately diverges from the discount precedent, which routesapplication 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_itemswould duplicate authoritative state and create adrift case between the stored proposal and the resubmitted one. The Platform
references
offer_id+revision; the Business applies the exact stored revision.Key invariant
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:
id+revisionis operationalidentity here; evidence identity is left to a shared convention;
state_refis intentionally opaque and implementation-assigned. If anotherextension 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:
ucp-schema lint source/uv run python scripts/validate_examples.py --schema-base source/schemas/uv run python scripts/test_validate_examples.pyuv run yamllint -c .github/linters/.yamllint.yml .cspell lint(3 changed files)DOCS_MODE=spec uv run mkdocs build --strictmainbaseline; no new warningsAdditional local verification (ad-hoc, not committed): resolved every
$refinboth service documents; confirmed the extension composes against
cart.jsonandvalidates a full Offer example; confirmed each decision-relevant field is required,
that an empty
state_refis rejected, thatpresentationis optional, and thatimpact.total_deltaaccepts negative values; confirmed REST and MCP take the same{id, offer_id, revision}inputs, both require idempotency, and neither carries arequest body.
Relationship
Implements the structural/transport portion of #738. Design history in #702.