portage-ucp 0.3.0 → 0.4.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: 8ce343c7abccfbe4c1a8cf2ed0db6340d588c770e959fef42aec644ef6a69800
4
- data.tar.gz: 7d1a96bb2e150b822c1dedbe95b8b3f14fbced7d6be9b5287e120e3e8152059f
3
+ metadata.gz: 36c3a77e9f625761b827d1e925d54217b7bff302f4a7a60540d4a8c5d1794fb9
4
+ data.tar.gz: 05a86e2740a327c63bb0e06aa450e2aa5207aa60ce637575133483ed8df4698b
5
5
  SHA512:
6
- metadata.gz: 0cd44b69ef19d804c91696b6477e3616eb9dd64c326754f5ff7f142ff494bdaf42b1bd4686888dcb1d7c6e6198b737cc501384278a9a6d2441173b6191029eab
7
- data.tar.gz: 22a096bb7ab8cfaa6285ad12b8c5dfd8098c40f4f538115688e03ad5eac59273f16eca247d1409e28f8f30c730d9ef7cb7c7a5d19586bdacb0c0f002f0b61e9a
6
+ metadata.gz: d0d4746f829e60009463ee2748ba5c46ff6f3932b5a345f3f26d94e437a5132b39002945bf19abe1ee3d02c46e1760b52afa6910a15531df494c4e39dcca7787
7
+ data.tar.gz: 60898e67d72f545c24970415775506be4930336a4f325be61714cef0f810547f77b79951f7b48e3fb32b72cdd9adb9669b4f3af4f872be0a4bcb7ea60b325bf5
data/CHANGELOG.md CHANGED
@@ -4,6 +4,69 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [0.4.0] - 2026-08-28
10
+
11
+ - Added `Adapter#reorder(order_id:, idempotency_key:)`, advertised as a new
12
+ `app.portage-ucp.reorder` capability — a Portage extension, not part of the
13
+ UCP spec. Hydrates a `Cart` from a previous order's line items, re-checking
14
+ each item's current price/availability rather than replaying the order's
15
+ historical totals, and reports anything no longer purchasable via the new
16
+ `ReorderResult#unavailable_items` rather than failing the whole call.
17
+ Implemented in `ReferenceAdapter` as a worked example.
18
+ - `Mcp::Server.call_tool` now emits a minimal pre-auth `tool_call_received`
19
+ event (capability, action, correlation id — no arguments) before
20
+ `authorize`/`rate_limit` run, moving the full `tool_called` event
21
+ (arguments included) below them. Previously the full event, arguments and
22
+ all, was logged before authorization, so an unauthenticated caller could
23
+ write attacker-chosen content into the operator's logs at whatever volume
24
+ the rate limiter would otherwise have refused (design-log §23).
25
+ - `Mcp::Server.correlation_id_for` stamps both events with a correlation id
26
+ read from the inbound W3C `traceparent` in `server_context[:_meta]`
27
+ (SEP-414, `MCP::TraceContext`), falling back to `SecureRandom.uuid` when
28
+ absent or malformed. Deliberately per-request, not per-session —
29
+ `Server::Context` is built once per process in `.build`, and `mcp`
30
+ 0.25.0's Streamable HTTP transport is stateful and multi-session, so
31
+ memoizing an id there would stamp every session in the process with the
32
+ same value (design-log §23). Because `traceparent` is unauthenticated
33
+ input read before `authorize`/`rate_limit` run, it's validated against
34
+ the W3C Trace Context format before use rather than accepted as-is — a
35
+ malformed or oversized value falls back to a generated id instead of
36
+ reaching the pre-auth log or `Dispatcher`/`CheckoutState` unchecked.
37
+ - `Dispatcher` now threads its logger and each call's correlation id to the
38
+ adapter for the duration of that one call
39
+ (`Support::CheckoutState.with_observability`) so a
40
+ `checkout_state_transition` event (§12) fires from `record_checkout_status`
41
+ carrying the same correlation id as the `tool_called` event that triggered
42
+ it — without adding a `correlation_id:` kwarg to any checkout method, which
43
+ would have been a breaking change to the `Adapter` contract. Storage is
44
+ `Thread.current`, keyed per adapter object and restored on exit, rather
45
+ than an instance variable on the adapter: `Mcp::Server.build` constructs
46
+ one adapter per process, shared across every concurrent session, so an
47
+ instance variable would let two in-flight requests clobber each other's
48
+ correlation id — the same per-process-state trap §23 diagnosed for the
49
+ correlation id generator itself, one layer down. No `capability_negotiated`
50
+ event yet: `CapabilityNegotiator#negotiate` has no call site anywhere in
51
+ the gem outside its own spec, so there's nowhere to emit it from without
52
+ building that call site first (design-log §23).
53
+ - `Observability::REDACTED_KEYS` grows past the three credential keys to
54
+ cover the PII that actually flows through logged events — `email`
55
+ (`Identity`, §3) and `first_name`/`last_name`/`phone_number`/
56
+ `street_address`/`extended_address`/`address_locality`/`address_region`/
57
+ `address_country`/`postal_code` (`PostalAddress`, fulfillment
58
+ destinations). §12's "Money-adjacent PII" named no real key — `Money`/
59
+ `Total` carry only amounts and currency codes (design-log §23 step 4).
60
+ - `Rack::WebhookEndpoint` takes a `logger:` kwarg (defaulting to
61
+ `Portage::Ucp.configuration.logger`) and emits `order_webhook_received`
62
+ (order id, checkout id) on a verified payload and `order_webhook_rejected`
63
+ (reason: `invalid_signature` or `bad_request`) on the two rejection paths
64
+ — never the request body. No new `config.event_sink`: this endpoint is a
65
+ plain Rack app never built through `Mcp::Server.build`, so it can't reach
66
+ `mcp`'s own request hooks, and threading the gem's existing `logger:`
67
+ convention through one more constructor was the whole fix (design-log
68
+ §23 step 5).
69
+
7
70
  ## [0.3.0] - 2026-08-27
