portage-ucp 0.3.0 → 0.5.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: 11276545531e50962e8e16e8d1c966853adfa8ddb8c1aa3851f7fa89a494b33e
4
+ data.tar.gz: 7421a957575b40d55200ce54e70335bec9a7c7a735cd0941eb2e1bdec0bdd535
5
5
  SHA512:
6
- metadata.gz: 0cd44b69ef19d804c91696b6477e3616eb9dd64c326754f5ff7f142ff494bdaf42b1bd4686888dcb1d7c6e6198b737cc501384278a9a6d2441173b6191029eab
7
- data.tar.gz: 22a096bb7ab8cfaa6285ad12b8c5dfd8098c40f4f538115688e03ad5eac59273f16eca247d1409e28f8f30c730d9ef7cb7c7a5d19586bdacb0c0f002f0b61e9a
6
+ metadata.gz: 52d85b70ca4e16311c318331bb0ca161c82ff9f833e4d08b5c3e262550ecaeccf334aaf35cf5e39f5a351e3a5aa58ed0a508615003c1da6d54d728a9dc34eda9
7
+ data.tar.gz: 05e158b1864ae760e08ddb3ab76080a1effd59bfa475ebd64e23f0f2e30f6ff87b4afe44eef0112ca5dda0a6e579e64532df396743cab582373f90e44805b089
data/CHANGELOG.md CHANGED
@@ -4,6 +4,119 @@ 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.5.0] - 2026-09-14
10
+
11
+ - Added `Adapter#create_payment_enrollment(idempotency_key:)` /
12
+ `#get_payment_enrollment(enrollment_id:)`, advertised as a new
13
+ `app.portage-ucp.payment_enrollment` capability — a Portage extension, not
14
+ part of the UCP spec. Starts a card-on-file enrollment without the card
15
+ ever touching this process: `#create_payment_enrollment` returns a
16
+ gateway-hosted `setup_url`, and the caller polls `#get_payment_enrollment`
17
+ until `status` leaves `"pending"` and a `payment_token` appears.
18
+ Implemented in `ReferenceAdapter` as a worked example (docs/plans/agentic-payments.md
19
+ Phase 1).
20
+ - Made the idempotency dedup store pluggable — `Support::Idempotency` now
21
+ takes a `store:` (defaulting to the existing in-memory behavior via the new
22
+ `MemoryStore`), with a `FileStore` alternative for dedup that survives a
23
+ process restart.
24
+ - Added a durable `Support::TransactionLog`, wired into
25
+ `Dispatcher#complete_checkout` dispatch — a transaction is reserved before
26
+ dispatch and marked settled/failed after, so a crash mid-charge leaves a
27
+ diagnosable record instead of silence.
28
+ - Extended `TransactionLog` to also record policy decisions and confirmation
29
+ outcomes on the same transaction record `PolicyGuard`/`Confirmer` gate.
30
+ - Added `PolicyGuard`, wired into `Dispatcher` just before `complete_checkout`
31
+ dispatch — enforces `Policy`'s per-transaction/rolling caps, velocity, and
32
+ merchant allowlist (docs/plans/agentic-payments.md Phase 2). Configured via
33
+ `portage-cli`'s `portage policy show/set`.
34
+ - Added per-token enrollment scopes to `Policy` — a merchant/max-amount/
35
+ currency scope can be bound to a token at enrollment time and is checked by
36
+ `PolicyGuard` keyed by the same `token_ref` derived from the token at
37
+ charge time.
38
+ - Added a `Confirmer` interface, wired into `Dispatcher` right after
39
+ `PolicyGuard.check!` passes and before `complete_checkout` dispatch.
40
+ `Confirmer::Terminal` blocks the process on stdin and fails closed on
41
+ anything but an explicit `"y"` (no answer, `"n"`, or EOF all deny);
42
+ `Confirmer::AutoApprove` is for specs/conformance kits (docs/plans/agentic-payments.md
43
+ Phase 3).
44
+ - Added a durable `Support::OrderLedger`, wired into the `complete_checkout`
45
+ settle path — written after the transaction record is already complete, so
46
+ a failed snapshot write surfaces without flipping an already-settled charge
47
+ to failed.
48
+ - `Mcp::Server.call_tool` now extracts `ucp-agent.profile` from `_meta` the
49
+ same way it already does `correlation_id` from `traceparent`, logging/
50
+ forwarding it as `agent_profile` through every transport (http, stdio,
51
+ loopback) and `Client::Session`. Additive only — `Dispatcher` accepts and
52
+ threads it without yet acting on it.
53
+ - Added `Adapter#lookup_catalog(ids:)`, advertised alongside `search_catalog`
54
+ — fetches several known product ids in one round trip instead of one
55
+ `get_product` call per id. Implemented in `Shopify::Adapter` via the Admin
56
+ API's `nodes(ids:)` field, reusing the same `Mapper.product` shape
57
+ `get_product`/`search_catalog` already use.
58
+
59
+ ## [0.4.0] - 2026-08-28
60
+
61
+ - Added `Adapter#reorder(order_id:, idempotency_key:)`, advertised as a new
62
+ `app.portage-ucp.reorder` capability — a Portage extension, not part of the
63
+ UCP spec. Hydrates a `Cart` from a previous order's line items, re-checking
64
+ each item's current price/availability rather than replaying the order's
65
+ historical totals, and reports anything no longer purchasable via the new
66
+ `ReorderResult#unavailable_items` rather than failing the whole call.
67
+ Implemented in `ReferenceAdapter` as a worked example.
68
+ - `Mcp::Server.call_tool` now emits a minimal pre-auth `tool_call_received`
69
+ event (capability, action, correlation id — no arguments) before
70
+ `authorize`/`rate_limit` run, moving the full `tool_called` event
71
+ (arguments included) below them. Previously the full event, arguments and
72
+ all, was logged before authorization, so an unauthenticated caller could
73
+ write attacker-chosen content into the operator's logs at whatever volume
74
+ the rate limiter would otherwise have refused (design-log §23).
75
+ - `Mcp::Server.correlation_id_for` stamps both events with a correlation id
76
+ read from the inbound W3C `traceparent` in `server_context[:_meta]`
77
+ (SEP-414, `MCP::TraceContext`), falling back to `SecureRandom.uuid` when
78
+ absent or malformed. Deliberately per-request, not per-session —
79
+ `Server::Context` is built once per process in `.build`, and `mcp`
80
+ 0.25.0's Streamable HTTP transport is stateful and multi-session, so
81
+ memoizing an id there would stamp every session in the process with the
82
+ same value (design-log §23). Because `traceparent` is unauthenticated
83
+ input read before `authorize`/`rate_limit` run, it's validated against
84
+ the W3C Trace Context format before use rather than accepted as-is — a
85
+ malformed or oversized value falls back to a generated id instead of
86
+ reaching the pre-auth log or `Dispatcher`/`CheckoutState` unchecked.
87
+ - `Dispatcher` now threads its logger and each call's correlation id to the
88
+ adapter for the duration of that one call
89
+ (`Support::CheckoutState.with_observability`) so a
90
+ `checkout_state_transition` event (§12) fires from `record_checkout_status`
91
+ carrying the same correlation id as the `tool_called` event that triggered
92
+ it — without adding a `correlation_id:` kwarg to any checkout method, which
93
+ would have been a breaking change to the `Adapter` contract. Storage is
94
+ `Thread.current`, keyed per adapter object and restored on exit, rather
95
+ than an instance variable on the adapter: `Mcp::Server.build` constructs
96
+ one adapter per process, shared across every concurrent session, so an
97
+ instance variable would let two in-flight requests clobber each other's
98
+ correlation id — the same per-process-state trap §23 diagnosed for the
99
+ correlation id generator itself, one layer down. No `capability_negotiated`
100
+ event yet: `CapabilityNegotiator#negotiate` has no call site anywhere in
101
+ the gem outside its own spec, so there's nowhere to emit it from without
102
+ building that call site first (design-log §23).
103
+ - `Observability::REDACTED_KEYS` grows past the three credential keys to
104
+ cover the PII that actually flows through logged events — `email`
105
+ (`Identity`, §3) and `first_name`/`last_name`/`phone_number`/
106
+ `street_address`/`extended_address`/`address_locality`/`address_region`/
107
+ `address_country`/`postal_code` (`PostalAddress`, fulfillment
108
+ destinations). §12's "Money-adjacent PII" named no real key — `Money`/
109
+ `Total` carry only amounts and currency codes (design-log §23 step 4).
110
+ - `Rack::WebhookEndpoint` takes a `logger:` kwarg (defaulting to
111
+ `Portage::Ucp.configuration.logger`) and emits `order_webhook_received`
112
+ (order id, checkout id) on a verified payload and `order_webhook_rejected`
113
+ (reason: `invalid_signature` or `bad_request`) on the two rejection paths
114
+ — never the request body. No new `config.event_sink`: this endpoint is a
115
+ plain Rack app never built through `Mcp::Server.build`, so it can't reach
116
+ `mcp`'s own request hooks, and threading the gem's existing `logger:`
117
+ convention through one more constructor was the whole fix (design-log
118
+ §23 step 5).
119
+
7
120
  ## [0.3.0] - 2026-08-27
