portage-ucp 0.14.0 → 0.15.1

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: a245e863596f643972f10c9be20792055d6bf1d2925b90373571966896ee429a
4
- data.tar.gz: ad0008e190d5c865e613b616653a7d5dbd5f6b19166f8f71723e57ae70ef7ce0
3
+ metadata.gz: f99f8f55e4bbfeb276c93c8f1c7e63d36b3f30661702303d1295103e2ea4993c
4
+ data.tar.gz: 1c21857f2b52165c30994ba40bab0d8522520bfe8d7bb50b7459898630b71647
5
5
  SHA512:
6
- metadata.gz: d9a93b39705265b1095da290e5615ddbfcf3d3ea529d9b5863308ad99819c70b11ed77bc9805444a003b78c61bb418d4faa756617148d74b964b88beb90b5330
7
- data.tar.gz: c2c95f6b3217ea2c2dc6b86318eaa845677b45c753b707ad2eb17f2ae7599f92fe457f7f82a34d46cfea26f1b5bfa8bc17ff4ee46459c94967c6de5ed6286611
6
+ metadata.gz: c406f740bebf4a83c4eb9184d62e6abc693478ba97b0ab51cd4549bcc8996addd658b84f437f488ffffafa03782be6b2ea464b05057545091380b86b02066f7a
7
+ data.tar.gz: 6acd7187aad41ebae15d667ebc150e6896e1f0f72b2f86211d173bd77a25a26d1cc0119d7a9375329c688d13423a655244c43b04537a30aa9a3c851c12c4efca
data/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ - **Added: `dev.ucp.shopping.permalink` capability** (UCP 2026-08-25). An adapter that overrides `Adapter#permalink_endpoint` (default `nil`) to return its https browser endpoint is advertised with `config.endpoint`. `Capability` takes an optional `config:` callable for the manifest entry's `config`. No MCP tool: the permalink is a browser redirect the host serves (design-log §61).
10
+ - **Added: policy disclosure warnings on `Cart` and `Checkout`** (UCP 2026-08-25). Each entry of `policies:` now also yields a `messages[]` warning with `code` equal to the policy `type`, `presentation: "disclosure"`, `content` from the description and `url`/`path` when the policy has them. Derived in `to_wire_h`, so every adapter gets it and nothing is stored. Orders are unchanged (design-log §62).
11
+ - **Added: optional `messages:` on `Cart` and `Checkout`.** An array of wire-shaped message hashes (default `[]`) an adapter can set itself. `to_wire_h` emits them before the derived policy warnings and drops a derived warning when the adapter already warned with the same `code`. `messages` is omitted from the wire when the combined list is empty (design-log §63).
12
+ - **Added: post-purchase adjustments on the order ledger** (order-ledger plan Phase 3). `Dispatcher` writes the `adjustments` of the `Order` returned by `cancel_order`, `refund_order` or `request_return` onto that order's existing ledger record; line items, totals and other purchase facts are never rewritten. `OrderLedger#refresh(order_id, adapter:)` re-fetches with `get_order` and replaces the stored adjustments the same way, for disputes and returns that settle later. `OrderLedger#unresolved` lists every `pending` adjustment with its order id. An order with no snapshot is skipped, not backfilled. A ledger write failure raises only after the adapter call has succeeded, as the Phase 1 snapshot does (design-log §64).
13
+
14
+ ## [0.15.1] - 2026-10-18
15
+
16
+ - **Fixed: generated MCP tools carry a real description, per-parameter description, and `readOnlyHint`/`destructiveHint`/`idempotentHint` annotations**, instead of `Mcp::Server.build_tool` emitting the bare `capability#action` identifier as the description and an empty schema for every parameter. New `Mcp::ToolMetadata` supplies this per action, sourced from the `@param`/`@return` docs already on `Adapter`'s methods; an action without an entry still falls back to the old `capability#action` format. `Adapter`, the wire format and every adapter gem are unchanged — this only changes what a calling agent reads in `tools/list`. Patch-level.
17
+
18
+ ## [0.15.0] - 2026-10-13
19
+
20
+ - **Added: optional `policies:` on `Cart`, `Checkout`, `Order`, `Product`, `CatalogSearchResult` and `ProductDetail`** (UCP 2026-08-25 `policies[]`). An array of `policy.json` hashes (`type` and `description` required; `applies_to` and `url` optional), default `[]`, omitted from the wire payload when empty. `portage-ucp-shopify` 0.7.0 supplies it (design-log §60).
21
+
9
22
  ## [0.14.0] - 2026-10-09
10
23
 