8
71
 
9
72
  - `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
@@ -90,6 +90,19 @@ module Portage
90
90
  # @return [Portage::Ucp::Order]
91
91
  def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
92
92
 
93
+ # --- Reorder (app.portage-ucp.reorder — Portage extension, not part of
94
+ # the UCP spec: dev.ucp.dev has no reorder/cart-hydration capability as
95
+ # of the 2026-04-08 spec) ---
96
+ # Hydrates a cart from a previous order's line items so a caller doesn't
97
+ # have to re-walk get_order + create_cart itself. Re-checks each item's
98
+ # current price/availability rather than replaying the order's snapshot
99
+ # totals — order_line_item.json's totals are historical, not live, same
100
+ # posture as complete_checkout's stock re-check above — and drops
101
+ # anything no longer purchasable instead of failing the whole call,
102
+ # reporting what got dropped via ReorderResult#unavailable_items.
103
+ # @return [Portage::Ucp::ReorderResult, nil] nil if the order isn't found.
104
+ def reorder(order_id:, idempotency_key:) = not_implemented
105
+
93
106
  # --- Discount (dev.ucp.shopping.discount) ---
94
107
  # Extends Cart/Checkout with the `discount_codes:` param above rather
95
108
  # than adding actions of its own — Capability::DISCOUNT advertises off
@@ -7,7 +7,7 @@ module Portage
7
7
  actions: { "link_identity" => :link_identity }
8
8
  )
9
9
 
10
- ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT].freeze
10
+ ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER].freeze
11
11
  end
12
12
  end
13
13
  end
@@ -0,0 +1,17 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # Portage-only extension, not part of the UCP spec (no ucp.dev
5
+ # reverse-domain meaning to borrow), so this is namespaced under the
6
+ # gem's own name instead of "dev.ucp.*". Advertised only if the
7
+ # adapter overrides Adapter#reorder, same as every other capability
8
+ # (Capability#advertised_for?) — an adapter that hasn't implemented it
9
+ # simply never shows it in the manifest.
10
+ REORDER = Portage::Ucp::Capability.new(
11
+ name: "app.portage-ucp.reorder",
12
+ version: "1",
13
+ actions: { "reorder" => :reorder }
14
+ )
15
+ end
16
+ end
17
+ end
@@ -13,6 +13,36 @@ module Portage
13
13
  # params instead. Either way this class only deals in the parsed version
14
14
  # list — transports are responsible for extracting it from their own
15
15
  # request shape.
16
+ #
17
+ # §23 step 3: §12 promises a `capability_negotiated` event here, but
18
+ # #negotiate has no call site anywhere in this gem outside its own spec
19
+ # — nothing in the request path (transport `initialize` handling, or
20
+ # otherwise) invokes it yet. Wiring the event would mean building that
21
+ # call site first, which is a bigger change than threading a logger
22
+ # through an existing collaborator; left undone, and cut from §12
23
+ # rather than promised.
24
+ #
25
+ # §25: the reason there's no call site is structural, not just
26
+ # unbuilt. `mcp` 0.25.0's `MCP::Server#init`
27
+ # (lib/mcp/server.rb:608) never reads `params[:_meta]` and never calls
28
+ # `add_instrumentation_data`, unlike `call_tool` — so nothing at
29
+ # initialize time reaches `around_request`/`instrumentation_callback`
30
+ # or the `_meta` mechanism `Mcp::Server.correlation_id_for` already
31
+ # relies on (§23/§24). And over HTTP,
32
+ # `StreamableHTTPTransport#handle_initialization`
33
+ # (lib/mcp/server/transports/streamable_http_transport.rb:812) builds
34
+ # a `Rack::Request` with full headers but only pulls `HTTP_ORIGIN` out
35
+ # of it before handing the raw JSON body string to
36
+ # `ServerSession#handle_json` — a `UCP-Agent` header never crosses into
37
+ # the server at all. Wiring #negotiate for real means monkeypatching
38
+ # both (subclass/prepend `MCP::Server#init` to capture
39
+ # `clientInfo`/`_meta`, and `StreamableHTTPTransport#handle_initialization`
40
+ # to capture the `UCP-Agent` header), coupling this gem to `mcp`
41
+ # internals that could silently break on a `mcp` gem upgrade.
42
+ # Revisiting this needs either an upstream `mcp` gem hook at
43
+ # initialize time, or a deliberate decision to accept the monkeypatch
44
+ # coupling — not something to build silently as a side effect of
45
+ # another task.
16
46
  class CapabilityNegotiator
