portage-ucp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: add1fc4dcd1b12c3dea3ade248972c2fd2616233b074230838daccce41c9033d
4
- data.tar.gz: e75364c190c83ee0b9f6bc43f2aca2ef5067d8a4baf3ad5c1b6534fd1096abd7
3
+ metadata.gz: 8ce343c7abccfbe4c1a8cf2ed0db6340d588c770e959fef42aec644ef6a69800
4
+ data.tar.gz: 7d1a96bb2e150b822c1dedbe95b8b3f14fbced7d6be9b5287e120e3e8152059f
5
5
  SHA512:
6
- metadata.gz: 72c18740172d1aeec163bbc7754d2190f59a92c8fa347256e344ff247ff30b8d4a046ad7f368c7b458b24be9e012748e045f7b4d059b9cef6df509c2aab200da
7
- data.tar.gz: 2fd42daa906727619d78a276926461c886ec9b20ce81e9bc5c99fe5b0e229172696edef94df5e9ef108ebc6cedcfb1435b62c6590ae96e2ccaaeb218b8778713
6
+ metadata.gz: 0cd44b69ef19d804c91696b6477e3616eb9dd64c326754f5ff7f142ff494bdaf42b1bd4686888dcb1d7c6e6198b737cc501384278a9a6d2441173b6191029eab
7
+ data.tar.gz: 22a096bb7ab8cfaa6285ad12b8c5dfd8098c40f4f538115688e03ad5eac59273f16eca247d1409e28f8f30c730d9ef7cb7c7a5d19586bdacb0c0f002f0b61e9a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [0.3.0] - 2026-08-27
8
+
9
+ - `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
10
+ bounded retry with backoff for adapters, plus normalized conflict/throttle
11
+ errors on `Support::ApiError` so a caller can distinguish "retry this" from
12
+ "don't."
13
+ - `Portage::Ucp::Support::SessionLock` (`lib/portage/ucp/support/session_lock.rb`)
14
+ — serializes per-cart/checkout mutations against a single upstream session,
15
+ used by the Shopify and Wix adapters to stop concurrent cart writes from
16
+ racing the same checkout.
17
+ - `Support::Idempotency` is now thread-safe under concurrent duplicate calls
18
+ — the dedup table write was not atomic, so two requests with the same
19
+ idempotency key arriving together could both miss the cache and both hit
20
+ the adapter.
21
+ - Conformance kit: the repeated-idempotency-key example no longer passes on
22
+ output equality alone. An adapter wired to a fixed-response test double
23
+ returns identical output whether or not it deduped, so the example now also
24
+ asserts the key reached `Support::Idempotency`'s dedup table when the
25
+ adapter includes that module, and `warn`s (rather than silently passing)
26
+ when it doesn't.
27
+
28
+ - `Portage::Ucp::ReferenceAdapter` (`lib/portage/ucp/reference_adapter.rb`) —
29
+ the in-memory `Adapter` roadmap §8 step 1 called for and design-log §17
30
+ flagged as missing outside `spec/support/fake_adapter.rb`, ships with the
31
+ gem now. Implements every capability including
32
+ `discount_codes_supported?`/`fulfillment_supported?`/`link_identity` — the
33
+ first adapter in this repo to back identity linking at all.
34
+ - `Portage::Ucp::RSpec`/`portage/ucp/rspec.rb` — the adapter conformance kit
35
+ design-log §17 called "the missing piece that turns 'any backend that
36
+ implements Adapter' from a README claim into something checked": an
37
+ `it_behaves_like "a portage adapter"` shared-examples suite checking the
38
+ contract's behavioral guarantees (idempotency dedup, the PAN guard,
39
+ schema-conformant wire output, `OutOfStockError` on a stale-stock line) —
40
+ not loaded by `require "portage/ucp"`, opt-in via `require
41
+ "portage/ucp/rspec"` since it pulls in RSpec itself. Exercised against
42
+ `ReferenceAdapter` in this gem's own suite
43
+ (`spec/reference_adapter_conformance_spec.rb`); wired into each adapter
44
+ gem's own spec suite (`spec/portage/ucp/<platform>/conformance_spec.rb`)
45
+ as follow-up.
46
+ - Conformance kit: `existing_variant_id` alongside `existing_product_id`, for
47
+ adapters (Shopify) where a catalog lookup id and a cart line-item id are
48
+ different GIDs. Defaults to `existing_product_id`, so every other adapter
49
+ is unaffected.
50
+ - `search_catalog`/`get_product` output is schema-wrapped like every other
51
+ capability now — previously returned a bare array/`Product` with no
52
+ `to_wire_h`, so the dispatcher's schema-wrap never touched it and nothing
53
+ caught it drifting from `catalog_search.json`/`catalog_lookup.json`.
54
+
7
55
  ## [0.2.0] - 2026-08-21
8
56
 
9
57
  - `Portage::Ucp::OutOfStockError` — the contract for `#complete_checkout`
@@ -4,9 +4,11 @@ module Portage
4
4
  # Unoverridden methods leave that capability out of the manifest.
5
5
  class Adapter
6
6
  # --- Catalog (dev.ucp.shopping.catalog) ---
7
- # @return [Array<Portage::Ucp::Product>]
7
+ # @return [Portage::Ucp::CatalogSearchResult]
8
8
  def search_catalog(query:, limit:) = not_implemented
9
- # @return [Portage::Ucp::Product, nil]
9
+ # nil when the product isn't found, same not-found posture as
10
+ # get_cart/get_checkout/get_order.
11
+ # @return [Portage::Ucp::ProductDetail, nil]
10
12
  def get_product(product_id:) = not_implemented
11
13
 
12
14
  # --- Cart (dev.ucp.shopping.cart) ---
@@ -16,5 +16,27 @@ module Portage
16
16
  # standardized "out_of_stock"/"item_unavailable" error codes
17
17
  # (schemas/shopping/types/error_code.json).
18
18
  class OutOfStockError < Error; end