11
24
  Moves the gem to UCP 2026-08-25 (design-log §56-58). Minor-level for a pre-1.0 gem, with breaking config changes below.
@@ -105,7 +105,7 @@ module Portage
105
105
 
106
106
  # --- Reorder (app.portage-ucp.reorder — Portage extension, not part of
107
107
  # the UCP spec: dev.ucp.dev has no reorder/cart-hydration capability as
108
- # of the 2026-04-08 spec) ---
108
+ # of the 2026-08-25 spec) ---
109
109
  # Hydrates a cart from a previous order's line items so a caller doesn't
110
110
  # have to re-walk get_order + create_cart itself. Re-checks each item's
111
111
  # current price/availability rather than replaying the order's snapshot
@@ -132,6 +132,16 @@ module Portage
132
132
  # @return [Boolean]
133
133
  def fulfillment_supported? = false
134
134
 
135
+ # --- Permalink (dev.ucp.shopping.permalink) ---
136
+ # The business's browser endpoint for shopping permalinks, e.g.
137
+ # "https://merchant.example/buy" (absolute https, no query, fragment or
138
+ # trailing slash). The endpoint itself is a redirect route the
139
+ # business's storefront serves, not an MCP action, so this is a
140
+ # config-carrying predicate: nil (the default) leaves the capability
141
+ # out, a String advertises it with `config.endpoint`.
142
+ # @return [String, nil]
143
+ def permalink_endpoint = nil
144
+
135
145
  # --- Identity Linking (dev.ucp.shopping.identity, OAuth 2.0) ---
136
146
  # @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
137
147
  def link_identity(oauth_token:) = not_implemented
@@ -7,8 +7,8 @@ module Portage
7
7
  actions: { "link_identity" => :link_identity }
8
8
  )
9
9
 
10
- ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER,
11
- PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
10
+ ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, PERMALINK,
11
+ REORDER, PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,19 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # schemas/shopping/permalink.json — a browser redirect endpoint the
5
+ # business serves (services/shopping/permalink.openapi.json), not an
6
+ # MCP action, so `actions` is empty and advertisement runs off
7
+ # Adapter#permalink_endpoint, whose value is the required
8
+ # `config.endpoint`.
9
+ PERMALINK = Portage::Ucp::Capability.new(
10
+ name: "dev.ucp.shopping.permalink",
11
+ version: "1",
12
+ schema: "shopping/permalink.json",
13
+ actions: {},
14
+ predicate: :permalink_endpoint,
15
+ config: ->(adapter) { { endpoint: adapter.permalink_endpoint } }
16
+ )
17
+ end
18
+ end
19
+ end
@@ -15,7 +15,11 @@ module Portage
15
15
  # `schema:` is the capability's schema path under a UCP revision's
16
16
  # `schemas/` (e.g. "shopping/checkout.json"); the manifest turns it into
17
17
  # the `schema` URL 2026-08-25 business profiles require.
18
- def initialize(name:, version:, actions:, predicate: nil, schema: nil)
18
+ #
19
+ # `config:` is a callable taking the adapter and returning the `config`
20
+ # object the manifest entry carries (permalink's `{endpoint:}`).
21
+ def initialize(name:, version:, actions:, predicate: nil, schema: nil, config: nil)
22
+ @config = config
19
23
  @name = name
20
24
  @schema = schema
21
25
  @version = version
@@ -32,6 +36,9 @@ module Portage
32
36
  actions.values.any? { |method_name| overridden?(adapter, method_name) }
33
37
  end
34
38
 
39
+ # @return [Hash, nil] the manifest entry's `config`, if the capability has one
40
+ def config_for(adapter) = @config&.call(adapter)
41
+
35
42
  private
36
43
 
37
44
  def overridden?(adapter, method_name)
@@ -9,6 +9,10 @@ module Portage
9
9
  # the only `dev.ucp.shopping.checkout` action that dispatches a charge.
10
10
  PAYMENT_COMPLETING_ACTION = "complete_checkout".freeze
11
11
 
12
+ # Each returns the updated Order whose adjustments land on the order
13
+ # ledger (docs/plans/order-ledger.md Phase 3).
14
+ ORDER_ADJUSTING_ACTIONS = %w[cancel_order refund_order request_return].freeze
15
+
12
16
  # @param shop [String, nil] identifies which store this Dispatcher
13
17
  # instance is completing charges for, threaded straight onto the
14
18
  # transaction log record — Dispatcher/Adapter have no shared notion
@@ -92,6 +96,11 @@ module Portage
92
96
  else
93
97
  call_adapter(method_name, arguments, correlation_id)
94
98
  end