17
47
  def initialize(registry: CapabilityRegistry.default)
18
48
  @registry = registry
@@ -4,12 +4,21 @@ module Portage
4
4
  # through the CapabilityRegistry to the backing Adapter method, and wraps
5
5
  # the result as MCP's dual content/structuredContent output (see §5).
6
6
  class Dispatcher
7
- def initialize(adapter:, registry: CapabilityRegistry.default)
7
+ def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger)
8
8
  @adapter = adapter
9
9
  @registry = registry
10
+ @logger = logger
10
11
  end
11
12
 
12
- def call(capability:, action:, arguments: {})
13
+ # @param correlation_id [String, nil] threaded through to the adapter
14
+ # (via Support::CheckoutState.with_observability, scoped to this call
15
+ # only) so a checkout_state_transition event (§12) it emits during
16
+ # this call carries the same id as the tool_called event that
17
+ # triggered it. Optional, not correlation_id: required, since
18
+ # Dispatcher.call is also the conformance kit's
19
+ # (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside
20
+ # any MCP request (§23).
21
+ def call(capability:, action:, arguments: {}, correlation_id: nil)
13
22
  capability_definition = @registry.find(capability)
14
23
  raise UnknownCapabilityError, capability if capability_definition.nil?
15
24
 
@@ -20,12 +29,22 @@ module Portage
20
29
 
21
30
  Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
22
31
 
