portage-ucp 0.15.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: 3030cd7b510d8e0e829878dd48ab11fd18f70e0d0e5cac5107852ead26a0077c
4
- data.tar.gz: 7e6abed9cd7ea40da3ee5f8681ec69f80a6f8c852e8956901f4fce1b383816da
3
+ metadata.gz: f99f8f55e4bbfeb276c93c8f1c7e63d36b3f30661702303d1295103e2ea4993c
4
+ data.tar.gz: 1c21857f2b52165c30994ba40bab0d8522520bfe8d7bb50b7459898630b71647
5
5
  SHA512:
6
- metadata.gz: 4c4dddb8b6aaeaa674d270400287420f3bcbbfc028c8d38b9482fe53fe2cb47b493711cd7fffe50051b892b49f720f4332ba0e3af89fe595df9d46cdf80c5306
7
- data.tar.gz: 5b1c1ee5a21fae8d7c8821c43820bcdcca15f54001e9d6e25af3b4916965da69a1bedb144faadb06ec222c11a59d3cd7e60c753579a4d8931924f51e62f06b78
6
+ metadata.gz: c406f740bebf4a83c4eb9184d62e6abc693478ba97b0ab51cd4549bcc8996addd658b84f437f488ffffafa03782be6b2ea464b05057545091380b86b02066f7a
7
+ data.tar.gz: 6acd7187aad41ebae15d667ebc150e6896e1f0f72b2f86211d173bd77a25a26d1cc0119d7a9375329c688d13423a655244c43b04537a30aa9a3c851c12c4efca
data/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ 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
+
9
18
  ## [0.15.0] - 2026-10-13
10
19
 
11
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).
@@ -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
@@ -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.
@@ -387,20 +389,57 @@ module Portage
387
389
  end
388
390
  end
389
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
+
390
425
  # schemas/shopping/cart.json — requires ucp/id/line_items/currency/totals.
391
426
  # The "ucp" envelope is added centrally by Portage::Ucp::WireEnvelope, not stored
392
427
  # on the value object itself.
393
428
  # `discounts` is the dev.ucp.shopping.discount extension (optional on both
394
429
  # cart.json and its own schema) — omitted from the wire payload when empty,
395
430
  # same as Order#adjustments.
396
- Cart = Data.define(:id, :line_items, :currency, :totals, :discounts, :policies) do
397
- def initialize(id:, line_items:, currency:, totals:, discounts: Discounts.new, policies: []) = 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
398
435
 
399
436
  def to_wire_h
400
437
  h = { "id" => id, "line_items" => line_items.map(&:to_wire_h), "currency" => currency,
401
438
  "totals" => totals.map(&:to_wire_h) }
402
439
  h["discounts"] = discounts.to_wire_h unless discounts.empty?
403
440
  h["policies"] = policies unless policies.empty?
441
+ all = Ucp.wire_messages(messages, policies)
442
+ h["messages"] = all unless all.empty?
404
443
  h
405
444
  end
406
445
  end
@@ -429,9 +468,9 @@ module Portage
429
468
  # omitted from the wire payload when empty), same posture as
430
469
  # Order#adjustments.
431
470
  Checkout = Data.define(:id, :status, :line_items, :currency, :totals, :links, :order, :discounts,
432
- :fulfillment, :policies) do
471
+ :fulfillment, :policies, :messages) do
433
472
  def initialize(id:, status:, line_items:, currency:, totals:, links:, order: nil, discounts: Discounts.new,
434
- fulfillment: CheckoutFulfillment.new, policies: [])
473
+ fulfillment: CheckoutFulfillment.new, policies: [], messages: [])
435
474
  super
436
475
  end
437
476
 
@@ -442,6 +481,8 @@ module Portage
442
481
  h["discounts"] = discounts.to_wire_h unless discounts.empty?
443
482
  h["fulfillment"] = fulfillment.to_wire_h unless fulfillment.empty?
444
483
  h["policies"] = policies unless policies.empty?
484
+ all = Ucp.wire_messages(messages, policies)
485
+ h["messages"] = all unless all.empty?
445
486
  h
446
487
  end
447
488
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.15.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.15.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-13 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
@@ -193,6 +193,7 @@ files:
193
193
  - lib/portage/ucp/capabilities/order.rb
194
194
  - lib/portage/ucp/capabilities/payment_enrollment.rb
195
195
  - lib/portage/ucp/capabilities/payment_method.rb
196
+ - lib/portage/ucp/capabilities/permalink.rb
196
197
  - lib/portage/ucp/capabilities/reorder.rb
197
198
  - lib/portage/ucp/capabilities/shopper_data.rb
198
199
  - lib/portage/ucp/capability.rb
@@ -204,6 +205,7 @@ files:
204
205
  - lib/portage/ucp/errors.rb
205
206
  - lib/portage/ucp/manifest.rb
206
207
  - lib/portage/ucp/mcp/server.rb
208
+ - lib/portage/ucp/mcp/tool_metadata.rb
207
209
  - lib/portage/ucp/observability.rb
208
210
  - lib/portage/ucp/payment_enrollment_guard.rb
209
211
  - lib/portage/ucp/payment_token_guard.rb