99
+ # Same after-the-adapter posture as the Phase 1 snapshot in
100
+ # #call_and_log_transaction: the cancel/refund/return has already
101
+ # happened upstream, so a ledger write failure surfaces only after
102
+ # it — never instead of it, never retried or rolled back here.
103
+ @order_ledger.record_adjustments(result) if result && ORDER_ADJUSTING_ACTIONS.include?(action)
95
104
  validate_outbound_boundaries!(result)
96
105
  wrap(capability, result)
97
106
  end
@@ -62,6 +62,8 @@ module Portage
62
62
  @registry.advertised(@adapter).to_h do |capability|
63
63
  entry = { version: UCP_VERSION }
64
64
  entry[:schema] = "https://ucp.dev/#{UCP_VERSION}/schemas/#{capability.schema}" if capability.schema
65
+ config = capability.config_for(@adapter)
66
+ entry[:config] = config if config
65
67
  [capability.name, [entry]]
66
68
  end
67
69
  end
@@ -42,19 +42,40 @@ module Portage
42
42
  def self.build_tool(adapter:, capability:, action_name:, method_name:, context:)
43
43
  keyword_params = adapter.method(method_name).parameters.select { |type, _| %i[key keyreq].include?(type) }
44
44
  required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
45
- properties = keyword_params.to_h { |_, name| [name.to_s, {}] }
45
+ metadata = ToolMetadata::TOOLS[action_name]
46
+ properties = keyword_params.to_h { |_, name| [name.to_s, property_schema(name.to_s, metadata)] }
46
47
  mutating = keyword_params.any? { |_, name| name == :idempotency_key }
47
48
 
48
49
  ::MCP::Tool.define(
49
50
  name: action_name,
50
- description: "#{capability.name}##{action_name}",
51
- input_schema: { type: "object", properties: properties, required: required }
51
+ description: description_for(capability, action_name, metadata),
52
+ input_schema: { type: "object", properties: properties, required: required },
53
+ annotations: metadata && metadata[:annotations]
52
54
  ) do |**kwargs|
53
55
  Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
54
56
  mutating: mutating, kwargs: kwargs)
55
57
  end
56
58
  end
57
59
 
60
+ # @return [String] the documented description from ToolMetadata, or
61
+ # (for an undocumented/extension action) the old namespaced
62
+ # fallback, e.g. "dev.ucp.shopping.catalog#get_product".
63
+ def self.description_for(capability, action_name, metadata)
64
+ metadata ? metadata[:description] : "#{capability.name}##{action_name}"
65
+ end
66
+
67
+ # @return [Hash] a property's JSON Schema entry: a description from
68
+ # ToolMetadata when the action has an entry (none for an
69
+ # undocumented/extension action — see `metadata&.fetch` above), plus
70
+ # a `type` where ToolMetadata::PARAM_TYPES pins one down for this
71
+ # param name.
72
+ def self.property_schema(name, metadata)
73
+ schema = {}
74
+ schema[:description] = metadata[:params][name] if metadata && metadata[:params][name]
75
+ schema[:type] = ToolMetadata::PARAM_TYPES[name] if ToolMetadata::PARAM_TYPES[name]
76
+ schema
77
+ end
78
+
58
79
  def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
59
80
  server_context = kwargs.delete(:server_context)
60
81
  correlation_id = correlation_id_for(server_context)
