portage-ucp 0.4.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: 36c3a77e9f625761b827d1e925d54217b7bff302f4a7a60540d4a8c5d1794fb9
4
- data.tar.gz: 05a86e2740a327c63bb0e06aa450e2aa5207aa60ce637575133483ed8df4698b
3
+ metadata.gz: 11276545531e50962e8e16e8d1c966853adfa8ddb8c1aa3851f7fa89a494b33e
4
+ data.tar.gz: 7421a957575b40d55200ce54e70335bec9a7c7a735cd0941eb2e1bdec0bdd535
5
5
  SHA512:
6
- metadata.gz: d0d4746f829e60009463ee2748ba5c46ff6f3932b5a345f3f26d94e437a5132b39002945bf19abe1ee3d02c46e1760b52afa6910a15531df494c4e39dcca7787
7
- data.tar.gz: 60898e67d72f545c24970415775506be4930336a4f325be61714cef0f810547f77b79951f7b48e3fb32b72cdd9adb9669b4f3af4f872be0a4bcb7ea60b325bf5
6
+ metadata.gz: 52d85b70ca4e16311c318331bb0ca161c82ff9f833e4d08b5c3e262550ecaeccf334aaf35cf5e39f5a351e3a5aa58ed0a508615003c1da6d54d728a9dc34eda9
7
+ data.tar.gz: 05e158b1864ae760e08ddb3ab76080a1effd59bfa475ebd64e23f0f2e30f6ff87b4afe44eef0112ca5dda0a6e579e64532df396743cab582373f90e44805b089
data/CHANGELOG.md CHANGED
@@ -6,6 +6,56 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
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
+
9
59
  ## [0.4.0] - 2026-08-28
10
60
 
11
61
  - Added `Adapter#reorder(order_id:, idempotency_key:)`, advertised as a new
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/
@@ -123,6 +128,18 @@ module Portage
123
128
  # @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
124
129
  def link_identity(oauth_token:) = not_implemented
125
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
+
126
143
  private
127
144
 
128
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, REORDER].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,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,10 +4,42 @@ 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, logger: Portage::Ucp.configuration.logger)
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
10
37
  @logger = logger
38
+ @shop = shop
39
+ @transaction_log = transaction_log
40
+ @policy = policy
41
+ @confirmer = confirmer
42
+ @order_ledger = order_ledger
11
43
  end
12
44
 
13
45
  # @param correlation_id [String, nil] threaded through to the adapter
@@ -18,7 +50,13 @@ module Portage
18
50
  # Dispatcher.call is also the conformance kit's
19
51
  # (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside
20
52
  # any MCP request (§23).
21
- def call(capability:, action:, arguments: {}, correlation_id: nil)
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)
22
60
  capability_definition = @registry.find(capability)
23
61
  raise UnknownCapabilityError, capability if capability_definition.nil?
24
62
 
@@ -29,12 +67,119 @@ module Portage
29
67
 
30
68
  Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
31
69
 
32
- result = call_adapter(method_name, arguments, correlation_id)
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
33
75
  wrap(capability, result)
34
76
  end
35
77
 
36
78
  private
37
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
+
38
183
  def call_adapter(method_name, arguments, correlation_id)
39
184
  unless @adapter.is_a?(Portage::Ucp::Support::CheckoutState)
40
185
  return @adapter.public_send(method_name, **arguments)
@@ -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
@@ -50,23 +50,27 @@ module Portage
50
50
  def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
51
51
  server_context = kwargs.delete(:server_context)
52
52
  correlation_id = correlation_id_for(server_context)
53
- Portage::Ucp::Observability.log(context.logger, "tool_call_received", capability: capability.name,
54
- action: action_name,
55
- correlation_id: correlation_id)
53
+ agent_profile = agent_profile_for(server_context)
54
+ log_tool_event(context.logger, "tool_call_received", correlation_id, agent_profile,
55
+ capability: capability.name, action: action_name)
56
56
 
57
57
  rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
58
58
  rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
59
59
  return rejection if rejection
60
60
 
61
- Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
62
- action: action_name, arguments: kwargs,
63
- correlation_id: correlation_id)
61
+ log_tool_event(context.logger, "tool_called", correlation_id, agent_profile,
62
+ capability: capability.name, action: action_name, arguments: kwargs)
64
63
 
65
64
  result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs,
66
- correlation_id: correlation_id)
65
+ correlation_id: correlation_id, agent_profile: agent_profile)
67
66
  ::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
68
67
  end