23
- result = @adapter.public_send(method_name, **arguments)
32
+ result = call_adapter(method_name, arguments, correlation_id)
24
33
  wrap(capability, result)
25
34
  end
26
35
 
27
36
  private
28
37
 
38
+ def call_adapter(method_name, arguments, correlation_id)
39
+ unless @adapter.is_a?(Portage::Ucp::Support::CheckoutState)
40
+ return @adapter.public_send(method_name, **arguments)
41
+ end
42
+
43
+ Portage::Ucp::Support::CheckoutState.with_observability(@adapter, @logger, correlation_id) do
44
+ @adapter.public_send(method_name, **arguments)
45
+ end
46
+ end
47
+
29
48
  def wrap(capability_name, result)
30
49
  unless result.respond_to?(:to_wire_h)
31
50
  return { content: [{ type: "text", text: result.inspect }], structuredContent: result }
@@ -1,4 +1,5 @@
1
1
  require "mcp"
2
+ require "securerandom"
2
3
 
3
4
  module Portage
4
5
  module Ucp
@@ -16,8 +17,10 @@ module Portage
16
17
  authenticator: Portage::Ucp.configuration.authenticator,
17
18
  rate_limiter: Portage::Ucp.configuration.rate_limiter,
18
19
  logger: Portage::Ucp.configuration.logger, **server_opts)
19
- context = Context.new(dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry),
20
- authenticator: authenticator, rate_limiter: rate_limiter, logger: logger)
20
+ context = Context.new(
21
+ dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger),
22
+ authenticator: authenticator, rate_limiter: rate_limiter, logger: logger
23
+ )
21
24
  tools = registry.advertised(adapter).flat_map do |capability|
22
25
  capability.actions.map do |action_name, method_name|
23
26
  build_tool(adapter: adapter, capability: capability, action_name: action_name,
@@ -46,17 +49,48 @@ module Portage
46
49
 
47
50
  def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
48
51
  server_context = kwargs.delete(:server_context)
49
- Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
50
- action: action_name, arguments: kwargs)
52
+ correlation_id = correlation_id_for(server_context)
53
+ Portage::Ucp::Observability.log(context.logger, "tool_call_received", capability: capability.name,
54
+ action: action_name,
55
+ correlation_id: correlation_id)
51
56
 
52
57
  rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
53
58
  rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
54
59
  return rejection if rejection
55
60
 
56
- result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs)
61
+ Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
62
+ action: action_name, arguments: kwargs,
63
+ correlation_id: correlation_id)
64
+
65
+ result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs,
66
+ correlation_id: correlation_id)
57
67
  ::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
58
68
  end
59
69
 
70
+ # Per-request correlation only (§23): `Context` above is built once per
71
+ # process in `.build`, and mcp 0.25.0's Streamable HTTP transport is
72
+ # explicitly stateful/multi-session, so memoizing an id there would
73
+ # stamp every session in the process with the same value. Prefers the
74
+ # inbound W3C `traceparent` the MCP spec passes through `_meta`
75
+ # untouched (SEP-414, see `MCP::TraceContext`) so a caller that already
76
+ # traces its own calls gets one trace across both sides; generates a
77
+ # fallback only when absent.
78
+ #
79
+ # `traceparent` is unauthenticated input — reachable before
80
+ # `authorize`/`rate_limit` run, same as the pre-auth event this
81
+ # correlation id feeds. Validated against W3C Trace Context's own
82
+ # format before use, which is spec-correct behavior (a malformed
83
+ # traceparent MUST be treated as absent, restarting the trace), and
84
+ # incidentally closes off unbounded-length log writes and non-String
85
+ # values reaching Dispatcher/CheckoutState as a "correlation_id".
86
+ TRACEPARENT_FORMAT = /\A[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}\z/
87
+
88
+ def self.correlation_id_for(server_context)
89
+ meta = server_context[:_meta] if server_context.respond_to?(:[])
90
+ traceparent = meta && (meta[:traceparent] || meta["traceparent"])
91
+ traceparent.is_a?(String) && TRACEPARENT_FORMAT.match?(traceparent) ? traceparent : SecureRandom.uuid
92
+ end
93
+
60
94
  def self.authorize(authenticator, server_context, mutating:)