@@ -0,0 +1,189 @@
1
+ module Portage
2
+ module Ucp
3
+ module Mcp
4
+ # Per-action tool metadata — description, parameter descriptions, and
5
+ # MCP annotations — for Server.build_tool to attach to each generated
6
+ # ::MCP::Tool. Kept separate from Capability (name/version/actions/
7
+ # schema — the UCP wire contract) since this is MCP-presentation only:
8
+ # what a calling agent reads before it decides whether and how to call
9
+ # a tool, not part of the UCP spec itself. Content mirrors the @param/
10
+ # @return docs already on Adapter's methods (adapter.rb) rather than
11
+ # inventing anything new.
12
+ module ToolMetadata
13
+ ID = "A stable identifier returned by an earlier call (e.g. get_cart, create_checkout) " \
14
+ "— not something the caller makes up.".freeze
15
+ IDEMPOTENCY_KEY = "Caller-generated key that dedupes retries: calling again with the same " \
16
+ "key returns the original result instead of repeating the effect.".freeze
17
+ REPLACE_NOTE = "Replaces the existing contents rather than adding to them — resend the " \
18
+ "full desired list with an item adjusted, added, or omitted.".freeze
19
+
20
+ # JSON Schema `type` for parameter names whose shape is unambiguous
21
+ # from the name alone and constant across every action it appears
22
+ # in; left out entirely (no `type`) where it genuinely varies.
23
+ PARAM_TYPES = {
24
+ "query" => "string", "limit" => "integer", "product_id" => "string",
25
+ "product_ids" => "array", "cart_id" => "string", "checkout_id" => "string",
26
+ "order_id" => "string", "idempotency_key" => "string", "payment_token" => "string",
27
+ "reason" => "string", "line_items" => "array", "discount_codes" => "array",
28
+ "fulfillment" => "object", "mandate" => "object"
29
+ }.freeze
30
+
31
+ TOOLS = {
32
+ "search_catalog" => {
33
+ description: "Search the store's product catalog by free-text query, returning up to " \
34
+ "`limit` matches with price and availability. Use this to discover products " \
35
+ "when you don't yet have an ID; once you do, use get_product for one or " \
36
+ "lookup_catalog for several — neither re-runs a search.",
37
+ params: { "query" => "Free-text search query.", "limit" => "Maximum number of results to return." },
38
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
39
+ },
40
+ "get_product" => {
41
+ description: "Fetch full details — price, availability, and whatever else the store's " \
42
+ "catalog carries — for one product by ID. Returns not-found rather than " \
43
+ "raising if the ID doesn't exist. Use search_catalog first if you don't " \
44
+ "already have the ID; use lookup_catalog instead for several known IDs, to " \
45
+ "avoid one call per product.",
46
+ params: { "product_id" => "Product ID, as returned by search_catalog or lookup_catalog." },
47
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
48
+ },
49
+ "lookup_catalog" => {
50
+ description: "Batch-fetch several products by ID in one call, same result shape as " \
51
+ "search_catalog. Use when you already have a set of product IDs — e.g. " \
52
+ "hydrating a cart or order — instead of calling get_product once per ID.",
53
+ params: { "product_ids" => "Product IDs to fetch, as returned by search_catalog, a cart, or an order." },
54
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
55
+ },
56
+ "create_cart" => {
57
+ description: "Start a new cart from a list of line items — the cart's complete starting " \
58
+ "contents, not an item appended to an existing one. Use update_cart to change " \
59
+ "a cart that already exists. Optional discount_codes applies coupons if the " \
60
+ "store supports them.",
61
+ params: {
62
+ "line_items" => "Items to add, each {product_id:, quantity:}.",
63
+ "idempotency_key" => IDEMPOTENCY_KEY,
64
+ "discount_codes" => "Discount/coupon codes to apply, if the store supports them."
65
+ },
66
+ annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
67
+ },
68
+ "get_cart" => {
69
+ description: "Fetch a cart's current contents and totals by ID.",
70
+ params: { "cart_id" => ID },
71
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
72
+ },
73
+ "update_cart" => {
74
+ description: "Replace a cart's contents. #{REPLACE_NOTE} Use this for every change to an " \
75
+ "existing cart: adding an item, removing one, or changing a quantity.",
76
+ params: {
77
+ "cart_id" => ID,
78
+ "line_items" => "The cart's complete desired contents, each {product_id:, quantity:}. #{REPLACE_NOTE}",
79
+ "idempotency_key" => IDEMPOTENCY_KEY,
80
+ "discount_codes" => "Discount/coupon codes to apply, if the store supports them."
81
+ },
82
+ annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
83
+ },
84
+ "cancel_cart" => {
85
+ description: "Cancel and discard a cart. Irreversible — use this to abandon a cart the " \
86
+ "shopper no longer wants.",
87
+ params: { "cart_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY },
88
+ annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
89
+ },
90
+ "create_checkout" => {
91
+ description: "Start a checkout directly from line items, skipping a separate cart. " \
92
+ "Optional discount_codes applies coupons; optional fulfillment carries the " \
93
+ "shopper's desired shipping or pickup selection, if the store supports it. " \
94
+ "Does not charge anything — call complete_checkout once the shopper approves " \
95
+ "the total from get_checkout.",
96
+ params: {
97
+ "line_items" => "Items to buy, each {product_id:, quantity:}.",
98
+ "idempotency_key" => IDEMPOTENCY_KEY,
99
+ "discount_codes" => "Discount/coupon codes to apply, if the store supports them.",
100
+ "fulfillment" => "Desired shipping/pickup method selection, if the store supports it."
101
+ },
102
+ annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
103
+ },
104
+ "get_checkout" => {
105
+ description: "Fetch a checkout's current contents, totals, and status by ID.",
106
+ params: { "checkout_id" => ID },
107
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
108
+ },
109
+ "update_checkout" => {
110
+ description: "Replace a checkout's contents before payment. #{REPLACE_NOTE} Also accepts " \
111
+ "updated discount_codes or fulfillment selection. Does not charge anything.",
112
+ params: {
113
+ "checkout_id" => ID,
114
+ "line_items" => "The checkout's complete desired contents, each {product_id:, quantity:}. " \
115
+ "#{REPLACE_NOTE}",
116
+ "idempotency_key" => IDEMPOTENCY_KEY,
117
+ "discount_codes" => "Discount/coupon codes to apply, if the store supports them.",
118
+ "fulfillment" => "Desired shipping/pickup method selection, if the store supports it."
119
+ },
120
+ annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
121
+ },
122
+ "complete_checkout" => {
123
+ description: "Charge the checkout and place the order — the one tool here that moves " \
124
+ "money. Takes a single-use payment_token from a payment handler, never a raw " \
125
+ "card number. Re-checks stock at this point, since catalog calls don't " \
126
+ "guarantee live inventory, and fails rather than charging if a line item is " \
127
+ "no longer available. Only call this after the shopper has explicitly " \
128
+ "approved the exact total from get_checkout.",
129
+ params: {
130
+ "checkout_id" => ID,
131
+ "payment_token" => "Single-use payment token from a payment handler — never a raw card number.",
132
+ "idempotency_key" => IDEMPOTENCY_KEY,
133
+ "mandate" => "Optional AP2 payment mandate authorizing this charge, alongside or instead " \
134
+ "of payment_token."
135
+ },
136
+ annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
137
+ },
138
+ "cancel_checkout" => {
139
+ description: "Cancel a checkout before payment. Irreversible — use this to abandon a " \
140
+ "checkout the shopper no longer wants. Safe with respect to money, since " \
141
+ "complete_checkout hasn't run yet.",
142
+ params: { "checkout_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY },
143
+ annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
144
+ },
145
+ "get_order" => {
146
+ description: "Fetch a placed order's status, line items, and totals by ID. Returns " \
147
+ "not-found rather than raising if the ID doesn't exist.",
148
+ params: { "order_id" => ID },
149
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
150
+ },
151
+ "cancel_order" => {
152
+ description: "Cancel an already-placed order. Irreversible. Optional reason is a " \
153
+ "free-text note for the merchant, not a fixed enum.",
154
+ params: {
155
+ "order_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY,
156
+ "reason" => "Optional free-text reason for the cancellation."
157
+ },
158
+ annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
159
+ },
160
+ "request_return" => {
161
+ description: "Request a return for one or more order line items. This files the request; " \
162
+ "it doesn't refund money itself — the order shows the items as pending until " \
163
+ "the merchant processes it. Use refund_order only once a refund has actually " \
164
+ "been approved.",
165
+ params: {
166
+ "order_id" => ID,
167
+ "line_items" => "Items to return, each {id:, quantity:}.",
168
+ "idempotency_key" => IDEMPOTENCY_KEY,
169
+ "reason" => "Optional free-text reason for the return."
170
+ },
171
+ annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
172
+ },
173
+ "refund_order" => {
174
+ description: "Refund one or more order line items, moving money back to the shopper. " \
175
+ "Irreversible. Use request_return first if the merchant requires a return " \
176
+ "before refunding.",
177
+ params: {
178
+ "order_id" => ID,
179
+ "line_items" => "Items to refund, each {id:, quantity:}.",
180
+ "idempotency_key" => IDEMPOTENCY_KEY,
181
+ "reason" => "Optional free-text reason for the refund."
182
+ },
183
+ annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
184
+ }
185
+ }.freeze
186
+ end
187
+ end
188
+ end
189
+ end
@@ -7,7 +7,7 @@ module Portage
7
7
  module Security
8
8
  # Verifies that an inbound agent request carries a valid RFC 9421 HTTP
9
9
  # Message Signature, per UCP's own signature spec
10
- # (ucp.dev/2026-04-08/specification/signatures/, pinned 2026-09-15 —
10
+ # (ucp.dev/2026-08-25/specification/signatures/, pinned 2026-09-15 —
11
11
  # this is the piece §22 of the design log flagged as unresearched
12
12
  # before writing code, now checked against the live spec rather than
13
13
  # guessed). This is a distinct concern from three other signing
@@ -38,6 +38,51 @@ module Portage
38
38
  @store.record(order.id, record)
39
39
  end
40
40
 
41
+ # Phase 3: replaces the stored snapshot's `adjustments` with
42
+ # `order.adjustments` — the upstream list is authoritative for
43
+ # adjustment state (a `pending` return settles on the merchant's
44
+ # clock), so a merge would only keep stale statuses. Every other
45
+ # purchase fact stays exactly as first recorded; `order`'s own
46
+ # line items/totals are never read.
47
+ #
48
+ # No-op (returns nil) when there's no purchase snapshot for the
49
+ # order: Phase 2's "report the gap, don't backfill" rule — a
50
+ # post-purchase payload isn't evidence of what was originally paid.
51
+ #
52
+ # ponytail: find-then-record isn't one lock; a write landing in
53
+ # between is lost. Add a Store#update if two writers ever share an
54
+ # order id concurrently.
55
+ def record_adjustments(order)
56
+ existing = find(order.id)
57
+ return nil unless existing
58
+
59
+ # Same omit-when-empty shape as Order#to_wire_h.
60
+ adjustments = order.adjustments.map(&:to_wire_h)
61
+ snapshot = existing["order"].except("adjustments")
62
+ snapshot = existing["order"].merge("adjustments" => adjustments) unless adjustments.empty?
63
+ @store.record(order.id, existing.merge("order" => snapshot))
64
+ end
65
+
66
+ # Explicit reconcile for adjustments with no originating dispatch
67
+ # (disputes) or that settle later (returns) — re-fetches via the
68
+ # caller's `adapter` and merges adjustments only, per
69
+ # #record_adjustments. No polling (plan Phase 3): call it when you
70
+ # want current state.
71
+ def refresh(order_id, adapter:)
72
+ order = adapter.get_order(order_id: order_id)
73
+ record_adjustments(order) if order
74
+ end
75
+
76
+ # Adjustments still `pending` — money owed but not yet received —
77
+ # each paired with its order id.
78
+ def unresolved
79
+ all.flat_map do |record|
80
+ order = record["order"]
81
+ Array(order["adjustments"]).select { |adjustment| adjustment["status"] == "pending" }
82
+ .map { |adjustment| { "order_id" => order["id"], "adjustment" => adjustment } }
83
+ end
84
+ end
85
+
41
86
  def find(order_id)
42
87
  @store.find(order_id)
43
88
  end
@@ -1,3 +1,5 @@
1
+ require "cgi"
2
+
1
3
  module Portage
2
4
  module Ucp
3
5
  # Internal arithmetic helper only — never appears in a wire shape directly.
@@ -115,9 +117,10 @@ module Portage
115
117
  # (Shopify/Wix metafields etc.) — see Portage::Ucp::Shopify.configure's
116
118
  # metadata_field.
117
119
  Product = Data.define(:id, :title, :description, :price_range, :variants, :handle, :url, :categories,
118
- :list_price_range, :media, :options, :tags, :metadata, :rating) do
120
+ :list_price_range, :media, :options, :tags, :metadata, :rating, :policies) do
119
121
  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)