19
+ # Raised when a mutation collides with a concurrent write upstream (HTTP
20
+ # 409, or a platform's own optimistic-concurrency rejection) — the
21
+ # resource changed between this call's read and its write. Support::Retry
22
+ # deliberately never retries this itself: the state it read is already
23
+ # stale, so retrying blindly would just collide again. The caller is
24
+ # expected to re-read (get_cart/get_checkout) and resubmit against
25
+ # current state. Maps to UCP's freeform "conflict" error code —
26
+ # error_code.json's examples list isn't exhaustive ("freeform codes are
27
+ # permitted"); "conflict" follows the same snake_case convention as its
28
+ # "out_of_stock"/"payment_failed" examples, same justification
29
+ # OutOfStockError above relies on.
30
+ class ConflictError < Error; end
31
+ # Raised when Support::Retry exhausts its bounded backoff against a
32
+ # genuinely-retryable upstream throttle (Shopify GraphQL THROTTLED, HTTP
33
+ # 429, or cartSubmitForCompletion's SubmitThrottled pollAfter) and the
34
+ # platform still hasn't done the work. Named apart from
35
+ # RateLimitExceededError — that one is this gem's own pluggable
36
+ # RateLimiter rejecting a call before it ever reaches the network; this
37
+ # is the upstream platform itself refusing after every retry. Maps to
38
+ # UCP's freeform "rate_limited" error code, same convention as
39
+ # ConflictError above.
40
+ class UpstreamThrottledError < Error; end
19
41
  end
20
42
  end