61
95
  return unless mutating
62
96
 
@@ -6,10 +6,19 @@ module Portage
6
6
  # Structured log events (§12), emitted through a consumer-injected logger
7
7
  # (defaults to Logger.new($stdout)) so the gem instruments nothing to a
8
8
  # specific APM — it just exposes the events. Redacts payment_token,
9
- # oauth_token, and Authorization by default so a debugging trail never
10
- # leaks the exact values this gem is most careful never to log.
9
+ # oauth_token, and Authorization by default, plus the PII fields that
10
+ # actually appear on Portage::Ucp::Identity (email) and
11
+ # Portage::Ucp::PostalAddress (name/address/contact) — §23 step 4
12
+ # resolving §12's "Money-adjacent PII" phrase, which named no real key:
13
+ # Money/Total carry amounts and currency only, no PII; the PII that
14
+ # flows through the gem is on identity-linking results and fulfillment
15
+ # destinations instead.
11
16
  module Observability
12
- REDACTED_KEYS = %w[payment_token oauth_token authorization].freeze
17
+ REDACTED_KEYS = %w[
18
+ payment_token oauth_token authorization
19
+ email first_name last_name phone_number
20
+ street_address extended_address address_locality address_region address_country postal_code
21
+ ].freeze
13
22
  REDACTED = "[REDACTED]".freeze
14
23
 
15
24
  def self.log(logger, event, **fields)
@@ -11,11 +11,22 @@ module Portage
11
11
  # authenticated it. Normalizes to a Portage::Ucp::Order and hands off to a
12
12
  # consumer-supplied `on_order_event` callback — the gem doesn't assume
13
13
  # anything about how the consumer stores or reacts to order events.
14
+ #
15
+ # Emits Observability events (§12) directly via `logger:` rather than a
16
+ # new config.event_sink seam (§23 step 5): this endpoint is a plain Rack
17
+ # app, not built through Mcp::Server.build, so it never runs inside an
18
+ # MCP request and `mcp`'s own around_request/instrumentation_callback
19
+ # hooks can't see it — the same reason CheckoutState (§23 step 3) needed
20
+ # a logger threaded to it, not a reason to invent a second config
21
+ # option: `logger:` already exists on Portage::Ucp.configuration and
22
+ # every other collaborator that logs takes it the same way.
14
23
  class WebhookEndpoint
15
- def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE")
24
+ def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE",
25
+ logger: Portage::Ucp.configuration.logger)
16
26
  @secret = secret
17
27
  @on_order_event = on_order_event
18
28
  @signature_header = signature_header
29
+ @logger = logger
19
30
  end
20
31
 
21
32
  def call(env)
@@ -23,7 +34,10 @@ module Portage
23
34
  return respond(404, error: "not_found") unless request.post?
24
35
 
25
36
  body = request.body.read
26
- return respond(401, error: "invalid_signature") unless valid_signature?(body, env[@signature_header])
37
+ unless valid_signature?(body, env[@signature_header])
38
+ Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature")
39
+ return respond(401, error: "invalid_signature")
40
+ end
27
41
 
28
42
  handle_order_event(body)
29
43
  end
@@ -32,9 +46,13 @@ module Portage
32
46
 
33
47
  def handle_order_event(body)
34
48
  payload = JSON.parse(body, symbolize_names: true)
35
- @on_order_event.call(Portage::Ucp::Order.new(**payload))
49
+ order = Portage::Ucp::Order.new(**payload)
50
+ Portage::Ucp::Observability.log(@logger, "order_webhook_received", order_id: order.id,
51
+ checkout_id: order.checkout_id)
52
+ @on_order_event.call(order)
36
53
  respond(200, ok: true)
37
54
  rescue JSON::ParserError, ArgumentError
55
+ Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request")
38
56
  respond(400, error: "bad_request")
39
57
  end
40
58
 