122
+ list_price_range: nil, media: [], options: [], tags: [], metadata: nil, rating: nil,
123
+ policies: [])
121
124
  super
122
125
  end
123
126
 
@@ -135,6 +138,7 @@ module Portage
135
138
  h["media"] = media.map(&:to_wire_h) unless media.empty?
136
139
  h["options"] = options.map(&:to_wire_h) unless options.empty?
137
140
  h["tags"] = tags unless tags.empty?
141
+ h["policies"] = policies unless policies.empty?
138
142
  h
139
143
  end
140
144
  end
@@ -144,12 +148,13 @@ module Portage
144
148
  # isn't schema-conformant (search_response requires `ucp` + `products` at
145
149
  # the top level, not a bare array). `messages` entries are left as plain
146
150
  # wire-shaped hashes, same rationale as Variant#barcodes above.
147
- CatalogSearchResult = Data.define(:products, :messages) do
148
- def initialize(products:, messages: []) = super
151
+ CatalogSearchResult = Data.define(:products, :messages, :policies) do
152
+ def initialize(products:, messages: [], policies: []) = super
149
153
 
150
154
  def to_wire_h
151
155
  h = { "products" => products.map(&:to_wire_h) }
152
156
  h["messages"] = messages unless messages.empty?
157
+ h["policies"] = policies unless policies.empty?
153
158
  h