@@ -0,0 +1,282 @@
1
+ require "digest"
2
+
3
+ module Portage
4
+ module Ucp
5
+ # In-memory Adapter implementing every capability in the contract,
6
+ # including dev.ucp.shopping.discount/fulfillment/identity — roadmap §8
7
+ # step 1 called for one of these ("no real backend required to prove the
8
+ # protocol layer works") and it never shipped; `spec/support/fake_adapter.rb`
9
+ # filled that gap for the core gem's own specs, but stayed test-only,
10
+ # catalog/cart/checkout/order only, and undocumented outside this repo.
11
+ #
12
+ # Two jobs: a copy-paste starting point for a third-party adapter author
13
+ # (see README "Writing your own adapter"), and the fixture the
14
+ # conformance kit (Portage::Ucp::RSpec, lib/portage/ucp/rspec.rb) runs its
15
+ # own shared examples against to prove the kit itself is correct.
16
+ #
17
+ # Not a mock — every mutating action does the real bookkeeping (line-item
18
+ # totals, checkout status transitions, order adjustments) an adapter over
19
+ # a live platform would, just against an in-process Hash instead of an
20
+ # HTTP API. `seed_product` is the one method with no `Adapter` contract
21
+ # counterpart — there is no real backend to seed real data into.
22
+ class ReferenceAdapter < Portage::Ucp::Adapter
23
+ include Portage::Ucp::Support::Idempotency
24
+ include Portage::Ucp::Support::CheckoutState
25
+
26
+ # Products whose id starts with this prefix are treated as sold out —
27
+ # #complete_checkout raises Portage::Ucp::OutOfStockError for them, the
28
+ # same way a real adapter's stock re-check (Adapter#complete_checkout's
29
+ # docs, design-log §16) would. Lets the conformance kit exercise that
30
+ # path without depending on adapter-specific seed data.
31
+ OUT_OF_STOCK_PREFIX = "oos_".freeze
32
+
33
+ def initialize
34
+ super
35
+ @products = {}
36
+ @carts = {}
37
+ @checkouts = {}
38
+ @orders = {}
39
+ @identities = {}
40
+ @next_id = 0
41
+ end
42
+
43
+ # Not part of the Adapter contract — this adapter has no backend to
44
+ # seed real data into, so callers (a spec, a README example) hand it
45
+ # catalog fixtures directly.
46
+ def seed_product(product)
47
+ @products[product.id] = product
48
+ end
49
+
50
+ def search_catalog(query:, limit:)
51
+ matches = @products.values.select { |p| p.title.downcase.include?(query.downcase) }.first(limit)
52
+ Portage::Ucp::CatalogSearchResult.new(products: matches)
53
+ end
54
+
55
+ def get_product(product_id:)
56
+ product = @products[product_id]
57
+ product && Portage::Ucp::ProductDetail.new(product: product)
58
+ end
59
+
60
+ def get_cart(cart_id:) = @carts[cart_id]
61
+
62
+ def create_cart(line_items:, idempotency_key:, discount_codes: nil)
63
+ dedup(idempotency_key) do
64
+ id = next_id("cart")
65
+ @carts[id] = build_cart(id, line_items, discount_codes)
66
+ end
67
+ end
68
+
69
+ def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil)
70
+ dedup(idempotency_key) do
71
+ @carts[cart_id] = build_cart(cart_id, line_items, discount_codes, previous: @carts[cart_id])
72
+ end
73
+ end
74
+
75
+ def cancel_cart(cart_id:, idempotency_key:)
76
+ dedup(idempotency_key) do
77
+ @carts.delete(cart_id)
78
+ Portage::Ucp::Cart.new(id: cart_id, line_items: [], currency: "USD", totals: zero_totals)
79
+ end
80
+ end
81
+
82
+ def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
83
+ dedup(idempotency_key) do
84
+ id = next_id("chk")
85
+ record_checkout_status(id, "incomplete")
86
+ @checkouts[id] = build_checkout(id, line_items, discount_codes, fulfillment, status: "incomplete")
87
+ end
88
+ end
89
+
90
+ def get_checkout(checkout_id:) = @checkouts[checkout_id]
91
+
92
+ def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
93
+ dedup(idempotency_key) do
94
+ record_checkout_status(checkout_id, "incomplete")
95
+ @checkouts[checkout_id] = build_checkout(checkout_id, line_items, discount_codes, fulfillment,
96
+ status: "incomplete", previous: @checkouts[checkout_id])
97
+ end
98
+ end
99
+
100
+ # payment_token: is part of the Adapter contract's call signature (and
101
+ # already validated as non-PAN by PaymentTokenGuard before this ever
102
+ # runs, per §9) but this in-memory adapter has no payment processor to
103
+ # pass it on to — nothing here should hold onto it any longer than the
104
+ # single call needs to, so it's accepted and left unused rather than
105
+ # stored (see .rubocop.yml's Lint/UnusedMethodArgument exclude, same
106
+ # posture as the abstract Adapter#complete_checkout it overrides).
107
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
108
+ dedup(idempotency_key) do
109
+ checkout = @checkouts.fetch(checkout_id)
110
+ raise_if_any_line_out_of_stock!(checkout)
111
+
112
+ order = store_order(checkout)
113
+ confirmation = Portage::Ucp::OrderConfirmation.new(id: order.id, permalink_url: order.permalink_url)
114
+ record_checkout_status(checkout_id, "completed")
115
+ @checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "completed",
116
+ order: confirmation)
117
+ end
118
+ end
119
+
120
+ def cancel_checkout(checkout_id:, idempotency_key:)
121
+ dedup(idempotency_key) do
122
+ record_checkout_status(checkout_id, "canceled")
123
+ checkout = @checkouts.fetch(checkout_id)
124
+ @checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "canceled")
125
+ end
126
+ end
127
+
128
+ def get_order(order_id:) = @orders[order_id]
129
+
130
+ def cancel_order(order_id:, idempotency_key:, reason: nil)
131
+ dedup(idempotency_key) do
132
+ add_adjustment(order_id, type: "cancellation", status: "completed", description: reason)
133
+ end
134
+ end
135
+
136
+ def request_return(order_id:, line_items:, idempotency_key:, reason: nil)
137
+ dedup(idempotency_key) do
138
+ add_adjustment(order_id, type: "return", status: "pending", line_items: line_items, description: reason)
139
+ end
140
+ end
141
+
142
+ def refund_order(order_id:, line_items:, idempotency_key:, reason: nil)
143
+ dedup(idempotency_key) do
144
+ add_adjustment(order_id, type: "refund", status: "completed", line_items: line_items, description: reason)
145
+ end
146
+ end
147
+
148
+ def discount_codes_supported? = true
149
+ def fulfillment_supported? = true
150
+
151
+ # Accepts any non-blank token and mints a stable identity for it — real
152
+ # OAuth verification is a platform concern this in-memory adapter has
153
+ # no platform to defer to, same posture as PaymentTokenGuard drawing
154
+ # the line at "rejects the clearest misuse" rather than proving
155
+ # validity.
156
+ def link_identity(oauth_token:)
157
+ raise Portage::Ucp::AuthenticationError, "blank oauth_token" if oauth_token.to_s.empty?
158
+
159
+ @identities[oauth_token] ||= Portage::Ucp::Identity.new(
160
+ subject: "user_#{Digest::SHA256.hexdigest(oauth_token)[0, 12]}",
161
+ email: nil, linked_at: Time.now.utc.iso8601
162
+ )
163
+ end
164
+
165
+ private
166
+
167
+ def add_adjustment(order_id, type:, status:, line_items: [], description: nil)
168
+ order = @orders.fetch(order_id)
169
+ adjustment = Portage::Ucp::Adjustment.new(
170
+ id: next_id("adj"), type: type, occurred_at: Time.now.utc.iso8601, status: status,
171
+ line_items: adjustment_line_items(line_items), totals: adjustment_totals(order, line_items),
172
+ description: description
173
+ )
174
+ @orders[order_id] = Portage::Ucp::Order.new(**order.to_h, adjustments: order.adjustments + [adjustment])
175
+ end
176
+
177
+ def adjustment_line_items(line_items)
178
+ return nil if line_items.empty?
179
+
180
+ line_items.map { |li| { "id" => li[:id], "quantity" => -li[:quantity] } }
181
+ end
182
+
183
+ def adjustment_totals(order, line_items)
184
+ return nil if line_items.empty?
185
+
186
+ amount = line_items.sum do |li|
187
+ order_line = order.line_items.find { |oli| oli.id == li[:id] }
188
+ unit_price = order_line.totals.find { |t| t.type == "total" }.amount / order_line.quantity
189
+ unit_price * li[:quantity]
190
+ end
191
+ [Portage::Ucp::Total.new(type: "total", amount: -amount)]
192
+ end
193
+
194
+ def store_order(checkout)
195
+ id = next_id("ord")
196
+ order = Portage::Ucp::Order.new(
197
+ id: id, checkout_id: checkout.id, permalink_url: "https://example.com/orders/#{id}",
198
+ line_items: checkout.line_items, fulfillment: Portage::Ucp::Fulfillment.new,
199
+ currency: checkout.currency, totals: checkout.totals
200
+ )
201
+ @orders[id] = order
202
+ end
203
+
204
+ def build_cart(id, requested_line_items, discount_codes, previous: nil)
205
+ line_items = build_line_items(requested_line_items)
206
+ discounts = discounts_for(discount_codes, previous&.discounts)
207
+ Portage::Ucp::Cart.new(id: id, line_items: line_items, currency: "USD",
208
+ totals: totals_for(line_items, discounts), discounts: discounts)
209
+ end
210
+
211
+ def build_checkout(id, requested_line_items, discount_codes, fulfillment, status:, previous: nil)
212
+ line_items = build_line_items(requested_line_items)
213
+ discounts = discounts_for(discount_codes, previous&.discounts)
214
+ Portage::Ucp::Checkout.new(
215
+ id: id, status: status, line_items: line_items, currency: "USD",
216
+ totals: totals_for(line_items, discounts), links: [], discounts: discounts,
217
+ fulfillment: fulfillment || previous&.fulfillment || Portage::Ucp::CheckoutFulfillment.new
218
+ )
219
+ end
220
+
221
+ # Fixed 10% off, applied only for the well-known code "SAVE10" — enough
222
+ # for a conformance spec/adapter author to see a real AppliedDiscount
223
+ # round-trip without inventing a discount engine this adapter has no
224
+ # reason to model in full.
225
+ def discounts_for(discount_codes, previous)
226
+ return previous || Portage::Ucp::Discounts.new if discount_codes.nil?
227
+
228
+ applied = if discount_codes.include?("SAVE10")
229
+ [Portage::Ucp::AppliedDiscount.new(title: "10% off",
230
+ amount: 0, code: "SAVE10")]
231
+ else
232
+ []
233
+ end
234
+ Portage::Ucp::Discounts.new(codes: discount_codes, applied: applied)
235
+ end
236
+
237
+ # `req[:product_id]` is looked up against the featured (first) variant
238
+ # — the same variant #search_catalog/#get_product would show as the
239
+ # listing default — since this in-memory adapter's fixtures are
240
+ # single-variant products and Item#id is spec'd as a variant id
241
+ # (types/variant.json: "Used as item.id in checkout"), not a product
242
+ # id.
243
+ def build_line_items(requested)
244
+ requested.map do |req|
245
+ product = @products.fetch(req[:product_id])
246
+ variant = product.variants.first
247
+ total = variant.price.amount * req[:quantity]
248
+ Portage::Ucp::LineItem.new(
249
+ id: next_id("li"),
250
+ item: Portage::Ucp::Item.new(id: variant.id, title: product.title, price: variant.price.amount),
251
+ quantity: req[:quantity],
252
+ totals: [Portage::Ucp::Total.new(type: "subtotal", amount: total),
253
+ Portage::Ucp::Total.new(type: "total", amount: total)]
254
+ )
255
+ end
256
+ end
257
+
258
+ def totals_for(line_items, discounts)
259
+ subtotal = line_items.sum { |li| li.totals.find { |t| t.type == "total" }.amount }
260
+ discount_amount = discounts.applied.sum(&:amount)
261
+ [Portage::Ucp::Total.new(type: "subtotal", amount: subtotal),
262
+ Portage::Ucp::Total.new(type: "total", amount: subtotal - discount_amount)]
263
+ end
264
+
265
+ def zero_totals
266
+ [Portage::Ucp::Total.new(type: "subtotal", amount: 0), Portage::Ucp::Total.new(type: "total", amount: 0)]
267
+ end
268
+
269
+ def raise_if_any_line_out_of_stock!(checkout)
270
+ unavailable = checkout.line_items.select { |li| li.item.id.start_with?(OUT_OF_STOCK_PREFIX) }
271
+ return if unavailable.empty?
272
+
273
+ raise Portage::Ucp::OutOfStockError, "no longer available: #{unavailable.map { |li| li.item.title }.join(', ')}"
274
+ end
275
+
276
+ def next_id(prefix)
277
+ @next_id += 1
278
+ "#{prefix}_#{@next_id}"
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,175 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Ucp
5
+ # Adapter conformance kit — design-log §17: "Nothing currently checks a
6
+ # third-party Adapter against the contract before its capability shows up
7
+ # in a manifest." `SchemaValidator` (see README, "Spec conformance")
8
+ # checks wire *shape*; this checks the behavioral guarantees §9 promises
9
+ # that schema-valid output can still violate — idempotency not actually
10
+ # deduped, a raw PAN reaching the adapter, a capability advertised whose
11
+ # own output doesn't round-trip through the schema it claims to speak.
12
+ #
13
+ # Not loaded by `require "portage/ucp"` — this pulls in RSpec itself,
14
+ # which the core gem otherwise has zero runtime dependency on (only a
15
+ # development one, per its own README). An adapter author's own spec
16
+ # suite opts in explicitly:
17
+ #
18
+ # require "portage/ucp/rspec"
19
+ #
20
+ # RSpec.describe MyAdapter do
21
+ # it_behaves_like "a portage adapter" do
22
+ # let(:adapter) { MyAdapter.new(client: my_test_client) }
23
+ # let(:existing_product_id) { "known-good-product-id" }
24
+ # # optional — only needed when a purchasable line item is a
25
+ # # *different* id than the catalog product id (Shopify: a
26
+ # # ProductVariant GID vs. the parent Product GID). Defaults to
27
+ # # existing_product_id, which is correct for any backend where
28
+ # # "the product" and "the thing you add to a cart" share one id.
29
+ # # let(:existing_variant_id) { "known-good-purchasable-id" }
30
+ # # optional — enables the out-of-stock example:
31
+ # # let(:out_of_stock_product_id) { "known-sold-out-product-id" }
32
+ # end
33
+ # end
34
+ #
35
+ # `existing_product_id` must resolve to a real, in-stock, purchasable
36
+ # product against whatever backend `adapter` is wired to (a live
37
+ # sandbox store, or webmock/VCR stubs — this kit doesn't care which).
38
+ # Every example below skips itself when the adapter under test doesn't
39
+ # advertise the capability it needs, so a catalog/cart-only adapter
40
+ # (§16's Etsy/Instagram shape) still runs the kit cleanly.
41
+ module RSpec
42
+ module_function
43
+
44
+ def advertised?(adapter, capability_name)
45
+ capability = Portage::Ucp::CapabilityRegistry.default.find(capability_name)
46
+ capability&.advertised_for?(adapter)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ RSpec.shared_examples "a portage adapter" do
53
+ let(:dispatcher) { Portage::Ucp::Dispatcher.new(adapter: adapter) }
54
+ let(:schema_validator) { Portage::Ucp::SchemaValidator.new }
55
+ let(:conformance_idempotency_key) { "conformance-#{object_id}-#{rand(1_000_000)}" }
56
+ let(:existing_variant_id) { existing_product_id }
57
+
58
+ def checkout_capability_advertised?
59
+ Portage::Ucp::RSpec.advertised?(adapter, "dev.ucp.shopping.checkout")
60
+ end
61
+
62
+ def catalog_capability_advertised?
63
+ Portage::Ucp::RSpec.advertised?(adapter, "dev.ucp.shopping.catalog")
64
+ end
65
+
66
+ def create_conformance_checkout(idempotency_key: conformance_idempotency_key)
67
+ dispatcher.call(
68
+ capability: "dev.ucp.shopping.checkout", action: "create_checkout",
69
+ arguments: { line_items: [{ product_id: existing_variant_id, quantity: 1 }],
70
+ idempotency_key: idempotency_key }
71
+ )
72
+ end
73
+
74
+ it "produces a create_checkout response that validates against UCP's own checkout schema" do
75
+ skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
76
+
77
+ response = create_conformance_checkout
78
+ errors = schema_validator.errors_for("schemas/shopping/checkout.json", response[:structuredContent])
79
+
80
+ expect(errors).to eq([]), "create_checkout's response doesn't validate: #{errors.join('; ')}"
81
+ end
82
+
83
+ # The core gem's own dedup table, when the adapter uses it (every bundled
84
+ # adapter includes `Support::Idempotency`). nil for an adapter that dedupes
85
+ # some other way — see the example below for why that costs it a check.
86
+ def conformance_dedup_table
87
+ return nil unless adapter.is_a?(Portage::Ucp::Support::Idempotency)
88
+
89
+ adapter.instance_variable_get(:@idempotency_results)
90
+ end
91
+
92
+ it "dedupes a repeated idempotency_key on create_checkout rather than re-running the mutation (§9a)" do
93
+ skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
94
+
95
+ first = create_conformance_checkout
96
+ second = create_conformance_checkout
97
+
98
+ expect(second[:structuredContent]).to eq(first[:structuredContent]),
99
+ "a repeated idempotency_key produced a different result — " \
100
+ "the adapter isn't deduping mutating calls per §9a"
101
+
102
+ # Equal output is necessary but nowhere near sufficient, and this is the
103
+ # trap §17 named: an adapter wired to webmock/VCR stubs that answer every
104
+ # request with one fixed response returns identical output whether or not
105
+ # it deduped anything, so the assertion above passes for the wrong reason
106
+ # on exactly the test setup an adapter author is most likely to write.
107
+ # When the adapter uses `Support::Idempotency`, check the table itself —
108
+ # an adapter that never deduped has no entry under the key at all.
109
+ table = conformance_dedup_table
110
+ if table.nil?
111
+ warn "[portage conformance] #{adapter.class} doesn't include " \
112
+ "Portage::Ucp::Support::Idempotency, so dedup was only checked by output equality — " \
113
+ "which a fixed-response test double satisfies without deduping. Assert the dedup " \
114
+ "yourself (e.g. `expect(stub).to have_been_requested.once`) in your own spec."
115
+ else
116
+ expect(table).to include(conformance_idempotency_key),
117
+ "create_checkout returned equal output for a repeated idempotency_key but recorded " \
118
+ "nothing in the dedup table — the second call re-ran the mutation and only looked " \
119
+ "deduped because the backend double answers every request identically (§9a)"
120
+ end
121
+ end
122
+
123
+ it "produces a search_catalog response that validates against UCP's own catalog search schema" do
124
+ skip "adapter does not advertise dev.ucp.shopping.catalog" unless catalog_capability_advertised?
125
+
126
+ response = dispatcher.call(capability: "dev.ucp.shopping.catalog", action: "search_catalog",
127
+ arguments: { query: "", limit: 5 })
128
+ errors = schema_validator.errors_for("schemas/shopping/catalog_search.json#/$defs/search_response",
129
+ response[:structuredContent])
130
+
131
+ expect(errors).to eq([]), "search_catalog's response doesn't validate: #{errors.join('; ')}"
132
+ end
133
+
134
+ it "produces a get_product response that validates against UCP's own catalog lookup schema" do
135
+ skip "adapter does not advertise dev.ucp.shopping.catalog" unless catalog_capability_advertised?
136
+
137
+ response = dispatcher.call(capability: "dev.ucp.shopping.catalog", action: "get_product",
138
+ arguments: { product_id: existing_product_id })
139
+ errors = schema_validator.errors_for("schemas/shopping/catalog_lookup.json#/$defs/get_product_response",
140
+ response[:structuredContent])
141
+
142
+ expect(errors).to eq([]), "get_product's response doesn't validate: #{errors.join('; ')}"
143
+ end
144
+
145
+ it "never lets a raw, Luhn-valid PAN reach the adapter's complete_checkout (§9's PCI boundary)" do
146
+ skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
147
+
148
+ checkout_id = create_conformance_checkout[:structuredContent]["id"]
149
+ expect(adapter).not_to receive(:complete_checkout)
150
+
151
+ expect do
152
+ dispatcher.call(capability: "dev.ucp.shopping.checkout", action: "complete_checkout",
153
+ arguments: { checkout_id: checkout_id, payment_token: "4242424242424242",
154
+ idempotency_key: "#{conformance_idempotency_key}-complete" })
155
+ end.to raise_error(Portage::Ucp::RawPanRejectedError)
156
+ end
157
+
158
+ it "raises Portage::Ucp::OutOfStockError from complete_checkout for a line that's gone out of stock" do
159
+ skip "no out_of_stock_product_id given — this example is opt-in" unless respond_to?(:out_of_stock_product_id)
160
+ skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
161
+
162
+ checkout = dispatcher.call(
163
+ capability: "dev.ucp.shopping.checkout", action: "create_checkout",
164
+ arguments: { line_items: [{ product_id: out_of_stock_product_id, quantity: 1 }],
165
+ idempotency_key: "#{conformance_idempotency_key}-oos" }
166
+ )
167
+
168
+ expect do
169
+ dispatcher.call(capability: "dev.ucp.shopping.checkout", action: "complete_checkout",
170
+ arguments: { checkout_id: checkout[:structuredContent]["id"],
171
+ payment_token: "sptk_conformance_test_token",
172
+ idempotency_key: "#{conformance_idempotency_key}-oos-complete" })
173
+ end.to raise_error(Portage::Ucp::OutOfStockError)
174
+ end
175
+ end
@@ -20,11 +20,17 @@ module Portage
20
20
  end
