booking-agent-cli 0.7.0__tar.gz → 0.9.0__tar.gz

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.
Files changed (231) hide show
  1. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/PKG-INFO +2 -2
  2. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/README.md +1 -1
  3. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/pyproject.toml +1 -1
  4. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/__init__.py +1 -1
  5. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/backend/live.py +16 -6
  6. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/defaults.env +6 -0
  7. booking_agent_cli-0.9.0/src/booking_agent/faults.py +154 -0
  8. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/namespace.py +1 -0
  9. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/observability.py +11 -4
  10. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/platform_profile.py +11 -0
  11. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_capabilities.py +6 -0
  12. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_checkout.py +144 -0
  13. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_profile.py +25 -3
  14. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/__init__.py +1 -1
  15. booking_agent_cli-0.9.0/src/booking_agent_cli/booking_summary.py +364 -0
  16. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/bundled_skill.md +61 -58
  17. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/calendar_event.py +4 -12
  18. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/calendar_preference.py +9 -23
  19. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/catalog.py +26 -5
  20. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/flow.py +979 -76
  21. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/engine.py +2 -0
  22. booking_agent_cli-0.9.0/src/booking_agent_cli/fault_report_schema.py +67 -0
  23. booking_agent_cli-0.9.0/src/booking_agent_cli/fault_reports.py +252 -0
  24. booking_agent_cli-0.9.0/src/booking_agent_cli/gates.py +82 -0
  25. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/geo_eligibility.py +1 -1
  26. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/identity.py +7 -0
  27. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/main.py +15 -0
  28. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/output.py +6 -0
  29. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/payment_helpers.py +77 -22
  30. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/session.py +58 -0
  31. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/support_bundle_schema.py +3 -4
  32. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/support_report.py +40 -8
  33. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/conftest.py +14 -0
  34. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/test_timezone_booking_e2e.py +6 -3
  35. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/test_ucp_skill_flow_e2e.py +188 -3
  36. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_booking_rejection.py +11 -5
  37. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_connector.py +1 -0
  38. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_flow.py +37 -0
  39. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_preference.py +5 -5
  40. booking_agent_cli-0.9.0/tests/integration/test_cli_enforced_gates.py +660 -0
  41. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_declined_spt.py +1 -0
  42. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_gates.py +98 -69
  43. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_home_visit_address.py +4 -3
  44. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_install_integrity.py +2 -1
  45. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_link_auth.py +34 -1
  46. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_platform_profile.py +17 -0
  47. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_skill_integrity.py +2 -1
  48. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_support_report.py +2 -2
  49. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/ucp_checkout_store.py +73 -0
  50. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/ucp_server.py +16 -5
  51. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/offline_stack/stack.py +26 -5
  52. booking_agent_cli-0.9.0/tests/unit/test_fault_reports.py +275 -0
  53. booking_agent_cli-0.9.0/tests/unit/test_pay_at_service.py +249 -0
  54. booking_agent_cli-0.7.0/src/booking_agent_cli/booking_summary.py +0 -177
  55. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/.gitignore +0 -0
  56. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/authorization.py +0 -0
  57. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/backend/__init__.py +0 -0
  58. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/__init__.py +0 -0
  59. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/filter.py +0 -0
  60. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/google.py +0 -0
  61. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/oauth_listener.py +0 -0
  62. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/scope.py +0 -0
  63. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/circuit_breaker.py +0 -0
  64. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/config.py +0 -0
  65. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/discovery.py +0 -0
  66. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/headless.py +0 -0
  67. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/http_deadlines.py +0 -0
  68. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/http_retry.py +0 -0
  69. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/http_trace.py +0 -0
  70. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/merchant_pacing.py +0 -0
  71. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/models/__init__.py +0 -0
  72. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/models/platform.py +0 -0
  73. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/models/wire.py +0 -0
  74. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/pop.py +0 -0
  75. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/profile_cache.py +0 -0
  76. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/registry_url.py +0 -0
  77. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/rest_client.py +0 -0
  78. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/secure_file.py +0 -0
  79. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/settings.py +0 -0
  80. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/signals.py +0 -0
  81. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/step_timing.py +0 -0
  82. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_negotiation.py +0 -0
  83. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent/usp_methods.py +0 -0
  84. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/activity_log.py +0 -0
  85. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/booking_poll.py +0 -0
  86. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/calendar_connection.py +0 -0
  87. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/checkout_prereq.py +0 -0
  88. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/__init__.py +0 -0
  89. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/config.py +0 -0
  90. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/lowlevel.py +0 -0
  91. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/runtime.py +0 -0
  92. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/update.py +0 -0
  93. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/config_loader.py +0 -0
  94. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/flow_timings.py +0 -0
  95. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/geocode.py +0 -0
  96. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/install_integrity.py +0 -0
  97. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/link_cli.py +0 -0
  98. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/payment_token.py +0 -0
  99. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/provenance.py +0 -0
  100. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/reschedule.py +0 -0
  101. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/runtime.py +0 -0
  102. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/runtime_prepare.py +0 -0
  103. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/search_area.py +0 -0
  104. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/skill_bundle.py +0 -0
  105. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/skill_integrity.py +0 -0
  106. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/timezone_context.py +0 -0
  107. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/updates.py +0 -0
  108. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/.env.tests +0 -0
  109. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/__init__.py +0 -0
  110. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/test_cancel_reschedule_e2e.py +0 -0
  111. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/test_latency_soak.py +0 -0
  112. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/test_staging_registry_integration.py +0 -0
  113. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/test_ucp_native_e2e.py +0 -0
  114. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/e2e/ucp_native.py +0 -0
  115. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/__init__.py +0 -0
  116. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_activity_log.py +0 -0
  117. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_at_rest_hygiene.py +0 -0
  118. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_bulk_cancel.py +0 -0
  119. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_connection.py +0 -0
  120. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_checkout_signals.py +0 -0
  121. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_circuit_breaker.py +0 -0
  122. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_config.py +0 -0
  123. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_correlation_flow.py +0 -0
  124. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_discover_registry_rejection.py +0 -0
  125. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_evidence.py +0 -0
  126. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_reschedule.py +0 -0
  127. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_step_timings.py +0 -0
  128. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_google_calendar_oauth.py +0 -0
  129. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_http_trace.py +0 -0
  130. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_identity_logout.py +0 -0
  131. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_lazy_oauth.py +0 -0
  132. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_link_cli_spt.py +0 -0
  133. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_namespace.py +0 -0
  134. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_oauth_listener.py +0 -0
  135. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_oauth_metadata.py +0 -0
  136. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_platform_key_pop.py +0 -0
  137. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_pop_keystore.py +0 -0
  138. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_profile_cache.py +0 -0
  139. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_regression_index.py +0 -0
  140. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_release_hold_uses_delete.py +0 -0
  141. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_request_retry_semantics.py +0 -0
  142. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_runtime_prepare.py +0 -0
  143. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_search_area.py +0 -0
  144. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_session_compat.py +0 -0
  145. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_session_retention.py +0 -0
  146. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_skill_portability.py +0 -0
  147. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_timezone_context.py +0 -0
  148. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_cancel_result.py +0 -0
  149. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_checkout_pop.py +0 -0
  150. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_checkout_proof_wireup.py +0 -0
  151. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_checkout_validate.py +0 -0
  152. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_profile.py +0 -0
  153. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_update_command.py +0 -0
  154. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/integration/test_wheel_contents.py +0 -0
  155. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/SOURCE +0 -0
  156. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/acp_booking_extension.json +0 -0
  157. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/availability.json +0 -0
  158. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/booking.json +0 -0
  159. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/calendar_freebusy.json +0 -0
  160. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/catalog.json +0 -0
  161. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/paid_bookings.json +0 -0
  162. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/profile.json +0 -0
  163. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/registry.json +0 -0
  164. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/rest_common.json +0 -0
  165. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/usp.json +0 -0
  166. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/waitlist.json +0 -0
  167. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/webhook_event.json +0 -0
  168. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/__init__.py +0 -0
  169. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/__init__.py +0 -0
  170. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/profiles.py +0 -0
  171. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/provenance/pypi_attestations-0.0.30-py3-none-any.whl.provenance.json +0 -0
  172. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/services.py +0 -0
  173. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/__init__.py +0 -0
  174. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/backend.py +0 -0
  175. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/link_cli.py +0 -0
  176. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/mock_calendar.py +0 -0
  177. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/pop_business.py +0 -0
  178. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/mock/pop_transport.py +0 -0
  179. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/support/offline_stack/__init__.py +0 -0
  180. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/__init__.py +0 -0
  181. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_authorization_policy.py +0 -0
  182. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_booking_poll_credentials.py +0 -0
  183. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_booking_request_error.py +0 -0
  184. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_calendar_filter.py +0 -0
  185. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_catalog.py +0 -0
  186. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_check_version.py +0 -0
  187. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_conditional_writes.py +0 -0
  188. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_delivery_address.py +0 -0
  189. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_discovery.py +0 -0
  190. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_geo_eligibility.py +0 -0
  191. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_geocode.py +0 -0
  192. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_headless.py +0 -0
  193. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_http_deadlines.py +0 -0
  194. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_http_retry.py +0 -0
  195. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_live_search_registry.py +0 -0
  196. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_merchant_pacing.py +0 -0
  197. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_mock_support.py +0 -0
  198. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_money_path_events.py +0 -0
  199. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_nonce_from_pop_error_body.py +0 -0
  200. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_observability.py +0 -0
  201. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_outbound_member_names.py +0 -0
  202. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_package_layering.py +0 -0
  203. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_provenance.py +0 -0
  204. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_search_filters.py +0 -0
  205. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_search_pricing.py +0 -0
  206. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_search_responses.py +0 -0
  207. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_url.py +0 -0
  208. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_release_notes.py +0 -0
  209. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_rest_client.py +0 -0
  210. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_scoped_method_authorization.py +0 -0
  211. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_session_credentials.py +0 -0
  212. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_spec_conformance.py +0 -0
  213. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_step_timing.py +0 -0
  214. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_capabilities.py +0 -0
  215. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_complete_outcome.py +0 -0
  216. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_create_price_guard.py +0 -0
  217. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_negotiation.py +0 -0
  218. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_updates.py +0 -0
  219. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/unit/test_wire_model_conformance.py +0 -0
  220. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/001-jcs-nested-buyer.json +0 -0
  221. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/002-jcs-meta-only.json +0 -0
  222. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/003-jcs-unicode-empty-meta.json +0 -0
  223. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/004-proof-issuance-no-ath.json +0 -0
  224. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/005-proof-presentation-ath-nonce.json +0 -0
  225. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/006-cross-key-replay.json +0 -0
  226. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/007-downgrade-cnf-without-proof.json +0 -0
  227. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/008-replayed-jti.json +0 -0
  228. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/009-aud-mismatch.json +0 -0
  229. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/010-alg-none.json +0 -0
  230. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/README.md +0 -0
  231. {booking_agent_cli-0.7.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/SOURCE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: booking-agent-cli
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library.
5
5
  Project-URL: Homepage, https://github.com/wix/booking-agent
6
6
  Project-URL: Documentation, https://github.com/wix/booking-agent/blob/master/booking-agent/README.md
@@ -78,7 +78,7 @@ Each release also publishes that skill at an immutable `skill-v<version>` tag an
78
78
  the release workflow, so a harness can pin and verify the exact text it follows:
79
79
 
80
80
  ```bash
81
- curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.55.0/booking-agent/skills/booking-agent/SKILL.md
81
+ curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.57.0/booking-agent/skills/booking-agent/SKILL.md
82
82
  gh attestation verify SKILL.md --repo wix/booking-agent \
83
83
  --signer-workflow wix/booking-agent/.github/workflows/release.yml
84
84
  ```
@@ -36,7 +36,7 @@ Each release also publishes that skill at an immutable `skill-v<version>` tag an
36
36
  the release workflow, so a harness can pin and verify the exact text it follows:
37
37
 
38
38
  ```bash
39
- curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.55.0/booking-agent/skills/booking-agent/SKILL.md
39
+ curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.57.0/booking-agent/skills/booking-agent/SKILL.md
40
40
  gh attestation verify SKILL.md --repo wix/booking-agent \
41
41
  --signer-workflow wix/booking-agent/.github/workflows/release.yml
42
42
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "booking-agent-cli"
3
- version = "0.7.0"
3
+ version = "0.9.0"
4
4
  description = "The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -3,4 +3,4 @@
3
3
  Ships in the booking-agent-cli wheel and never imports ``booking_agent_cli``.
4
4
  """
5
5
 
6
- __version__ = "0.7.0"
6
+ __version__ = "0.9.0"
@@ -33,6 +33,7 @@ from ..rest_client import (
33
33
  USPTokenStore,
34
34
  )
35
35
  from ..headless import attach_registry_hit, mark_live_catalog_service, service_from_registry_hit
36
+ from ..faults import record_silent_fault
36
37
  from ..authorization import BookingScopedCredential, evaluate, resolve_posture
37
38
  from ..pop import PopKey, PopKeyStore
38
39
  from ..ucp_profile import UcpNativeContext, fetch_and_resolve_business_profile
@@ -115,39 +116,41 @@ class LiveUSPBackend(USPBackend):
115
116
  decision=decision,
116
117
  )
117
118
 
119
+ # Discovery absorbs these failures, so they stay at debug level: a warning reaches
120
+ # stderr, which agent hosts merge into the stdout JSON they parse.
118
121
  @staticmethod
119
122
  def _log_profile_failure(profile_url: str, exc: BaseException) -> None:
120
123
  if is_rate_limited(exc):
121
- logger.warning(
124
+ logger.debug(
122
125
  "Profile resolution rate limited (HTTP 429) for %s",
123
126
  profile_url,
124
127
  )
125
128
  return
126
129
  if isinstance(exc, httpx.HTTPStatusError):
127
- logger.warning(
130
+ logger.debug(
128
131
  "Profile resolution HTTP %s for %s",
129
132
  exc.response.status_code,
130
133
  profile_url,
131
134
  )
132
135
  return
133
- logger.warning("Profile resolution failed for %s", profile_url)
136
+ logger.debug("Profile resolution failed for %s: %s: %s", profile_url, type(exc).__name__, exc)
134
137
 
135
138
  @staticmethod
136
139
  def _log_service_failure(service_id: str, exc: BaseException) -> None:
137
140
  if is_rate_limited(exc):
138
- logger.warning(
141
+ logger.debug(
139
142
  "Service catalog fetch rate limited (HTTP 429) for %s",
140
143
  service_id,
141
144
  )
142
145
  return
143
146
  if isinstance(exc, httpx.HTTPStatusError):
144
- logger.warning(
147
+ logger.debug(
145
148
  "Service catalog fetch HTTP %s for %s",
146
149
  exc.response.status_code,
147
150
  service_id,
148
151
  )
149
152
  return
150
- logger.warning("Service fetch failed: %s", service_id)
153
+ logger.debug("Service fetch failed: %s: %s", service_id, type(exc).__name__)
151
154
 
152
155
  # -- Registry-level ------------------------------------------------------
153
156
 
@@ -184,6 +187,9 @@ class LiveUSPBackend(USPBackend):
184
187
  self._log_profile_failure(profile_url, exc)
185
188
  if is_rate_limited(exc):
186
189
  raise
190
+ record_silent_fault(
191
+ "profile_resolve", exc, profile_url=profile_url, business_id=biz_id,
192
+ )
187
193
 
188
194
  async def search_registry(self, intent: ServiceSearchIntent) -> List[USPService]:
189
195
  if not self._cfg.usp_registry_url:
@@ -289,6 +295,10 @@ class LiveUSPBackend(USPBackend):
289
295
  if is_rate_limited(exc):
290
296
  rate_limited_exc = exc
291
297
  raise
298
+ record_silent_fault(
299
+ "catalog_enrich", exc, profile_url=profile_url,
300
+ business_id=biz_id, service_id=r.service_id,
301
+ )
292
302
  return attach_registry_hit(
293
303
  service_from_registry_hit(r), r, rank=rank,
294
304
  )
@@ -43,6 +43,12 @@ BOOKING_AGENT_DISCOVERY_CONCURRENCY=8
43
43
  BOOKING_AGENT_PROFILE_CACHE_TTL_SECONDS=900
44
44
  # 1 writes a redacted request log to <session dir>/http_trace.log.
45
45
  BOOKING_AGENT_HTTP_TRACE=0
46
+ # 1 writes money-path JSON event lines to stderr. Off by default because many
47
+ # agent hosts merge stderr into the stdout JSON they parse.
48
+ BOOKING_AGENT_STDERR_EVENTS=0
49
+ # 0 stops automatic fault reports: faults the user never saw, about a business's
50
+ # profile or catalog or the assistant's own code, sent with no user data.
51
+ BOOKING_AGENT_FAULT_REPORTS=1
46
52
 
47
53
  # --- Address -------------------------------------------------------------------
48
54
  BOOKING_AGENT_GEOCODER_URL=https://nominatim.openstreetmap.org/search
@@ -0,0 +1,154 @@
1
+ """Faults the client absorbed, so the user never saw them.
2
+
3
+ Code that catches an exception and carries on (a business profile that cannot be
4
+ read during discovery, a catalog entry that cannot be fetched) calls
5
+ ``record_silent_fault``. Warnings logged by the allowed modules during a command are
6
+ collected the same way. The CLI decides at the end of the command whether these
7
+ become automatic fault reports; nothing here does I/O.
8
+
9
+ Only structural facts are kept: the step, the exception type, a code-authored log
10
+ format string, and public identifiers of the business involved. Exception messages
11
+ and log arguments are never kept, because they can carry anything.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from typing import Any, Dict, List, Optional
18
+ from urllib.parse import urlsplit
19
+
20
+ #: Steps whose outcome never depends on the user's own accounts or data.
21
+ STEPS = frozenset({
22
+ "registry_search",
23
+ "profile_resolve",
24
+ "catalog_enrich",
25
+ "availability_prefetch",
26
+ "platform_profile",
27
+ "cli",
28
+ })
29
+ #: Loggers whose warnings count as degraded results. Link, payment, identity, buyer
30
+ #: details, and Google Calendar code is not here: their faults stay on the consented path.
31
+ WARNING_LOGGERS = (
32
+ "booking_agent.backend.live",
33
+ "booking_agent.discovery",
34
+ "booking_agent.headless",
35
+ "booking_agent.ucp_profile",
36
+ "booking_agent.profile_cache",
37
+ )
38
+ _MAX_RECORDED = 50
39
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
40
+
41
+ _recorded: List[Dict[str, Any]] = []
42
+
43
+
44
+ def _reason(exc: Optional[BaseException]) -> Optional[str]:
45
+ if exc is None:
46
+ return None
47
+ response = getattr(exc, "response", None)
48
+ status = getattr(response, "status_code", None)
49
+ if isinstance(status, int):
50
+ return f"http_{status}"
51
+ from booking_agent.ucp_profile import UcpProfileValidationError
52
+
53
+ if isinstance(exc, UcpProfileValidationError) and exc.args and isinstance(exc.args[0], str):
54
+ # Validation messages are written in this package and name only profile fields.
55
+ slug = _SLUG_RE.sub("_", exc.args[0].lower()).strip("_")
56
+ return slug[:64] or None
57
+ return None
58
+
59
+
60
+ def _subject(profile_url: Optional[str], **ids: Any) -> Dict[str, Any]:
61
+ subject: Dict[str, Any] = {}
62
+ if profile_url:
63
+ try:
64
+ parts = urlsplit(profile_url)
65
+ except ValueError:
66
+ parts = None
67
+ if parts is not None and parts.scheme in ("http", "https") and parts.hostname:
68
+ subject["host"] = parts.hostname
69
+ subject["path"] = parts.path or "/"
70
+ for key, value in ids.items():
71
+ if value is not None:
72
+ subject[key] = value
73
+ return subject
74
+
75
+
76
+ def record_silent_fault(
77
+ step: str,
78
+ exc: Optional[BaseException] = None,
79
+ *,
80
+ profile_url: Optional[str] = None,
81
+ business_id: Optional[str] = None,
82
+ service_id: Optional[str] = None,
83
+ ) -> None:
84
+ """Note a fault this code absorbed. Never raises."""
85
+ try:
86
+ if len(_recorded) >= _MAX_RECORDED:
87
+ return
88
+ response = getattr(exc, "response", None)
89
+ status = getattr(response, "status_code", None)
90
+ _recorded.append({
91
+ "fault_class": "absorbed_exception",
92
+ "step": step if step in STEPS else "cli",
93
+ "exception_type": _qualname(exc),
94
+ "message_template": None,
95
+ "reason_code": _reason(exc),
96
+ "subject": _subject(
97
+ profile_url,
98
+ business_id=business_id,
99
+ service_ids=[service_id] if service_id else None,
100
+ http_status=status if isinstance(status, int) else None,
101
+ ),
102
+ })
103
+ except Exception:
104
+ pass
105
+
106
+
107
+ def _qualname(exc: Optional[BaseException]) -> Optional[str]:
108
+ if exc is None:
109
+ return None
110
+ cls = type(exc)
111
+ return f"{cls.__module__}.{cls.__qualname__}"
112
+
113
+
114
+ class _WarningCollector(logging.Handler):
115
+ """Keeps the format string of each warning; the formatted text is never stored."""
116
+
117
+ def __init__(self) -> None:
118
+ super().__init__(level=logging.WARNING)
119
+
120
+ def emit(self, record: logging.LogRecord) -> None:
121
+ try:
122
+ if not record.name.startswith(WARNING_LOGGERS) or len(_recorded) >= _MAX_RECORDED:
123
+ return
124
+ exc = record.exc_info[1] if record.exc_info else None
125
+ _recorded.append({
126
+ "fault_class": "degraded_result",
127
+ "step": "cli",
128
+ "exception_type": _qualname(exc),
129
+ "message_template": record.msg if isinstance(record.msg, str) else None,
130
+ "reason_code": None,
131
+ "subject": {"logger": record.name},
132
+ })
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ _collector: Optional[_WarningCollector] = None
138
+
139
+
140
+ def install_warning_collector() -> None:
141
+ """Collect allowed warnings instead of printing them to stderr (idempotent)."""
142
+ global _collector
143
+ if _collector is not None:
144
+ return
145
+ _collector = _WarningCollector()
146
+ for name in ("booking_agent", "booking_agent_cli"):
147
+ logging.getLogger(name).addHandler(_collector)
148
+
149
+
150
+ def drain() -> List[Dict[str, Any]]:
151
+ """Every fault recorded so far, cleared."""
152
+ out = list(_recorded)
153
+ _recorded.clear()
154
+ return out
@@ -17,6 +17,7 @@ CAP_CATALOG = "dev.usp-protocol.services.catalog"
17
17
  CAP_AVAILABILITY = "dev.usp-protocol.services.availability"
18
18
  CAP_BOOKINGS = "dev.usp-protocol.services.bookings"
19
19
  CAP_PAID_BOOKINGS = "dev.usp-protocol.services.paid_bookings"
20
+ CAP_PAY_AT_SERVICE = "dev.usp-protocol.services.pay_at_service"
20
21
 
21
22
 
22
23
  class NamespaceOriginError(ValueError):
@@ -9,6 +9,8 @@ from typing import Any, Callable, Dict, Optional
9
9
 
10
10
  import httpx
11
11
 
12
+ from booking_agent import settings
13
+
12
14
  # Wire name for the flow join key. Optional on the USP HTTP binding (Track S of
13
15
  # spec issue #160): send when bound, never require it on responses, never fail a
14
16
  # call because a peer omitted or ignored it.
@@ -35,9 +37,14 @@ _FORBIDDEN_FIELD_KEYS = frozenset({
35
37
  })
36
38
 
37
39
  _correlation_id: ContextVar[Optional[str]] = ContextVar("booking_agent_correlation_id", default=None)
38
- _event_sink: Callable[[Dict[str, Any]], None] = lambda record: sys.stderr.write(
39
- json.dumps(record, default=str) + "\n"
40
- )
40
+
41
+
42
+ def _default_sink(record: Dict[str, Any]) -> None:
43
+ if settings.get_bool("BOOKING_AGENT_STDERR_EVENTS"):
44
+ sys.stderr.write(json.dumps(record, default=str) + "\n")
45
+
46
+
47
+ _event_sink: Callable[[Dict[str, Any]], None] = _default_sink
41
48
 
42
49
 
43
50
  def set_event_sink(sink: Callable[[Dict[str, Any]], None]) -> None:
@@ -49,7 +56,7 @@ def set_event_sink(sink: Callable[[Dict[str, Any]], None]) -> None:
49
56
  def reset_event_sink() -> None:
50
57
  """Restore the default stderr event sink."""
51
58
  global _event_sink
52
- _event_sink = lambda record: sys.stderr.write(json.dumps(record, default=str) + "\n")
59
+ _event_sink = _default_sink
53
60
 
54
61
 
55
62
  def new_correlation_id() -> str:
@@ -88,6 +88,17 @@ def platform_profile_doc() -> Dict[str, Any]:
88
88
  "extends": "dev.ucp.shopping.checkout",
89
89
  }
90
90
  ],
91
+ "dev.usp-protocol.services.pay_at_service": [
92
+ {
93
+ "version": "2026-08-20",
94
+ "spec": (
95
+ "https://usp-protocol.dev/specification"
96
+ "#pay-at-service-settlement-extension"
97
+ ),
98
+ "schema": "https://usp-protocol.dev/schemas/services/pay_at_service.json",
99
+ "extends": "dev.ucp.shopping.checkout",
100
+ }
101
+ ],
91
102
  "dev.ucp.common.payment.terms": [
92
103
  {
93
104
  "version": "2026-08-25",
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
  from typing import Any, Callable, Dict, List
29
29
 
30
30
  from .ucp_negotiation import _capabilities_from_doc
31
+ from .namespace import CAP_PAY_AT_SERVICE
31
32
  from .ucp_profile import CAP_PAID_BOOKINGS, UcpProfileValidationError
32
33
 
33
34
 
@@ -91,9 +92,14 @@ def _paid_bookings_handler(echoed_versions: List[str], platform_supported: List[
91
92
  _validate_versions_supported(CAP_PAID_BOOKINGS, echoed_versions, platform_supported)
92
93
 
93
94
 
95
+ def _pay_at_service_handler(echoed_versions: List[str], platform_supported: List[str]) -> None:
96
+ _validate_versions_supported(CAP_PAY_AT_SERVICE, echoed_versions, platform_supported)
97
+
98
+
94
99
  # namespace -> handler. Register new capabilities (e.g. AP2) here.
95
100
  CAPABILITY_HANDLERS: Dict[str, CapabilityHandler] = {
96
101
  CAP_PAID_BOOKINGS: _paid_bookings_handler,
102
+ CAP_PAY_AT_SERVICE: _pay_at_service_handler,
97
103
  }
98
104
 
99
105
 
@@ -5,6 +5,7 @@ import json
5
5
  import logging
6
6
  import uuid
7
7
  from dataclasses import dataclass
8
+ from datetime import datetime
8
9
  from enum import Enum
9
10
  from typing import Any, Dict, List, Optional
10
11
 
@@ -489,6 +490,129 @@ def immediate_schedule_amount(term: Dict[str, Any]) -> Optional[int]:
489
490
  return amount
490
491
 
491
492
 
493
+ PAY_AT_SERVICE_CAPABILITY = "dev.usp-protocol.services.pay_at_service"
494
+ SETTLEMENT_MODE_OFFLINE = "offline"
495
+ #: Disclosure code the business emits for a pay-at-service checkout (USP 11.3.5 S8).
496
+ PAYMENT_DUE_AT_SERVICE_CODE = "payment_due_at_service"
497
+
498
+
499
+ def is_offline_settled(checkout: Any) -> bool:
500
+ """True when the checkout declares ``settlement.mode == "offline"`` (USP 11.3.3)."""
501
+ settlement = checkout.get("settlement") if isinstance(checkout, dict) else None
502
+ return isinstance(settlement, dict) and settlement.get("mode") == SETTLEMENT_MODE_OFFLINE
503
+
504
+
505
+ def _instants_equal(a: Any, b: Any) -> bool:
506
+ """Compare two RFC 3339 instants, tolerating a differing UTC offset spelling."""
507
+ if a == b:
508
+ return True
509
+ try:
510
+ left = datetime.fromisoformat(str(a).replace("Z", "+00:00"))
511
+ right = datetime.fromisoformat(str(b).replace("Z", "+00:00"))
512
+ except ValueError:
513
+ return False
514
+ if left.tzinfo is None or right.tzinfo is None:
515
+ return False
516
+ return left == right
517
+
518
+
519
+ def _is_at_service_term(term: Any) -> bool:
520
+ schedules = term.get("schedules") if isinstance(term, dict) else None
521
+ return (
522
+ isinstance(schedules, list)
523
+ and bool(schedules)
524
+ and all(isinstance(s, dict) and s.get("type") == "at_service" for s in schedules)
525
+ )
526
+
527
+
528
+ def validate_offline_settlement(
529
+ checkout: Dict[str, Any],
530
+ *,
531
+ negotiated_capabilities: Optional[Dict[str, Any]] = None,
532
+ persisted_term: Optional[Dict[str, Any]] = None,
533
+ slot_start: Optional[str] = None,
534
+ ) -> Dict[str, Any]:
535
+ """Validate a pay-at-service checkout (USP 11.3) and return its selected term.
536
+
537
+ Fails closed on anything the extension does not define: an unknown settlement
538
+ mode, a settlement without the terms it names (S1), more than one offline term
539
+ (S2), a ``due_at`` that is not the slot start (S3), or a selected term other
540
+ than the offline one (S4 applies only when the offline term is selected).
541
+ """
542
+ settlement = checkout.get("settlement")
543
+ if not isinstance(settlement, dict):
544
+ raise UcpProfileValidationError("Checkout settlement must be an object.")
545
+ if settlement.get("mode") != SETTLEMENT_MODE_OFFLINE:
546
+ raise UcpProfileValidationError(
547
+ f"Checkout settlement mode {settlement.get('mode')!r} is not supported."
548
+ )
549
+ term_id = settlement.get("term_id")
550
+ if not isinstance(term_id, str) or not term_id:
551
+ raise UcpProfileValidationError("Checkout settlement is missing term_id.")
552
+ caps = negotiated_capabilities or {}
553
+ missing = [
554
+ cap for cap in (PAY_AT_SERVICE_CAPABILITY, PAYMENT_TERMS_CAPABILITY) if cap not in caps
555
+ ]
556
+ if missing:
557
+ raise UcpProfileValidationError(
558
+ "Checkout declared pay-at-service settlement without negotiated capabilities "
559
+ f"{missing!r}."
560
+ )
561
+ payment = checkout.get("payment") if isinstance(checkout.get("payment"), dict) else {}
562
+ terms = payment.get("terms")
563
+ selected_id = payment.get("selected_term_id")
564
+ if not isinstance(terms, list) or not terms or not selected_id:
565
+ raise UcpProfileValidationError(
566
+ "Checkout settlement requires payment.terms and payment.selected_term_id."
567
+ )
568
+ named = [t for t in terms if isinstance(t, dict) and t.get("id") == term_id]
569
+ if len(named) != 1:
570
+ raise UcpProfileValidationError(
571
+ "Checkout settlement.term_id must match exactly one payment.terms[].id."
572
+ )
573
+ term = named[0]
574
+ offline_terms = [t for t in terms if _is_at_service_term(t)]
575
+ if len(offline_terms) != 1 or offline_terms[0] is not term:
576
+ raise UcpProfileValidationError(
577
+ "A pay-at-service checkout must offer exactly one pay-at-service term, "
578
+ "and settlement.term_id must name it."
579
+ )
580
+ if selected_id != term_id:
581
+ raise UcpProfileValidationError(
582
+ "The pay-at-service term is not the selected payment term, so the booking "
583
+ "cannot be completed without a payment instrument."
584
+ )
585
+ total = checkout_total_minor_units(checkout)
586
+ if total is None:
587
+ raise UcpProfileValidationError("Checkout totals must include a type=total amount.")
588
+ amounts: list[int] = []
589
+ for schedule in term["schedules"]:
590
+ amount = _schedule_amount(schedule)
591
+ if amount is None or amount <= 0:
592
+ raise UcpProfileValidationError(
593
+ "Each pay-at-service schedule must carry a positive integer amount."
594
+ )
595
+ amounts.append(amount)
596
+ due_at = schedule.get("due_at")
597
+ if not due_at:
598
+ raise UcpProfileValidationError(
599
+ "Pay-at-service schedule must set due_at to the slot start."
600
+ )
601
+ if slot_start and not _instants_equal(due_at, slot_start):
602
+ raise UcpProfileValidationError(
603
+ "Pay-at-service due_at must equal the booked slot start."
604
+ )
605
+ if sum(amounts) != int(total):
606
+ raise UcpProfileValidationError(
607
+ "Pay-at-service schedule amounts must sum to the checkout total."
608
+ )
609
+ if persisted_term is not None and persisted_term != term:
610
+ raise UcpProfileValidationError(
611
+ "The payment term changed since it was quoted. Do not complete this booking."
612
+ )
613
+ return term
614
+
615
+
492
616
  def validate_payment_terms(
493
617
  checkout: Dict[str, Any],
494
618
  *,
@@ -496,13 +620,33 @@ def validate_payment_terms(
496
620
  require_terms: bool = False,
497
621
  persisted_term: Optional[Dict[str, Any]] = None,
498
622
  slot_start: Optional[str] = None,
623
+ expect_offline_settlement: bool = False,
499
624
  ) -> Optional[Dict[str, Any]]:
500
625
  """Validate UCP payment terms on a checkout. Returns the selected term or None.
501
626
 
502
627
  Deposits require the negotiated terms capability, exactly one selected term,
503
628
  schedules that sum to checkout total, exactly one positive immediate amount,
504
629
  and an at-service balance with due_at.
630
+
631
+ A checkout routed as pay-at-service (``expect_offline_settlement``) must carry
632
+ an offline ``settlement`` and is validated by :func:`validate_offline_settlement`;
633
+ any other route that receives a ``settlement`` fails closed rather than being
634
+ treated as an ordinary charge.
505
635
  """
636
+ has_settlement = "settlement" in checkout
637
+ if has_settlement != expect_offline_settlement:
638
+ raise UcpProfileValidationError(
639
+ "Checkout carries a settlement this booking was not routed for."
640
+ if has_settlement
641
+ else "This booking was routed as pay-at-service but the checkout has no settlement."
642
+ )
643
+ if has_settlement:
644
+ return validate_offline_settlement(
645
+ checkout,
646
+ negotiated_capabilities=negotiated_capabilities,
647
+ persisted_term=persisted_term,
648
+ slot_start=slot_start,
649
+ )
506
650
  payment = checkout.get("payment") if isinstance(checkout.get("payment"), dict) else {}
507
651
  terms = payment.get("terms") if isinstance(payment, dict) else None
508
652
  has_terms = isinstance(terms, list) and len(terms) > 0
@@ -20,9 +20,16 @@ from pydantic import BaseModel, ConfigDict, Field
20
20
  from pydantic.alias_generators import to_camel
21
21
 
22
22
  from .authorization import AuthorizationPolicy, parse_policy
23
- from .namespace import CAP_CATALOG, CAP_PAID_BOOKINGS, SVC_SERVICES, check_origin_binding
23
+ from .namespace import (
24
+ CAP_CATALOG,
25
+ CAP_PAID_BOOKINGS,
26
+ CAP_PAY_AT_SERVICE,
27
+ SVC_SERVICES,
28
+ check_origin_binding,
29
+ )
24
30
 
25
31
  CAP_CHECKOUT = "dev.ucp.shopping.checkout"
32
+ CAP_PAYMENT_TERMS = "dev.ucp.common.payment.terms"
26
33
 
27
34
 
28
35
  class UcpTransportBinding(BaseModel):
@@ -110,6 +117,20 @@ class UcpNativeContext(BaseModel):
110
117
  def requires_paid_bookings(self) -> bool:
111
118
  return CAP_PAID_BOOKINGS in self.profile.capabilities
112
119
 
120
+ def declares_pay_at_service(self) -> bool:
121
+ """True when the business declares everything the pay-at-service checkout path needs.
122
+
123
+ USP 11.3.2: a business routing ``at_service`` through a checkout declares
124
+ ``pay_at_service`` *and* ``dev.ucp.common.payment.terms``; declaring the
125
+ first without the second is malformed, so it is not treated as declared.
126
+ """
127
+ caps = self.profile.capabilities
128
+ return (
129
+ CAP_PAY_AT_SERVICE in caps
130
+ and CAP_PAYMENT_TERMS in caps
131
+ and CAP_CHECKOUT in caps
132
+ )
133
+
113
134
  def usp_authorization(self) -> Optional[AuthorizationPolicy]:
114
135
  """The ``config.authorization`` policy on the ``dev.usp-protocol.services`` binding.
115
136
 
@@ -148,10 +169,11 @@ class UcpNativeContext(BaseModel):
148
169
  continue
149
170
  if "authorization" in binding:
150
171
  logger.warning(
151
- f"{SVC_SERVICES} binding declares a sibling 'authorization' member, "
172
+ "%s binding declares a sibling 'authorization' member, "
152
173
  "which USP prohibits; the policy MUST be published as "
153
174
  "config.authorization. Treating this business as publishing no "
154
- "policy."
175
+ "policy.",
176
+ SVC_SERVICES,
155
177
  )
156
178
  misplaced = True
157
179
  continue
@@ -1,3 +1,3 @@
1
1
  """booking_agent_cli: typer-based stateful CLI for USP booking flows."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"