154
159
  end
155
160
  end
@@ -161,8 +166,14 @@ module Portage
161
166
  # extension (interactive variant narrowing) — Adapter#get_product's
162
167
  # signature has no `selected:`/`preferences:` params to source it from
163
168
  # 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 }
169
+ ProductDetail = Data.define(:product, :policies) do
170
+ def initialize(product:, policies: []) = super
171
+
172
+ def to_wire_h
173
+ h = { "product" => product.to_wire_h }
174
+ h["policies"] = policies unless policies.empty?
175
+ h
176
+ end
166
177
  end
167
178
 
168
179
  # One cost-breakdown entry (schemas/shopping/types/total.json). `amount` is a
@@ -198,6 +209,10 @@ module Portage
198
209
  end
199
210
  end
200
211
 
212
+ # `policies` (cart/checkout/order/catalog responses and products) is the
213
+ # 2026-08-25 policy.json array (return/warranty terms), optional and omitted
214
+ # when empty. Entries stay wire-shaped hashes (`type` and `description` are
215
+ # required, `applies_to`/`url` optional), same posture as `messages`.
201
216
  # schemas/shopping/types/link.json
202
217
  Link = Data.define(:type, :url, :title) do
203
218
  def initialize(type:, url:, title: nil) = super
@@ -374,19 +389,57 @@ module Portage
374
389
  end