21
21
 
22
22
  # @param relative_path [String] e.g. "schemas/shopping/cart.json", matching
23
- # the vendored path under schemas/<version>/.
23
+ # the vendored path under schemas/<version>/. A path with a
24
+ # `#/$defs/...` fragment (e.g.
25
+ # "schemas/shopping/catalog_search.json#/$defs/search_response")
26
+ # validates against that named subschema instead of the document
27
+ # root — for schemas like catalog_search.json/catalog_lookup.json
28
+ # that define several request/response shapes as siblings under
29
+ # $defs rather than being one schema per file.
24
30
  # @return [Array<String>] human-readable validation error messages: empty
25
31
  # means `data` conforms.
26
32
  def errors_for(relative_path, data)
27
- schemer = JSONSchemer.schema(load(relative_path), ref_resolver: method(:resolve_ref))
33
+ schemer = JSONSchemer.schema(schema_for(relative_path), ref_resolver: method(:resolve_ref))
28
34
  schemer.validate(data).map { |error| JSONSchemer::Errors.pretty(error) }
29
35
  end
30
36
 
@@ -40,6 +46,21 @@ module Portage
40
46
 
41
47
  private
42
48
 
49
+ # A bare path loads and validates against the document root, same as
50
+ # before. A path carrying a fragment becomes a single-$ref schema
51
+ # pointing at that fragment, resolved the same way any other
52
+ # cross-document $ref in these vendored schemas is (via #resolve_ref) —
53
+ # $defs entries can themselves $ref sibling $defs by a local "#/..."
54
+ # pointer (catalog_lookup.json's get_product_response -> #/$defs/
55
+ # detail_product being one), so the fragment must be resolved by the
56
+ # schema library against the whole loaded document, not sliced out of
57
+ # it here.
58
+ def schema_for(relative_path)
59
+ return load(relative_path) unless relative_path.include?("#")
60
+
61
+ { "$ref" => "#{@base_url}#{relative_path}" }
62
+ end
63
+
43
64
  def resolve_ref(uri)