8
121
 
9
122
  - `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
data/README.md CHANGED
@@ -25,6 +25,10 @@ in this gem.
25
25
  | `Portage::Ucp::Rack::WebhookEndpoint` | HMAC-verified inbound order-lifecycle webhooks. |
26
26
  | `Portage::Ucp::SchemaValidator` | Validates data against UCP's own vendored JSON Schemas/OpenRPC docs, offline. |
27
27
  | `Portage::Ucp::Resolver` / `exe/portage-ucp-check` | Probes any store's homepage/`.well-known/ucp` and recommends the matching adapter gem. |
28
+ | `Portage::Ucp::Support::TransactionLog` | Durable pre/post-dispatch record of every `complete_checkout` call — reserved before dispatch, marked settled/failed after, so a crash mid-charge is diagnosable rather than silently lost. |
29
+ | `Portage::Ucp::Support::OrderLedger` | Durable snapshot written after settlement, alongside (not instead of) the transaction record — a failed snapshot write surfaces without flipping an already-settled charge to failed. |
30
+ | `Portage::Ucp::Confirmer` | Gate run just before `complete_checkout` dispatch, after `PolicyGuard`. `Confirmer::Terminal` blocks on stdin and fails closed on anything but an explicit `"y"`; `Confirmer::AutoApprove` is for specs/conformance kits that need a real `confirm!` without blocking. |
31
+ | `Portage::Ucp::PolicyGuard` / `Portage::Ucp::Policy` | Per-transaction/rolling/velocity caps and a merchant allowlist, checked before `complete_checkout` dispatch; configured via `portage-cli`'s `portage policy show/set`. |
28
32
 
29
33
  Security defaults are all locked down, not permissive-by-omission —
30
34
  `UnconfiguredAuthenticator` rejects every mutating call until you configure a real
@@ -10,6 +10,11 @@ module Portage
10
10
  # get_cart/get_checkout/get_order.
11
11
  # @return [Portage::Ucp::ProductDetail, nil]
12
12
  def get_product(product_id:) = not_implemented
13
+ # Batch fetch — same result shape as search_catalog, for a caller that
14
+ # already has a set of product ids (e.g. hydrating a cart/order) and
15
+ # wants one round trip instead of N #get_product calls.
16
+ # @return [Portage::Ucp::CatalogSearchResult]
17
+ def lookup_catalog(product_ids:) = not_implemented
13
18
 
14
19
  # --- Cart (dev.ucp.shopping.cart) ---
15
20
  # Full-replacement semantics, matching UCP's real cart methods: create/
@@ -90,6 +95,19 @@ module Portage
90
95
  # @return [Portage::Ucp::Order]
91
96
  def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
92
97
 
98
+ # --- Reorder (app.portage-ucp.reorder — Portage extension, not part of
99
+ # the UCP spec: dev.ucp.dev has no reorder/cart-hydration capability as
100
+ # of the 2026-04-08 spec) ---
101
+ # Hydrates a cart from a previous order's line items so a caller doesn't
102
+ # have to re-walk get_order + create_cart itself. Re-checks each item's
103
+ # current price/availability rather than replaying the order's snapshot
104
+ # totals — order_line_item.json's totals are historical, not live, same
105
+ # posture as complete_checkout's stock re-check above — and drops
106
+ # anything no longer purchasable instead of failing the whole call,
107
+ # reporting what got dropped via ReorderResult#unavailable_items.
108
+ # @return [Portage::Ucp::ReorderResult, nil] nil if the order isn't found.
109
+ def reorder(order_id:, idempotency_key:) = not_implemented
110
+
93
111
  # --- Discount (dev.ucp.shopping.discount) ---
94
112
  # Extends Cart/Checkout with the `discount_codes:` param above rather
95
113
  # than adding actions of its own — Capability::DISCOUNT advertises off
@@ -110,6 +128,18 @@ module Portage
110
128
  # @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
111
129
  def link_identity(oauth_token:) = not_implemented
112
130
 
131
+ # --- Payment Enrollment (app.portage-ucp.payment_enrollment — Portage
132
+ # extension, not part of the UCP spec) ---
133
+ # Starts a card-on-file enrollment. Card data never touches this
134
+ # process: #create_payment_enrollment returns a `setup_url` for a
135
+ # gateway-hosted page where the human enters their card, and the
136
+ # caller polls #get_payment_enrollment until `status` leaves
137
+ # "pending". See docs/plans/agentic-payments.md Phase 1.
138
+ # @return [Portage::Ucp::PaymentEnrollment]
139
+ def create_payment_enrollment(idempotency_key:) = not_implemented
140
+ # @return [Portage::Ucp::PaymentEnrollment, nil] nil if the enrollment isn't found
141
+ def get_payment_enrollment(enrollment_id:) = not_implemented
142
+
113
143
  private
114
144
 
115
145
  def not_implemented
@@ -4,7 +4,8 @@ module Portage
4
4
  CATALOG = Portage::Ucp::Capability.new(
5
5
  name: "dev.ucp.shopping.catalog",
6
6
  version: "1",
7
- actions: { "search_catalog" => :search_catalog, "get_product" => :get_product }
7
+ actions: { "search_catalog" => :search_catalog, "get_product" => :get_product,
8
+ "lookup_catalog" => :lookup_catalog }
8
9
  )
9
10
  end
10
11
  end
@@ -7,7 +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].freeze
10
+ ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER,
11
+ PAYMENT_ENROLLMENT].freeze
11
12
  end
12
13
  end
13
14
  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), same posture as Capabilities::REORDER.
6
+ # Advertised only if the adapter overrides Adapter#create_payment_enrollment
7
+ # — an adapter with no real gateway support for it simply never shows it
8
+ # in the manifest (see docs/plans/agentic-payments.md Phase 1).
9
+ PAYMENT_ENROLLMENT = Portage::Ucp::Capability.new(
10
+ name: "app.portage-ucp.payment_enrollment",
11
+ version: "1",
12
+ actions: { "create_payment_enrollment" => :create_payment_enrollment,
13
+ "get_payment_enrollment" => :get_payment_enrollment }
14
+ )
15
+ end
16
+ end
17
+ 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
@@ -0,0 +1,74 @@
1
+ require "timeout"
2
+
3
+ module Portage
4
+ module Ucp
5
+ # Phase 3 (docs/plans/agentic-payments.md) — the last gate before a
6
+ # payment-completing dispatch, run by Dispatcher right after PolicyGuard
7
+ # passes. Transport-agnostic by design: Dispatcher only ever calls
8
+ # `#confirm!(amount:, currency:, merchant:, idempotency_key:)` on
9
+ # whatever object it's given, so a future WhatsApp/Slack confirmer is a
10
+ # separate gem implementing the same method, no core change required.
11
+ # `idempotency_key` rides along even though this phase's Terminal
12
+ # implementation ignores it — an async transport needs it to bind a
13
+ # reply back to the request that asked.
14
+ module Confirmer
15
+ # Blocks the CLI process on stdin: "Approve? [y/N]". Fail-closed — no
16
+ # answer within `timeout_seconds` denies, same as an explicit "n",
17
+ # never leaves a payment pending on an agent that's still waiting.
18
+ # Checkout staleness while the operator was thinking isn't handled
19
+ # here: the confirmed amount is re-validated by nothing in this class,
20
+ # but the adapter call `call_adapter` makes right after this passes
21
+ # hits the real backend, which is the thing that'd reject a checkout
22
+ # that expired mid-wait — no separate re-check needed.
23
+ class Terminal
24
+ DEFAULT_TIMEOUT_SECONDS = 120
25
+
26
+ def initialize(timeout_seconds: DEFAULT_TIMEOUT_SECONDS, input: $stdin, output: $stdout)
27
+ @timeout_seconds = timeout_seconds
28
+ @input = input
29
+ @output = output
30
+ end
31
+
32
+ # @raise [Portage::Ucp::ConfirmationDeniedError] on "n", timeout, or
33
+ # EOF (stdin closed out from under a headless run) — anything that
34
+ # isn't an explicit "y" denies.
35
+ # @return [Hash] `{approved: true}` on "y".
36
+ def confirm!(amount:, currency:, merchant:, idempotency_key:)
37
+ @output.print("Approve payment of #{amount} #{currency} to #{merchant.inspect}? [y/N] ")
38
+ @output.flush
39
+
40
+ answer = read_with_timeout
41
+ return { approved: true } if answer&.strip&.downcase == "y"
42
+
43
+ deny!(answer.nil? ? :timeout : :denied, idempotency_key)
44
+ end
45
+
46
+ private
47
+
48
+ def read_with_timeout
49
+ Timeout.timeout(@timeout_seconds) { @input.gets }
50
+ rescue Timeout::Error
51
+ nil
52
+ end
53
+
54
+ def deny!(reason, idempotency_key)
55
+ message = reason == :timeout ? "confirmation timed out after #{@timeout_seconds}s" : "confirmation denied"
56
+ raise Portage::Ucp::ConfirmationDeniedError.new(
57
+ message, reason: reason, decision: { approved: false, reason: reason, idempotency_key: idempotency_key }
58
+ )
59
+ end
60
+ end
61
+
62
+ # Always approves. For adapter conformance suites and Dispatcher specs
63
+ # that need a real `confirm!` call to return without blocking on
64
+ # stdin — never wire this into a real Dispatcher: confirmation
65
+ # defaulting to *on* is the thing that makes Phase 1's permanently-
66
+ # spendable stored token safe (see plan, "No arm step").
67
+ class AutoApprove
68
+ def confirm!(**)
69
+ { approved: true }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -4,12 +4,59 @@ 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
+ # The one action that moves money — see docs/plans/agentic-payments.md
8
+ # Phase 0. Gated by name, not capability, since `complete_checkout` is
9
+ # the only `dev.ucp.shopping.checkout` action that dispatches a charge.
10
+ PAYMENT_COMPLETING_ACTION = "complete_checkout".freeze
11
+
12
+ # @param shop [String, nil] identifies which store this Dispatcher
13
+ # instance is completing charges for, threaded straight onto the
14
+ # transaction log record — Dispatcher/Adapter have no shared notion
15
+ # of shop identity today, so this is nil unless the caller passes one.
16
+ # @param transaction_log [Support::TransactionLog] reserve/commit
17
+ # ledger for `complete_checkout` calls (Phase 0). Injectable so specs
18
+ # don't write to the real `~/.portage/transactions.json`.
19
+ # @param policy [Policy] Phase 2 policy config PolicyGuard.check! reads
20
+ # caps/velocity/allowlist/token-scope from. Injectable for the same
21
+ # reason as `transaction_log` — defaulting to `Policy.load` would
22
+ # have every spec read the real `~/.portage/policy.json`.
23
+ # @param confirmer [#confirm!] Phase 3 gate, run after PolicyGuard
24
+ # passes, before dispatch. Defaults to `Confirmer::Terminal.new`
25
+ # (blocks on stdin) — confirmation is on by default per the plan;
26
+ # specs and conformance suites inject `Confirmer::AutoApprove.new`
27
+ # instead so a run never blocks waiting on a human.
28
+ # @param order_ledger [Support::OrderLedger] settled-order snapshot
29
+ # store (Phase 1, docs/plans/order-ledger.md). Injectable for the
30
+ # same reason as `transaction_log` — defaults to the real
31
+ # `~/.portage/orders.json`.
32
+ def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger,
33
+ shop: nil, transaction_log: Support::TransactionLog.new, policy: Policy.load,
34
+ confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new)
8
35
  @adapter = adapter