375
390
  end
376
391
 
392
+ # A policy's buyer-facing disclosure obligation is a `messages[]` warning
393
+ # whose `code` equals the policy `type` (policy.json). Derived at
394
+ # serialization for Cart and Checkout, so it needs no storage and survives
395
+ # any rebuild without duplicating. Content is the plain (else markdown)
396
+ # description; an html-only policy has its tags stripped, since the
397
+ # warning schema has no html format.
398
+ def self.policy_messages(policies)
399
+ policies.map do |policy|
400
+ description = policy["description"]
401
+ markdown = !description["plain"] && description["markdown"]
402
+ content = description["plain"] || description["markdown"] || html_to_text(description["html"])
403
+ message = { "type" => "warning", "code" => policy["type"], "presentation" => "disclosure",
404
+ "content" => content }
405
+ message["content_type"] = "markdown" if markdown
406
+ message["url"] = policy["url"] if policy["url"]
407
+ message["path"] = policy["applies_to"].first if policy["applies_to"]&.one?
408
+ message
409
+ end
410
+ end
411
+
412
+ # Adapter messages first, then the derived policy warnings, minus any
413
+ # whose `code` the adapter already warned about (that warning meets the
414
+ # disclosure obligation). Only the adapter's `messages` are stored.
415
+ def self.wire_messages(messages, policies)
416
+ warned = messages.select { |m| m["type"] == "warning" }.to_set { |m| m["code"] }
417
+ messages + policy_messages(policies).reject { |m| warned.include?(m["code"]) }
418
+ end
419
+
420
+ def self.html_to_text(html)
421
+ text = html.gsub(%r{</(p|div|li|h\d)>|<br\s*/?>}i, "\n").gsub(/<[^>]*>/, "")
422
+ CGI.unescapeHTML(text).gsub(/\n{3,}/, "\n\n").strip
423
+ end
424
+
377
425
  # schemas/shopping/cart.json — requires ucp/id/line_items/currency/totals.
378
426
  # The "ucp" envelope is added centrally by Portage::Ucp::WireEnvelope, not stored
379
427
  # on the value object itself.
380
428
  # `discounts` is the dev.ucp.shopping.discount extension (optional on both
381
429
  # cart.json and its own schema) — omitted from the wire payload when empty,
382
430
  # same as Order#adjustments.
383
- Cart = Data.define(:id, :line_items, :currency, :totals, :discounts) do
384
- def initialize(id:, line_items:, currency:, totals:, discounts: Discounts.new) = super
431
+ # `messages` holds only the adapter's own wire-shaped message hashes;
432
+ # policy warnings are derived in to_wire_h (see Ucp.wire_messages).
433
+ Cart = Data.define(:id, :line_items, :currency, :totals, :discounts, :policies, :messages) do
434
+ def initialize(id:, line_items:, currency:, totals:, discounts: Discounts.new, policies: [], messages: []) = super
385
435
 
386
436
  def to_wire_h
387
437
  h = { "id" => id, "line_items" => line_items.map(&:to_wire_h), "currency" => currency,
388
438
  "totals" => totals.map(&:to_wire_h) }
389
439
  h["discounts"] = discounts.to_wire_h unless discounts.empty?
440
+ h["policies"] = policies unless policies.empty?
441
+ all = Ucp.wire_messages(messages, policies)
442
+ h["messages"] = all unless all.empty?
390
443
  h
391
444
  end
392
445
  end
@@ -415,9 +468,9 @@ module Portage
415
468
  # omitted from the wire payload when empty), same posture as
416
469
  # Order#adjustments.