@@ -145,6 +145,21 @@ module Portage
145
145
  end
146
146
  end
147
147
 
148
+ # Not memoized against @carts' own id sequence namespace collision:
149
+ # next_id("cart") is shared with create_cart/update_cart, same as every
150
+ # other *_id prefix here.
151
+ def reorder(order_id:, idempotency_key:)
152
+ dedup(idempotency_key) do
153
+ order = @orders[order_id]
154
+ next nil unless order
155
+
156
+ available, unavailable = partition_reorderable(order.line_items)
157
+ cart = build_cart(next_id("cart"), available, nil)
158
+ @carts[cart.id] = cart
159
+ Portage::Ucp::ReorderResult.new(cart: cart, unavailable_items: unavailable)
160
+ end
161
+ end
162
+
148
163
  def discount_codes_supported? = true
149
164
  def fulfillment_supported? = true
150
165
 
@@ -266,6 +281,42 @@ module Portage
266
281
  [Portage::Ucp::Total.new(type: "subtotal", amount: 0), Portage::Ucp::Total.new(type: "total", amount: 0)]
267
282
  end
268
283
 
284
+ # order.line_items here are the checkout's own response-shaped LineItem
285
+ # objects (see #store_order) — item.id is a variant id, not the
286
+ # product id build_cart's request-shaped hashes need (build_line_items'
287
+ # note), so this re-derives product_id by scanning @products the same
288
+ # way a real adapter would hit its platform's variant lookup.
289
+ def partition_reorderable(line_items)
290
+ available = []
291
+ unavailable = []
292
+
293
+ line_items.each do |line_item|
294
+ product_id = product_id_for_variant(line_item.item.id)
295
+
296
+ if product_id.nil?
297
+ unavailable << unavailable_reorder_item(line_item, "discontinued")
298
+ elsif line_item.item.id.start_with?(OUT_OF_STOCK_PREFIX)
299
+ unavailable << unavailable_reorder_item(line_item, "out_of_stock")
300
+ else
301
+ available << { product_id: product_id, quantity: line_item.quantity }
302
+ end
303
+ end
304
+
305
+ [available, unavailable]
306
+ end
307
+
308
+ def unavailable_reorder_item(line_item, reason)
309
+ Portage::Ucp::UnavailableReorderItem.new(item_id: line_item.item.id, title: line_item.item.title,
310
+ reason: reason)
311
+ end
312
+
313
+ def product_id_for_variant(variant_id)
314
+ @products.each_value do |product|
315
+ return product.id if product.variants.any? { |variant| variant.id == variant_id }
316
+ end
317
+ nil
318
+ end
319
+
269
320
  def raise_if_any_line_out_of_stock!(checkout)
270
321
  unavailable = checkout.line_items.select { |li| li.item.id.start_with?(OUT_OF_STOCK_PREFIX) }
271
322
  return if unavailable.empty?
@@ -16,6 +16,37 @@ module Portage
16
16
  # respectively): a checkout this process didn't create is still
17
17
  # schema-valid to report, just not information-complete.
18
18
  module CheckoutState
19
+ # Dispatcher wraps each adapter call in .with_observability rather
20
+ # than writing [logger, correlation_id] onto an instance variable on
21
+ # the adapter. The adapter instance is shared across every session in
22
+ # the process (built once in Mcp::Server.build), so an instance
23
+ # variable is a race: two concurrent requests against the same
24
+ # adapter clobber each other's correlation id, and
25
+ # checkout_state_transition ends up stamped with the wrong request's
26
+ # id — the same per-process-state trap §23 diagnosed for the
27
+ # correlation id generator itself, one layer down. Storage is
28
+ # Thread.current, keyed by the adapter's object_id so multiple
29
+ # adapters (e.g. in specs) don't share a slot, and .with_observability
30
+ # restores whatever was there before on the way out so a stale value
31
+ # never leaks into an unrelated direct adapter call afterward.
32
+ #
33
+ # A `correlation_id:` kwarg on every checkout method would carry the
34
+ # same information, but those methods are the public Adapter
35
+ # contract (§9), and adding a required kwarg there breaks any
36
+ # existing adapter/caller (§23) — this stays out of that contract.
37
+ def self.with_observability(adapter, logger, correlation_id)
38
+ key = observability_key(adapter)
39
+ previous = Thread.current[key]
40
+ Thread.current[key] = [logger, correlation_id]
41
+ yield
42
+ ensure
43
+ Thread.current[key] = previous
44
+ end
45
+
46
+ def self.observability_key(adapter)
47
+ :"portage_ucp_checkout_state_observability_#{adapter.object_id}"
48
+ end
49
+
19
50
  private