9
36
  @registry = registry
37
+ @logger = logger
38
+ @shop = shop
39
+ @transaction_log = transaction_log
40
+ @policy = policy
41
+ @confirmer = confirmer
42
+ @order_ledger = order_ledger
10
43
  end
11
44
 
12
- def call(capability:, action:, arguments: {})
45
+ # @param correlation_id [String, nil] threaded through to the adapter
46
+ # (via Support::CheckoutState.with_observability, scoped to this call
47
+ # only) so a checkout_state_transition event (§12) it emits during
48
+ # this call carries the same id as the tool_called event that
49
+ # triggered it. Optional, not correlation_id: required, since
50
+ # Dispatcher.call is also the conformance kit's
51
+ # (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside
52
+ # any MCP request (§23).
53
+ # @param agent_profile [String, nil] caller-supplied `ucp-agent.profile`
54
+ # hint from `_meta` (see Mcp::Server.agent_profile_for). Dispatcher
55
+ # has no direct Observability.log call of its own to thread this
56
+ # into — accepted here purely so callers that already pass
57
+ # correlation_id: have a matching, equally optional slot; existing
58
+ # callers that omit it are unaffected.
59
+ def call(capability:, action:, arguments: {}, correlation_id: nil, agent_profile: nil)
13
60
  capability_definition = @registry.find(capability)