417
470
  Checkout = Data.define(:id, :status, :line_items, :currency, :totals, :links, :order, :discounts,
418
- :fulfillment) do
471
+ :fulfillment, :policies, :messages) do
419
472
  def initialize(id:, status:, line_items:, currency:, totals:, links:, order: nil, discounts: Discounts.new,
420
- fulfillment: CheckoutFulfillment.new)
473
+ fulfillment: CheckoutFulfillment.new, policies: [], messages: [])
421
474
  super
422
475
  end
423
476
 
@@ -427,6 +480,9 @@ module Portage
427
480
  h["order"] = order.to_wire_h if order
428
481
  h["discounts"] = discounts.to_wire_h unless discounts.empty?
429
482
  h["fulfillment"] = fulfillment.to_wire_h unless fulfillment.empty?
483
+ h["policies"] = policies unless policies.empty?
484
+ all = Ucp.wire_messages(messages, policies)
485
+ h["messages"] = all unless all.empty?
430
486
  h
431
487
  end
432
488
  end
@@ -508,9 +564,9 @@ module Portage
508
564
  # line_items/fulfillment/currency/totals. `adjustments` is optional —
509
565
  # omitted from the wire payload when empty.
510
566
  Order = Data.define(:id, :checkout_id, :permalink_url, :line_items, :fulfillment, :currency, :totals,
511
- :adjustments) do
567
+ :adjustments, :policies) do
512
568
  def initialize(id:, checkout_id:, permalink_url:, line_items:, fulfillment:, currency:, totals:,
513
- adjustments: [])
569
+ adjustments: [], policies: [])
514
570
  super
515
571
  end
516
572
 
@@ -519,6 +575,7 @@ module Portage
519
575
  "line_items" => line_items.map(&:to_wire_h), "fulfillment" => fulfillment.to_wire_h,
520
576
  "currency" => currency, "totals" => totals.map(&:to_wire_h) }
521
577
  h["adjustments"] = adjustments.map(&:to_wire_h) unless adjustments.empty?
578
+ h["policies"] = policies unless policies.empty?
522
579
  h
523
580
  end
524
581
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.14.0".freeze
3
+ VERSION = "0.15.1".freeze
4
4
  end
5
5
  end
data/lib/portage/ucp.rb CHANGED
@@ -57,12 +57,14 @@ require_relative "ucp/capabilities/order"
57
57
  require_relative "ucp/capabilities/reorder"
58
58
  require_relative "ucp/capabilities/discount"
59
59
  require_relative "ucp/capabilities/fulfillment"
60
+ require_relative "ucp/capabilities/permalink"
60
61
  require_relative "ucp/capabilities/payment_enrollment"
61
62
  require_relative "ucp/capabilities/payment_method"
62
63
  require_relative "ucp/capabilities/shopper_data"
63
64
  require_relative "ucp/capabilities/identity_linking"
64
65
  require_relative "ucp/capability_registry"
65
66
  require_relative "ucp/dispatcher"
67
+ require_relative "ucp/mcp/tool_metadata"
66
68
  require_relative "ucp/mcp/server"
67
69
  require_relative "ucp/rack/forwarded_request"
68
70
  require_relative "ucp/rack/manifest_endpoint"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-09 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bigdecimal
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.1'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: json_schemer
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -38,6 +52,20 @@ dependencies:
38
52
  - - "~>"
39
53
  - !ruby/object:Gem::Version
40
54
  version: '2.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: logger
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.5'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.5'
41
69
  - !ruby/object:Gem::Dependency
42
70
  name: mcp
43
71
  requirement: !ruby/object:Gem::Requirement
@@ -165,6 +193,7 @@ files:
165
193
  - lib/portage/ucp/capabilities/order.rb
166
194
  - lib/portage/ucp/capabilities/payment_enrollment.rb
167
195
  - lib/portage/ucp/capabilities/payment_method.rb
196
+ - lib/portage/ucp/capabilities/permalink.rb
168
197
  - lib/portage/ucp/capabilities/reorder.rb
169
198
  - lib/portage/ucp/capabilities/shopper_data.rb
170
199
  - lib/portage/ucp/capability.rb
@@ -176,6 +205,7 @@ files:
176
205
  - lib/portage/ucp/errors.rb
177
206
  - lib/portage/ucp/manifest.rb
178
207
  - lib/portage/ucp/mcp/server.rb
208
+ - lib/portage/ucp/mcp/tool_metadata.rb
179
209
  - lib/portage/ucp/observability.rb
180
210
  - lib/portage/ucp/payment_enrollment_guard.rb
181
211
  - lib/portage/ucp/payment_token_guard.rb