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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +4 -0
- data/lib/portage/ucp/adapter.rb +17 -0
- data/lib/portage/ucp/capabilities/catalog.rb +2 -1
- data/lib/portage/ucp/capabilities/identity_linking.rb +2 -1
- data/lib/portage/ucp/capabilities/payment_enrollment.rb +17 -0
- data/lib/portage/ucp/confirmer.rb +74 -0
- data/lib/portage/ucp/dispatcher.rb +148 -3
- data/lib/portage/ucp/errors.rb +35 -0
- data/lib/portage/ucp/mcp/server.rb +20 -7
- data/lib/portage/ucp/policy.rb +79 -0
- data/lib/portage/ucp/policy_guard.rb +152 -0
- data/lib/portage/ucp/reference_adapter.rb +31 -0
- data/lib/portage/ucp/rspec.rb +26 -3
- data/lib/portage/ucp/support/idempotency/file_store.rb +78 -0
- data/lib/portage/ucp/support/idempotency/memory_store.rb +34 -0
- data/lib/portage/ucp/support/idempotency.rb +34 -8
- data/lib/portage/ucp/support/order_ledger.rb +79 -0
- data/lib/portage/ucp/support/token_ref.rb +20 -0
- data/lib/portage/ucp/support/transaction_log.rb +160 -0
- data/lib/portage/ucp/value_objects.rb +21 -0
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +13 -0
- metadata +16 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11276545531e50962e8e16e8d1c966853adfa8ddb8c1aa3851f7fa89a494b33e
|
|
4
|
+
data.tar.gz: 7421a957575b40d55200ce54e70335bec9a7c7a735cd0941eb2e1bdec0bdd535
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|