20
51
 
21
52
  def checkout_status(checkout_id)
@@ -24,6 +55,16 @@ module Portage
24
55
 
25
56
  def record_checkout_status(checkout_id, status)
26
57
  (@checkout_status ||= {})[checkout_id] = status
58
+ log_checkout_transition(checkout_id, status)
59
+ end
60
+
61
+ def log_checkout_transition(checkout_id, status)
62
+ logger, correlation_id = Thread.current[CheckoutState.observability_key(self)]
63
+ return unless logger
64
+
65
+ Portage::Ucp::Observability.log(logger, "checkout_state_transition", checkout_id: checkout_id,
66
+ status: status,
67
+ correlation_id: correlation_id)
27
68
  end
28
69
 
29
70
  # Keyed by String: adapters are called with an order id straight off
@@ -521,6 +521,28 @@ module Portage
521
521
  end
522
522
  end
523
523
 
524
+ # Portage extension (see Adapter#reorder / Capabilities::REORDER) — no
525
+ # schemas/ counterpart to cite, since it isn't part of the UCP spec.
526
+ # `reason` is a short adapter-chosen string (e.g. "out_of_stock",
527
+ # "discontinued"), not a closed enum — same posture as Adapter#cancel_order's
528
+ # `reason:`.
529
+ UnavailableReorderItem = Data.define(:item_id, :title, :reason) do
530
+ def to_wire_h = { "item_id" => item_id, "title" => title, "reason" => reason }
531
+ end
532
+
533
+ # Portage extension — the hydrated Cart plus what got dropped along the
534
+ # way, so a caller can tell the buyer what to expect before checkout
535
+ # rather than silently under-filling the cart.
536
+ ReorderResult = Data.define(:cart, :unavailable_items) do
537
+ def initialize(cart:, unavailable_items: []) = super
538
+
539
+ def to_wire_h
540
+ h = { "cart" => cart.to_wire_h }
541
+ h["unavailable_items"] = unavailable_items.map(&:to_wire_h) unless unavailable_items.empty?
542
+ h
543
+ end
544
+ end
545
+
524
546
  Identity = Data.define(:subject, :email, :linked_at)
525
547
  end
526
548
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.3.0".freeze
3
+ VERSION = "0.4.0".freeze
4
4
  end
5
5
  end
data/lib/portage/ucp.rb CHANGED
@@ -31,6 +31,7 @@ require_relative "ucp/capabilities/catalog"
31
31
  require_relative "ucp/capabilities/cart"
32
32
  require_relative "ucp/capabilities/checkout"
33
33
  require_relative "ucp/capabilities/order"
34
+ require_relative "ucp/capabilities/reorder"
34
35
  require_relative "ucp/capabilities/discount"
35
36
  require_relative "ucp/capabilities/fulfillment"
36
37
  require_relative "ucp/capabilities/identity_linking"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -156,6 +156,7 @@ files:
156
156
  - lib/portage/ucp/capabilities/fulfillment.rb
157
157
  - lib/portage/ucp/capabilities/identity_linking.rb
158
158
  - lib/portage/ucp/capabilities/order.rb
159
+ - lib/portage/ucp/capabilities/reorder.rb
159
160
  - lib/portage/ucp/capability.rb
160
161
  - lib/portage/ucp/capability_negotiator.rb
161
162
  - lib/portage/ucp/capability_registry.rb