69
68
 
69
+ def self.log_tool_event(logger, event, correlation_id, agent_profile, **fields)
70
+ Portage::Ucp::Observability.log(logger, event, correlation_id: correlation_id, agent_profile: agent_profile,
71
+ **fields)
72
+ end
73
+
70
74
  # Per-request correlation only (§23): `Context` above is built once per
71
75
  # process in `.build`, and mcp 0.25.0's Streamable HTTP transport is
72
76
  # explicitly stateful/multi-session, so memoizing an id there would
@@ -91,6 +95,15 @@ module Portage
91
95
  traceparent.is_a?(String) && TRACEPARENT_FORMAT.match?(traceparent) ? traceparent : SecureRandom.uuid
92
96
  end
93
97
 
98
+ # Same `_meta` path as `traceparent` above, but for the caller-supplied
99
+ # `ucp-agent.profile` hint — no format validation, since (unlike
100
+ # traceparent) nothing here parses or trusts its shape, it's just
101
+ # threaded through for observability/policy consumers to interpret.
102
+ def self.agent_profile_for(server_context)
103
+ meta = server_context[:_meta] if server_context.respond_to?(:[])
104
+ meta && (meta["ucp-agent.profile"] || meta[:"ucp-agent.profile"])
105
+ end
106
+
94
107
  def self.authorize(authenticator, server_context, mutating:)
95
108
  return unless mutating
96
109
 
@@ -0,0 +1,79 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Ucp
6
+ # Local policy config for PolicyGuard (docs/plans/agentic-payments.md
7
+ # Phase 2). File format is deliberately private/internal — `portage-ucp`
8
+ # is a published gem, so PolicyGuard.check!'s keyword API is the stable
9
+ # surface, not this schema, which can change across releases.
10
+ #
11
+ # Absent file, or an absent field within it, means "no restriction" for
12
+ # that check — a fresh install doesn't block dispatch just because no
13
+ # policy was ever configured. That's a deliberate default-permissive
14
+ # choice: PolicyGuard.check! and Phase 3's Confirmer are two independent
15
+ # layers, and confirmation defaults to *on* (Phase 1), so an unconfigured
16
+ # policy isn't the only thing standing between an agent and a charge.
17
+ #
18
+ # A corrupt file is not the same as an absent one — that's data damage,
19
+ # not "nothing configured" — so JSON parse errors raise rather than
20
+ # silently falling back to permissive defaults.
21
+ class Policy
22
+ PATH = File.join(Dir.home, ".portage", "policy.json").freeze
23
+
24
+ def self.load(path: PATH)
25
+ new(path: path, data: read(path))
26
+ end
27
+
28
+ def initialize(path: PATH, data: {})
29
+ @path = path
30
+ @data = data
31
+ end
32
+
33
+ def per_transaction_cap = @data["per_transaction_cap"]
34
+ def rolling_cap = @data["rolling_cap"]
35
+ def velocity = @data["velocity"]
36
+ def merchant_allowlist = Array(@data["merchant_allowlist"])
37
+ def token_scope(token_ref) = (@data["token_scopes"] || {})[token_ref]
38
+
39
+ # @param token_ref [String] from Support::TokenRef.for — bound at
40
+ # enrollment time (portage-cli's PaymentMethods#enroll), not per-call.
41
+ def set_token_scope(token_ref, scope)
42
+ @data["token_scopes"] ||= {}
43
+ @data["token_scopes"][token_ref] = scope
44
+ write
45
+ scope
46
+ end
47
+
48
+ def set(key, value)
49
+ @data[key.to_s] = value
50
+ write
51
+ value
52
+ end
53
+
54
+ def to_h = @data.dup
55
+
56
+ def self.read(path)
57
+ return {} unless File.readable?(path)
58
+
59
+ raw = File.read(path)
60
+ return {} if raw.empty?
61
+
62
+ parsed = JSON.parse(raw)
63
+ parsed.is_a?(Hash) ? parsed : {}
64
+ end
65
+ private_class_method :read
66
+
67
+ private
68
+
69
+ # Same "raise, don't swallow" convention as TransactionLog — a lost
70
+ # write here means a cap/allowlist/scope change silently didn't take,
71
+ # which is worse than the write never having been attempted.
72
+ def write
73
+ FileUtils.mkdir_p(File.dirname(@path))
74
+ File.write(@path, JSON.pretty_generate(@data))
75
+ File.chmod(0o600, @path)
76
+ end
77
+ end
78
+ end
79
+ end