14
61
  raise UnknownCapabilityError, capability if capability_definition.nil?
15
62
 
@@ -20,12 +67,129 @@ module Portage
20
67
 
21
68
  Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
22
69
 
23
- result = @adapter.public_send(method_name, **arguments)
70
+ result = if action == PAYMENT_COMPLETING_ACTION
71
+ call_and_log_transaction(method_name, arguments, correlation_id)
72
+ else
73
+ call_adapter(method_name, arguments, correlation_id)
74
+ end
24
75
  wrap(capability, result)
25
76
  end
26
77
 
27
78
  private
28
79
 
80
+ # Reserve-then-commit around the one action that dispatches a charge:
81
+ # the `pending` record lands *before* `call_adapter` runs, so a crash
82
+ # during the adapter's own gateway round-trip leaves that record
83
+ # behind rather than nothing. `amount`/`currency` are unknown at
84
+ # reserve time (see TransactionLog#reserve) and filled in from the
85
+ # settled Checkout on success; a raised error settles the record
86
+ # `failed` before re-raising, never left dangling `pending`.
87
+ #
88
+ # PolicyGuard.check! (Phase 2), then the Confirmer (Phase 3), run
89
+ # after reserve, before dispatch — both need `amount`/`currency` to
90
+ # check the spend cap / show the operator a prompt, which means
91
+ # fetching the checkout via `@adapter.get_checkout` up front rather
92
+ # than waiting for the settled result `call_adapter` would otherwise
93
+ # provide after the charge already happened. A block or a deny never
94
+ # reaches `call_adapter` at all; a pass is recorded immediately so it
95
+ # survives a crash during the adapter round-trip, same reasoning as
96
+ # `reserve`.
97
+ def call_and_log_transaction(method_name, arguments, correlation_id)
98
+ idempotency_key = arguments.fetch(:idempotency_key)
99
+ token_ref = payment_token_ref(arguments[:payment_token])
100
+
101
+ result = settle(method_name, arguments, correlation_id, idempotency_key, token_ref)
102
+
103
+ # Snapshot AFTER `complete` inside `settle` above, never before/
104
+ # interleaved — opposite of `reserve`'s pre-dispatch posture.
105
+ # Deliberately OUTSIDE `settle`'s rescue: the money has already
106
+ # moved by this point, so if this write raises it must surface only
107
+ # after the transaction record is durably `complete` — a lost local
108
+ # history write must never flip a settled charge to `failed` or
109
+ # leave the transaction record ambiguous. Not every completed
110
+ # checkout produces an order (e.g. cart-only flows), so skip
111
+ # silently when absent.
112
+ @order_ledger.record(idempotency_key: idempotency_key, order: result.order) if result.order
113
+ result
114
+ end
115
+
116
+ def settle(method_name, arguments, correlation_id, idempotency_key, token_ref)
117
+ @transaction_log.reserve(idempotency_key: idempotency_key, shop: @shop,
118
+ checkout_id: arguments[:checkout_id], payment_token_ref: token_ref)
119
+
120
+ checkout = @adapter.get_checkout(checkout_id: arguments[:checkout_id])
121
+ gate!(idempotency_key, checkout, token_ref)
122
+
123
+ result = call_adapter(method_name, arguments, correlation_id)
124
+
125
+ @transaction_log.complete(idempotency_key: idempotency_key, status: "complete",
126
+ amount: settled_amount(result), currency: settled_currency(result))
127
+ result
128
+ rescue StandardError
129
+ @transaction_log.complete(idempotency_key: idempotency_key, status: "failed")
130
+ raise
131
+ end
132
+
133
+ # PolicyGuard (Phase 2) then Confirmer (Phase 3), in that order — both
134
+ # gate the same dispatch, and each phase records its own outcome on
135
+ # the transaction record as soon as it passes, per `reserve`'s
136
+ # crash-survives-as-evidence reasoning above.
137
+ def gate!(idempotency_key, checkout, token_ref)
138
+ decision = policy_check!(idempotency_key, checkout, token_ref)
139
+ @transaction_log.record_decision(idempotency_key: idempotency_key, policy_decision: decision)
140
+
141
+ confirmation = confirmation_check!(idempotency_key, checkout)
142
+ @transaction_log.record_confirmation(idempotency_key: idempotency_key, confirmation_outcome: confirmation)
143
+ end
144
+
145
+ def policy_check!(idempotency_key, checkout, token_ref)
146
+ Portage::Ucp::PolicyGuard.check!(amount: settled_amount(checkout), currency: settled_currency(checkout),
147
+ merchant: @shop, token_ref: token_ref, policy: @policy,
148
+ transaction_log: @transaction_log)
149
+ rescue Portage::Ucp::PolicyViolationError => e
150
+ @transaction_log.complete(idempotency_key: idempotency_key, status: "failed", policy_decision: e.decision)
151
+ raise
152
+ end
153
+
154
+ def confirmation_check!(idempotency_key, checkout)
155
+ @confirmer.confirm!(amount: settled_amount(checkout), currency: settled_currency(checkout),
156
+ merchant: @shop, idempotency_key: idempotency_key)
157
+ rescue Portage::Ucp::ConfirmationDeniedError => e
158
+ @transaction_log.complete(idempotency_key: idempotency_key, status: "failed", confirmation_outcome: e.decision)
159
+ raise
160
+ end
161
+
162
+ # Never persists the payment token itself (single-use, still sensitive
163
+ # even though PaymentTokenGuard has already ruled out a raw PAN) — only
164
+ # a one-way reference an operator can correlate against, not replay.
165
+ def payment_token_ref(payment_token)
166
+ Support::TokenRef.for(payment_token)
167
+ end
168
+
169
+ def settled_amount(result)
170
+ return nil unless result.respond_to?(:totals)
171
+
172
+ # `Total#amount` is a bare integer minor-unit amount (the parent
173
+ # object's `currency` applies), not a Money struct — see
174
+ # value_objects.rb's Total/Item comments.
175
+ total = Array(result.totals).find { |t| t.type == "total" }
176
+ total&.amount
177
+ end
178
+
179
+ def settled_currency(result)
180
+ result.respond_to?(:currency) ? result.currency : nil
181
+ end
182
+
183
+ def call_adapter(method_name, arguments, correlation_id)
184
+ unless @adapter.is_a?(Portage::Ucp::Support::CheckoutState)
185
+ return @adapter.public_send(method_name, **arguments)
186
+ end
187
+
188
+ Portage::Ucp::Support::CheckoutState.with_observability(@adapter, @logger, correlation_id) do
189
+ @adapter.public_send(method_name, **arguments)
190
+ end
191
+ end
192
+
29
193
  def wrap(capability_name, result)
