Class: Portage::Ucp::Dispatcher

Inherits:
Object
  • Object
show all
Defined in:
lib/portage/ucp/dispatcher.rb

Overview

Accepts a UCP-shaped request (capability + action + arguments), routes it through the CapabilityRegistry to the backing Adapter method, and wraps the result as MCP's dual content/structuredContent output (see §5).

Constant Summary collapse

PAYMENT_COMPLETING_ACTION =

The one action that moves money — see docs/plans/agentic-payments.md Phase 0. Gated by name, not capability, since complete_checkout is the only dev.ucp.shopping.checkout action that dispatches a charge.

"complete_checkout".freeze

Instance Method Summary collapse

Constructor Details

#initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger, shop: nil, transaction_log: Support::TransactionLog.new, policy: Policy.load, confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new, journal: nil, mandate_trust_keys: Portage::Ucp.configuration.mandate_trusted_keys, require_mandate_signature: Portage::Ucp.configuration.require_mandate_signature) ⇒ Dispatcher

Returns a new instance of Dispatcher.

Parameters:

  • shop (String, nil) (defaults to: nil) —

    identifies which store this Dispatcher instance is completing charges for, threaded straight onto the transaction log record — Dispatcher/Adapter have no shared notion of shop identity today, so this is nil unless the caller passes one.

  • transaction_log (Support::TransactionLog) (defaults to: Support::TransactionLog.new) —

    reserve/commit ledger for complete_checkout calls (Phase 0). Injectable so specs don't write to the real ~/.portage/transactions.json.

  • policy (Policy) (defaults to: Policy.load) —

    Phase 2 policy config PolicyGuard.check! reads caps/velocity/allowlist/token-scope from. Injectable for the same reason as transaction_log — defaulting to Policy.load would have every spec read the real ~/.portage/policy.json.

  • confirmer (#confirm!) (defaults to: Confirmer::Terminal.new) —

    Phase 3 gate, run after PolicyGuard passes, before dispatch. Defaults to Confirmer::Terminal.new (blocks on stdin) — confirmation is on by default per the plan; specs and conformance suites inject Confirmer::AutoApprove.new instead so a run never blocks waiting on a human.

  • order_ledger (Support::OrderLedger) (defaults to: Support::OrderLedger.new) —

    settled-order snapshot store (Phase 1, docs/plans/order-ledger.md). Injectable for the same reason as transaction_log — defaults to the real ~/.portage/orders.json.

  • journal (#record_checkout, nil) (defaults to: nil) —

    optional buyer-side purchase journal (docs/plans/storage-abstraction-journal.md) — the portage-ucp-journal gem's PurchaseJournal, or anything duck-typed the same way. nil by default and never required from core (§2: core stays dependency-light); a consumer wires one in from their own app after requiring that gem themselves.

  • mandate_trust_keys (Array<Hash>, #call, nil) (defaults to: Portage::Ucp.configuration.mandate_trusted_keys) —

    forwarded to Ap2::MandateGuard.validate! as trusted_keys:. Defaults to Configuration#mandate_trusted_keys, which itself has no default — same reasoning as journal: this gem has no AP2 issuer keys of its own to default to, so an unconfigured Dispatcher gets shape-only mandate validation, not a silent skip of crypto a caller thought was on. A caller with a real trust anchor (a PSP adapter's own key store, or a resolver against the issuing agent's manifest) passes it here to get Ap2::MandateSignature's cryptographic check on every dispatch that carries a mandate.

  • require_mandate_signature (Boolean) (defaults to: Portage::Ucp.configuration.require_mandate_signature) —

    forwarded to Ap2::MandateGuard.validate! as require_signature:. Defaults to Configuration#require_mandate_signature (false) — set true to make a dispatch with a mandate but no resolvable trust anchor raise InvalidMandateError instead of downgrading to shape-only.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/portage/ucp/dispatcher.rb', line 53

def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger,
               shop: nil, transaction_log: Support::TransactionLog.new, policy: Policy.load,
               confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new, journal: nil,
               mandate_trust_keys: Portage::Ucp.configuration.mandate_trusted_keys,
               require_mandate_signature: Portage::Ucp.configuration.require_mandate_signature)
  @adapter = adapter
  @registry = registry
  @logger = logger
  @shop = shop
  @transaction_log = transaction_log
  @policy = policy
  @confirmer = confirmer
  @order_ledger = order_ledger
  @journal = journal
  @mandate_trust_keys = mandate_trust_keys
  @require_mandate_signature = require_mandate_signature
end

Instance Method Details

#call(capability:, action:, arguments: {}, correlation_id: nil, agent_profile: nil) ⇒ Object

Parameters:

  • correlation_id (String, nil) (defaults to: nil) —

    threaded through to the adapter (via Support::CheckoutState.with_observability, scoped to this call only) so a checkout_state_transition event (§12) it emits during this call carries the same id as the tool_called event that triggered it. Optional, not correlation_id: required, since Dispatcher.call is also the conformance kit's (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside any MCP request (§23).

  • agent_profile (String, nil) (defaults to: nil) —

    caller-supplied ucp-agent.profile hint from _meta (see Mcp::Server.agent_profile_for). Dispatcher has no direct Observability.log call of its own to thread this into — accepted here purely so callers that already pass correlation_id: have a matching, equally optional slot; existing callers that omit it are unaffected.

Raises:



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/portage/ucp/dispatcher.rb', line 85

def call(capability:, action:, arguments: {}, correlation_id: nil, agent_profile: nil)
  capability_definition = @registry.find(capability)
  raise UnknownCapabilityError, capability if capability_definition.nil?

  raise CapabilityNotAdvertisedError, capability unless capability_definition.advertised_for?(@adapter)

  method_name = capability_definition.actions[action]
  raise UnknownActionError, action if method_name.nil?

  validate_inbound_boundaries!(arguments)

  result = if action == PAYMENT_COMPLETING_ACTION
             call_and_log_transaction(method_name, arguments, correlation_id)
           else
             call_adapter(method_name, arguments, correlation_id)
           end
  validate_outbound_boundaries!(result)
  wrap(capability, result)
end