44
65
  relative_path = uri.to_s.delete_prefix(@base_url).sub(/#.*/, "")
45
66
  load(relative_path)
@@ -6,16 +6,19 @@ module Portage
6
6
  # everything the Wix gem raises) — a module rather than a base class
7
7
  # precisely to leave that hierarchy alone.
8
8
  #
9
- # Carries the two things every gem's ApiError carried identically: the
10
- # HTTP status (which Support::NotFound#nil_on_not_found reads) and the parsed
11
- # body. What differs per platform is only where the human-readable
12
- # message lives inside that body, which is the `detail` hook.
9
+ # Carries the three things every gem's ApiError carried identically: the
10
+ # HTTP status (which Support::NotFound#nil_on_not_found reads), the
11
+ # parsed body, and when the response sent one the Retry-After
12
+ # header Support::Retry consults to time a 429's backoff. What differs
13
+ # per platform is only where the human-readable message lives inside
14
+ # the body, which is the `detail` hook.
13
15
  module ApiError
14
- attr_reader :status, :body
16
+ attr_reader :status, :body, :retry_after
15
17
 
16
- def initialize(status, body)
18
+ def initialize(status, body, retry_after: nil)
17
19
  @status = status
18
20
  @body = body
21
+ @retry_after = retry_after
19
22
  super("#{api_label} API error (#{status}): #{detail(body)}")
20
23
  end
21
24
 
@@ -40,9 +40,16 @@ module Portage
40
40
  # answer a successful DELETE with 204 and no content at all.
41
41
  def parse!(response)
42
42
  parsed = response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
43
- raise api_error_class.new(response.code.to_i, parsed) unless response.is_a?(Net::HTTPSuccess)
43
+ return parsed if response.is_a?(Net::HTTPSuccess)
44
44
 
45
- parsed
45
+ status = response.code.to_i
46
+ # 409 means "you lost a race with a concurrent write" the same way
47
+ # on every REST platform behind this module — normalized here to
48
+ # Portage::Ucp::ConflictError rather than each gem's own ApiError,
49
+ # since it's cross-cutting, not platform-specific (see errors.rb).
50
+ raise Portage::Ucp::ConflictError, "conflict (409): #{parsed}" if status == 409
51
+
52
+ raise api_error_class.new(status, parsed, retry_after: response["Retry-After"])
46
53
  end
47
54
 
48
55
  def api_error_class
@@ -12,13 +12,32 @@ module Portage
12
12
  # workers needs a shared store, which is a consumer concern the same
13
13
  # way RateLimiter is.
14
14
  module Idempotency
15
+ # Guards lazy init of each instance's lock table below — brief and
16
+ # only touched once per instance, not on the hot dedup path.
17
+ INIT_MUTEX = Mutex.new
18
+
15
19
  private
16
20
 
17
21
  def dedup(idempotency_key)
18
- @idempotency_results ||= {}
19
- return @idempotency_results[idempotency_key] if @idempotency_results.key?(idempotency_key)
22
+ init_idempotency_locks!
23
+
24
+ key_lock = @idempotency_mutex.synchronize { @idempotency_locks[idempotency_key] ||= Mutex.new }
25
+
26
+ key_lock.synchronize do
27
+ return @idempotency_results[idempotency_key] if @idempotency_results.key?(idempotency_key)
28
+
29
+ @idempotency_results[idempotency_key] = yield
30
+ end
31
+ end
32
+
33
+ def init_idempotency_locks!
34
+ return if @idempotency_mutex
20
35
 
21
- @idempotency_results[idempotency_key] = yield
36
+ INIT_MUTEX.synchronize do
37
+ @idempotency_mutex ||= Mutex.new
38
+ @idempotency_results ||= {}
39
+ @idempotency_locks ||= {}
40
+ end
22
41
  end
23
42
  end
24
43
  end
@@ -0,0 +1,66 @@
1
+ module Portage
2
+ module Ucp
3
+ module Support
4
+ # Bounded exponential backoff + jitter around a single upstream call.
5
+ #
6
+ # #with_retry is only ever safe to wrap around a call that is either
7
+ # naturally idempotent (a read) or already sitting inside a
8
+ # Support::Idempotency#dedup block (a mutation memoized by
9
+ # idempotency_key) — every bundled adapter's mutating method already
10
+ # dedups, so its Client-level HTTP/GraphQL calls satisfy this by
11
+ # construction. Retrying a non-idempotent mutation that ISN'T inside
12
+ # dedup can double it up: a request that partially applied upstream
13
+ # before the connection dropped gets reapplied on retry. Never call
14
+ # #with_retry around a bare mutation outside dedup.
15
+ #
16
+ # Only retries signals that mean "the upstream didn't do the work, try
17
+ # again": HTTP 429 (honoring Retry-After when present), 5xx, and
18
+ # platform-specific throttling (Shopify's GraphQL THROTTLED code,
19
+ # cartSubmitForCompletion's SubmitThrottled pollAfter) via
20
+ # #retryable_error? overrides. Everything else — 4xx business
21
+ # rejections, UserError, OutOfStockError, ConflictError — passes
22
+ # straight through un-retried.
23
+ module Retry
24
+ DEFAULT_MAX_ATTEMPTS = 4
25
+ DEFAULT_BASE_DELAY = 0.25
26
+ DEFAULT_MAX_DELAY = 4.0
27
+
28
+ private
29
+
30
+ def with_retry(max_attempts: DEFAULT_MAX_ATTEMPTS, base_delay: DEFAULT_BASE_DELAY,
31
+ max_delay: DEFAULT_MAX_DELAY)
32
+ attempt = 0
33
+
34
+ begin
35
+ attempt += 1
36
+ yield
37
+ rescue StandardError => e
38
+ raise unless retryable_error?(e)
39
+ raise if attempt >= max_attempts
40
+
41
+ sleep(retry_delay(e, attempt, base_delay, max_delay))
42
+ retry
43
+ end
44
+ end
45
+
46
+ # Default covers the common REST shape: any error carrying a `status`
47
+ # of 429 or 5xx (Support::ApiError-including errors all expose this).
48
+ # Platform clients override to recognize signals with no HTTP status
49
+ # of their own (e.g. a GraphQL THROTTLED extension code).
50
+ def retryable_error?(error)
51
+ error.respond_to?(:status) && (error.status == 429 || (500..599).cover?(error.status))
52
+ end
53
+
54
+ # Honors an upstream Retry-After (seconds) when the error carries
55
+ # one; otherwise backs off exponentially with full jitter.
56
+ def retry_delay(error, attempt, base_delay, max_delay)
57
+ retry_after = error.respond_to?(:retry_after) ? error.retry_after : nil
58
+ return [retry_after.to_f, 0].max if retry_after
59
+
60
+ exponential = [base_delay * (2**(attempt - 1)), max_delay].min
61
+ rand * exponential
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,40 @@
1
+ module Portage
2
+ module Ucp
3
+ module Support
4
+ # §21: multi-call mutations (read-modify-write against Shopify's
5
+ # Storefront API, or the equivalent REST sequence on Wix) aren't
6
+ # atomic upstream, so two concurrent calls against the same cart or
7
+ # checkout id can interleave and drop each other's writes — not just
8
+ # a lost update, lines vanish. This serializes calls per id so a
9
+ # concurrent duplicate waits for the first to finish instead of
10
+ # racing it.
11
+ #
12
+ # In-process only, same caveat as RateLimiter and Idempotency: a
13
+ # multi-process deployment needs a shared lock, which is a consumer
14
+ # concern. Keep #synchronize as the only method a consumer touches so
15
+ # a Redis-backed replacement can drop in without adapters changing.
16
+ module SessionLock
17
+ INIT_MUTEX = Mutex.new
18
+
19
+ private
20
+
21
+ def synchronize(session_id)
22
+ init_session_locks!
23
+
24
+ key_lock = @session_lock_mutex.synchronize { @session_locks[session_id] ||= Mutex.new }
25
+
26
+ key_lock.synchronize { yield }
27
+ end
28
+
29
+ def init_session_locks!
30
+ return if @session_lock_mutex
31
+
32
+ INIT_MUTEX.synchronize do
33
+ @session_lock_mutex ||= Mutex.new
34
+ @session_locks ||= {}
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,9 +1,169 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  # Internal arithmetic helper only — never appears in a wire shape directly.
4
+ # Cart/Checkout/Order totals are bare minor-unit integers (Total#amount)
5
+ # once they reach a value object; Money exists so Support::Amounts has
6
+ # somewhere to hand back both the minor-unit amount and its currency
7
+ # together mid-conversion. Product/Variant carry currency-bearing wire
8
+ # fields directly (Price, below) rather than through Money.
4
9
  Money = Data.define(:amount_minor, :currency)
5
10
 
6
- Product = Data.define(:id, :title, :description, :price, :available, :variants, :url)
11
+ # schemas/shopping/types/price.json the wire-shape counterpart to Money
12
+ # above: an integer minor-unit amount paired with its currency, appearing
13
+ # directly in Product/Variant payloads.
14
+ Price = Data.define(:amount, :currency) do
15
+ def to_wire_h = { "amount" => amount, "currency" => currency }
16
+ end
17
+
18
+ # schemas/shopping/types/price_range.json
19
+ PriceRange = Data.define(:min, :max) do
20
+ def to_wire_h = { "min" => min.to_wire_h, "max" => max.to_wire_h }
21
+ end
22
+
23
+ # schemas/shopping/types/description.json — the spec requires at least
24
+ # one of plain/html/markdown; that's a request-time authoring concern
25
+ # this value object doesn't enforce, same posture as PostalAddress
26
+ # leaving every field optional.
27
+ Description = Data.define(:plain, :html, :markdown) do
28
+ def initialize(plain: nil, html: nil, markdown: nil) = super
29
+
30
+ def to_wire_h = { "plain" => plain, "html" => html, "markdown" => markdown }.compact
31
+ end
32
+
33
+ # schemas/shopping/types/category.json
34
+ Category = Data.define(:value, :taxonomy) do
35
+ def initialize(value:, taxonomy: nil) = super
36
+
37
+ def to_wire_h = { "value" => value, "taxonomy" => taxonomy }.compact
38
+ end
39
+
40
+ # schemas/shopping/types/media.json
41
+ Media = Data.define(:type, :url, :alt_text, :width, :height) do
42
+ def initialize(type:, url:, alt_text: nil, width: nil, height: nil) = super
43
+
44
+ def to_wire_h
45
+ { "type" => type, "url" => url, "alt_text" => alt_text, "width" => width, "height" => height }.compact
46
+ end
47
+ end
48
+
49
+ # schemas/shopping/types/option_value.json — a selectable value for a
50
+ # ProductOption (e.g. "Blue" under "Color").
51
+ OptionValue = Data.define(:id, :label) do
52
+ def initialize(label:, id: nil) = super
53
+
54
+ def to_wire_h = { "id" => id, "label" => label }.compact
55
+ end
56
+
57
+ # schemas/shopping/types/product_option.json
58
+ ProductOption = Data.define(:name, :values) do
59
+ def to_wire_h = { "name" => name, "values" => values.map(&:to_wire_h) }
60
+ end
61
+
62
+ # schemas/shopping/types/selected_option.json — which OptionValue a
63
+ # Variant actually carries for a given option (e.g. Size: Large).
64
+ SelectedOption = Data.define(:name, :id, :label) do
65
+ def initialize(name:, label:, id: nil) = super
66
+
67
+ def to_wire_h = { "name" => name, "id" => id, "label" => label }.compact
68
+ end
69
+
70
+ # schemas/shopping/types/rating.json
71
+ Rating = Data.define(:value, :scale_max, :scale_min, :count) do
72
+ def initialize(value:, scale_max:, scale_min: 1, count: nil) = super
73
+
74
+ def to_wire_h
75
+ { "value" => value, "scale_min" => scale_min, "scale_max" => scale_max, "count" => count }.compact
76
+ end
77
+ end
78
+
79
+ # schemas/shopping/types/variant.json — a purchasable variant of a
80
+ # Product. `id` is what LineItem#item.id actually references at
81
+ # checkout, not Product#id.
82
+ # `barcodes` and `availability` are left as plain wire-shaped hashes
83
+ # (`{"type" =>, "value" =>}` / `{"available" =>, "status" =>}`) rather
84
+ # than their own Data types — both are simple, spec doesn't name them as
85
+ # standalone $refs, and the parent's `to_wire_h` needs them exactly as
86
+ # given, same posture as Adjustment#line_items' inline hashes.
87
+ Variant = Data.define(:id, :title, :description, :price, :sku, :barcodes, :list_price, :availability, :options,
88
+ :media, :tags, :metadata) do
89
+ def initialize(id:, title:, description:, price:, sku: nil, barcodes: [], list_price: nil, availability: nil,
90
+ options: [], media: [], tags: [], metadata: nil)
91
+ super
92
+ end
93
+
94
+ def to_wire_h
95
+ { "id" => id, "title" => title, "description" => description.to_wire_h,
96
+ "price" => price.to_wire_h }.merge(optional_wire_h)
97
+ end
98
+
99
+ private
100
+
101
+ def optional_wire_h
102
+ h = { "sku" => sku, "list_price" => list_price&.to_wire_h, "availability" => availability }.compact
103
+ h["barcodes"] = barcodes unless barcodes.empty?
104
+ h["options"] = options.map(&:to_wire_h) unless options.empty?
105
+ h["media"] = media.map(&:to_wire_h) unless media.empty?
106
+ h["tags"] = tags unless tags.empty?
107
+ h["metadata"] = metadata if metadata
108
+ h
109
+ end
110
+ end
111
+
112
+ # schemas/shopping/types/product.json — requires id/title/description/
113
+ # price_range/variants (variants minItems: 1). `metadata` is the spec's
114
+ # own sanctioned extension point for business-defined custom data
115
+ # (Shopify/Wix metafields etc.) — see Portage::Ucp::Shopify.configure's
116
+ # metadata_field.
117
+ Product = Data.define(:id, :title, :description, :price_range, :variants, :handle, :url, :categories,
118
+ :list_price_range, :media, :options, :tags, :metadata, :rating) do
119
+ def initialize(id:, title:, description:, price_range:, variants:, handle: nil, url: nil, categories: [],
120
+ list_price_range: nil, media: [], options: [], tags: [], metadata: nil, rating: nil)
121
+ super
122
+ end
123
+
124
+ def to_wire_h
125
+ { "id" => id, "title" => title, "description" => description.to_wire_h,
126
+ "price_range" => price_range.to_wire_h, "variants" => variants.map(&:to_wire_h) }.merge(optional_wire_h)
127
+ end
128
+
129
+ private
130
+
131
+ def optional_wire_h
132
+ h = { "handle" => handle, "url" => url, "list_price_range" => list_price_range&.to_wire_h,
133
+ "metadata" => metadata, "rating" => rating&.to_wire_h }.compact
134
+ h["categories"] = categories.map(&:to_wire_h) unless categories.empty?
135
+ h["media"] = media.map(&:to_wire_h) unless media.empty?
136
+ h["options"] = options.map(&:to_wire_h) unless options.empty?
137
+ h["tags"] = tags unless tags.empty?
138
+ h
139
+ end
140
+ end
141
+
142
+ # schemas/shopping/catalog_search.json#/$defs/search_response — the
143
+ # container Adapter#search_catalog actually returns; `products` alone
144
+ # isn't schema-conformant (search_response requires `ucp` + `products` at
145
+ # the top level, not a bare array). `messages` entries are left as plain
146
+ # wire-shaped hashes, same rationale as Variant#barcodes above.
147
+ CatalogSearchResult = Data.define(:products, :messages) do
148
+ def initialize(products:, messages: []) = super
149
+
150
+ def to_wire_h
151
+ h = { "products" => products.map(&:to_wire_h) }
152
+ h["messages"] = messages unless messages.empty?
153
+ h
154
+ end
155
+ end
156
+
157
+ # schemas/shopping/catalog_lookup.json#/$defs/get_product_response — the
158
+ # container Adapter#get_product returns when the product is found (nil
159
+ # otherwise, same not-found posture as get_cart/get_checkout/get_order).
160
+ # Doesn't model detail_product's `selected`/`options` availability-signal
161
+ # extension (interactive variant narrowing) — Adapter#get_product's
162
+ # signature has no `selected:`/`preferences:` params to source it from
163
+ # yet; unresearched, same status as design-log §16's other backlog items.
164
+ ProductDetail = Data.define(:product) do
165
+ def to_wire_h = { "product" => product.to_wire_h }
166
+ end
7
167
 
8
168
  # One cost-breakdown entry (schemas/shopping/types/total.json). `amount` is a
9
169
  # signed integer in the parent object's currency's minor units.
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.2.0".freeze
3
+ VERSION = "0.3.0".freeze
4
4
  end
5
5
  end
@@ -11,7 +11,11 @@ module Portage
11
11
  ENVELOPES = {
12
12
  "dev.ucp.shopping.cart" => -> { { "version" => SPEC_VERSION } },
13
13
  "dev.ucp.shopping.checkout" => -> { { "version" => SPEC_VERSION, "payment_handlers" => {} } },
14
- "dev.ucp.shopping.order" => -> { { "version" => SPEC_VERSION } }
14
+ "dev.ucp.shopping.order" => -> { { "version" => SPEC_VERSION } },
15
+ # response_catalog_schema (catalog_search.json's search_response and
16
+ # catalog_lookup.json's get_product_response both require it) — same
17
+ # minimal shape as cart/order, no capability-specific extra field.
18
+ "dev.ucp.shopping.catalog" => -> { { "version" => SPEC_VERSION } }
15
19
  }.freeze
16
20
 
17
21
  def self.wrap(capability_name, payload_hash)
data/lib/portage/ucp.rb CHANGED
@@ -13,6 +13,9 @@ require_relative "ucp/support/totals"
13
13
  require_relative "ucp/support/line_item_status"
14
14
  require_relative "ucp/support/idempotency"
15
15
  require_relative "ucp/support/checkout_state"
16
+ require_relative "ucp/support/session_lock"
17
+ require_relative "ucp/support/retry"
18
+ require_relative "ucp/reference_adapter"
16
19
  require_relative "ucp/support/api_error"
17
20
  require_relative "ucp/support/not_found"
18
21
  require_relative "ucp/support/http_client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -170,7 +170,9 @@ files:
170
170
  - lib/portage/ucp/rack/manifest_endpoint.rb
171
171
  - lib/portage/ucp/rack/webhook_endpoint.rb
172
172
  - lib/portage/ucp/rate_limiter.rb
173
+ - lib/portage/ucp/reference_adapter.rb
173
174
  - lib/portage/ucp/resolver.rb
175
+ - lib/portage/ucp/rspec.rb
174
176
  - lib/portage/ucp/schema_validator.rb
175
177
  - lib/portage/ucp/support/amounts.rb
176
178
  - lib/portage/ucp/support/api_error.rb
@@ -179,6 +181,8 @@ files:
179
181
  - lib/portage/ucp/support/idempotency.rb
180
182
  - lib/portage/ucp/support/line_item_status.rb
181
183
  - lib/portage/ucp/support/not_found.rb
184
+ - lib/portage/ucp/support/retry.rb
185
+ - lib/portage/ucp/support/session_lock.rb
182
186
  - lib/portage/ucp/support/token_exchange.rb
183
187
  - lib/portage/ucp/support/totals.rb
184
188
  - lib/portage/ucp/value_objects.rb
@@ -275,7 +279,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
275
279
  - !ruby/object:Gem::Version
276
280
  version: '0'
277
281
  requirements: []
278
- rubygems_version: 4.0.18
282
+ rubygems_version: 3.6.9
279
283
  specification_version: 4
280
284
  summary: Expose a commerce backend to AI shopping agents over MCP and UCP
281
285
  test_files: []