30
194
  unless result.respond_to?(:to_wire_h)
31
195
  return { content: [{ type: "text", text: result.inspect }], structuredContent: result }
@@ -38,5 +38,40 @@ module Portage
38
38
  # UCP's freeform "rate_limited" error code, same convention as
39
39
  # ConflictError above.
40
40
  class UpstreamThrottledError < Error; end
41
+
42
+ # Raised by PolicyGuard.check! (docs/plans/agentic-payments.md Phase 2)
43
+ # when a payment-completing dispatch fails a locally-configured guard —
44
+ # spend cap, velocity limit, merchant allowlist, or per-token scope.
45
+ # `reason` is a stable snake_case symbol (not just the message) so a
46
+ # caller can branch on *why* without parsing prose, matching
47
+ # OutOfStockError/ConflictError's freeform-error-code convention above.
48
+ # `decision` carries the same shape PolicyGuard.check! returns on
49
+ # success, so Dispatcher can persist "blocked, here's why" onto the
50
+ # Phase 0 transaction record the same way it persists a passing decision.
51
+ class PolicyViolationError < Error
52
+ attr_reader :reason, :decision
53
+
54
+ def initialize(message, reason:, decision:)
55
+ super(message)
56
+ @reason = reason
57
+ @decision = decision
58
+ end
59
+ end
60
+
61
+ # Raised by a Confirmer (docs/plans/agentic-payments.md Phase 3) when a
62
+ # payment-completing dispatch isn't approved — explicit "n", a timeout,
63
+ # or an async transport's own deny. Same reason/decision shape as
64
+ # PolicyViolationError above, for the same reasons: a stable symbol to
65
+ # branch on, and a decision Dispatcher persists onto the Phase 0
66
+ # transaction record via `confirmation_outcome`.
67
+ class ConfirmationDeniedError < Error
68
+ attr_reader :reason, :decision
69
+
70
+ def initialize(message, reason:, decision:)
71
+ super(message)
72
+ @reason = reason
73
+ @decision = decision
74
+ end
75
+ end
41
76
  end
42
77
  end