provecart 0.1.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.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: provecart
3
+ Version: 0.1.0
4
+ Summary: ProveCart client: check an AI agent's cart, booking, or reservation against the buyer's signed instructions before payment is captured.
5
+ License: MIT
6
+ Project-URL: Homepage, https://provecart.com
7
+ Project-URL: Documentation, https://provecart.com/agents
8
+ Project-URL: Correction format, https://provecart.com/standards/correction-format
9
+ Keywords: ai-agents,agent-commerce,verification,payments,provecart
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+
16
+ # provecart
17
+
18
+ Your agent put the wrong variant in the cart — the wrong size, the wrong model, a quantity nobody asked for — and your user paid for it. By the time anyone noticed, the order had shipped, and the return came out of somebody's pocket. That is the failure this package exists to stop: one check, between what the agent built and the payment, that compares the transaction against what the buyer actually said.
19
+
20
+ ProveCart is a neutral verification API for agent commerce. You record the buyer's instruction as a signed mandate, send what the agent actually built, and get back a signed verdict — `MATCH`, `REVIEW`, or `BLOCK` — before payment is captured (or, for bookings, before a ticket is issued). A `BLOCK` or `REVIEW` answer carries machine-readable corrections, so the agent can fix what it built and resubmit instead of losing the sale or guessing. Every check is written to a sealed evidence record. ProveCart never holds or moves money.
21
+
22
+ ```bash
23
+ pip install provecart
24
+ ```
25
+
26
+ Standard library only. No dependencies.
27
+
28
+ ## Check a cart
29
+
30
+ ```python
31
+ from provecart import ProveCartClient, route_verdict
32
+ client = ProveCartClient(api_key="YOUR_API_KEY")
33
+ verdict = client.check_cart(merchant={"name": "Example Shop", "domain": "shop.example.com"}, items=[{"name": "Wireless Headphones", "variant": "black", "quantity": 1, "unit_price": 249.99}], total_price=249.99, mandate={"user_id": "user-123", "item_description": "Wireless noise cancelling over-ear headphones", "item_keywords": ["wireless", "headphones"], "category": "electronics", "max_total_price": 300.00, "currency": "USD", "max_quantity": 1, "expires_at": "2027-01-01T00:00:00Z"})
34
+ decision, detail = route_verdict(verdict) # proceed / fix / escalate
35
+ ```
36
+
37
+ Already holding a mandate id? Pass `mandate_id=` instead of `mandate=` — `check_cart` takes either. The lower-level pair is also there when you want the steps separate: `create_mandate(**fields)` returns the signed mandate, and `verify_cart(mandate_id, merchant, items, total_price, ...)` runs the check.
38
+
39
+ ## Check a booking
40
+
41
+ ```python
42
+ from provecart import ProveCartClient, route_verdict
43
+ client = ProveCartClient(api_key="YOUR_API_KEY")
44
+ verdict = client.check_booking(merchant={"name": "Example Air", "domain": "air.example.com"}, price={"total": 412.00}, travelers=["Alex Rivera"], legs=[{"from_airport": "SFO", "to_airport": "JFK", "depart_at": "2027-03-10T08:00:00Z", "arrive_at": "2027-03-10T16:30:00Z"}], mandate={"user_id": "user-123", "travelers": [{"name": "Alex Rivera"}], "origin": {"airport": "SFO"}, "destination": {"airport": "JFK"}, "departure_date": "2027-03-10", "max_total_price": 500.00, "currency": "USD", "expires_at": "2027-03-01T00:00:00Z"})
45
+ decision, detail = route_verdict(verdict) # gate both capture and ticket issuance on MATCH
46
+ ```
47
+
48
+ The separate steps are `create_booking_mandate(**fields)` and `verify_booking(mandate_id, merchant, price, travelers=..., legs=..., stays=..., fare=...)`.
49
+
50
+ ## Check a reservation
51
+
52
+ ```python
53
+ from provecart import ProveCartClient, route_verdict
54
+ client = ProveCartClient(api_key="YOUR_API_KEY")
55
+ verdict = client.check_reservation(merchant={"name": "Example Table", "domain": "table.example.com"}, date="2027-03-10", time="19:00", party_size=2, name_on_reservation="Alex Rivera", total_amount=40.00, mandate={"user_id": "user-123", "service_description": "Dinner reservation for two", "date": "2027-03-10", "time": "19:00", "party_size": 2, "name_on_reservation": "Alex Rivera", "max_total_price": 60.00, "currency": "USD", "expires_at": "2027-03-09T00:00:00Z"})
56
+ decision, detail = route_verdict(verdict) # proceed / fix / escalate
57
+ ```
58
+
59
+ The separate steps are `create_reservation_mandate(**fields)` and `verify_reservation(mandate_id, merchant, date, time, party_size, name_on_reservation, total_amount, ...)`.
60
+
61
+ ## What to do with the verdict
62
+
63
+ `route_verdict` encodes the consumer rules of the Agent Correction Format, and they are deliberately strict:
64
+
65
+ - **MATCH → proceed.** Capture the payment (or issue the ticket).
66
+ - **BLOCK → fix, narrowly.** Fix only when every correction in the response is a known action that brings the transaction back inside the buyer's existing authorization. Apply exactly those corrections, then resubmit as a new check carrying the earlier check's `record_id` as `previous_record_id`, within the response's `max_attempts` budget.
67
+ - **REVIEW, unknown verdict, unknown action → escalate.** Stop and ask the buyer. Never guess, and never widen the buyer's authorization to make a verdict go away.
68
+
69
+ ```python
70
+ decision, detail = route_verdict(first_verdict)
71
+ if decision == "fix":
72
+ # ... apply detail["corrections"] to the transaction, nothing else ...
73
+ second = client.check_cart(..., mandate_id=same_id, previous_record_id=detail["record_id"])
74
+ ```
75
+
76
+ ## Offline and mock runs
77
+
78
+ The client takes an optional `transport` callable, `(path, body) -> (status, dict)`, that replaces the HTTP layer entirely. Point it at canned verdicts and the full mandate → check → route → resubmit loop runs with no network and no API key traffic — that is how this package's own test suite works, and it is the recommended way to rehearse an integration before wiring a real key:
79
+
80
+ ```python
81
+ def stub(path, body):
82
+ return 200, {"id": "m-1"} if "mandates" in path else {"verdict": "MATCH", "record_id": "r-1"}
83
+ client = ProveCartClient(api_key="test", transport=stub)
84
+ ```
85
+
86
+ Calls made with a sandbox key against the live API behave exactly as in production and stay sealed under that key, separate from live data.
87
+
88
+ ## Links
89
+
90
+ - Agents and certification: https://provecart.com/agents
91
+ - Correction format standard: https://provecart.com/standards/correction-format
92
+ - API reference: https://api.provecart.com/docs
93
+
94
+ ProveCart. Prove the cart. Patent pending.
@@ -0,0 +1,79 @@
1
+ # provecart
2
+
3
+ Your agent put the wrong variant in the cart — the wrong size, the wrong model, a quantity nobody asked for — and your user paid for it. By the time anyone noticed, the order had shipped, and the return came out of somebody's pocket. That is the failure this package exists to stop: one check, between what the agent built and the payment, that compares the transaction against what the buyer actually said.
4
+
5
+ ProveCart is a neutral verification API for agent commerce. You record the buyer's instruction as a signed mandate, send what the agent actually built, and get back a signed verdict — `MATCH`, `REVIEW`, or `BLOCK` — before payment is captured (or, for bookings, before a ticket is issued). A `BLOCK` or `REVIEW` answer carries machine-readable corrections, so the agent can fix what it built and resubmit instead of losing the sale or guessing. Every check is written to a sealed evidence record. ProveCart never holds or moves money.
6
+
7
+ ```bash
8
+ pip install provecart
9
+ ```
10
+
11
+ Standard library only. No dependencies.
12
+
13
+ ## Check a cart
14
+
15
+ ```python
16
+ from provecart import ProveCartClient, route_verdict
17
+ client = ProveCartClient(api_key="YOUR_API_KEY")
18
+ verdict = client.check_cart(merchant={"name": "Example Shop", "domain": "shop.example.com"}, items=[{"name": "Wireless Headphones", "variant": "black", "quantity": 1, "unit_price": 249.99}], total_price=249.99, mandate={"user_id": "user-123", "item_description": "Wireless noise cancelling over-ear headphones", "item_keywords": ["wireless", "headphones"], "category": "electronics", "max_total_price": 300.00, "currency": "USD", "max_quantity": 1, "expires_at": "2027-01-01T00:00:00Z"})
19
+ decision, detail = route_verdict(verdict) # proceed / fix / escalate
20
+ ```
21
+
22
+ Already holding a mandate id? Pass `mandate_id=` instead of `mandate=` — `check_cart` takes either. The lower-level pair is also there when you want the steps separate: `create_mandate(**fields)` returns the signed mandate, and `verify_cart(mandate_id, merchant, items, total_price, ...)` runs the check.
23
+
24
+ ## Check a booking
25
+
26
+ ```python
27
+ from provecart import ProveCartClient, route_verdict
28
+ client = ProveCartClient(api_key="YOUR_API_KEY")
29
+ verdict = client.check_booking(merchant={"name": "Example Air", "domain": "air.example.com"}, price={"total": 412.00}, travelers=["Alex Rivera"], legs=[{"from_airport": "SFO", "to_airport": "JFK", "depart_at": "2027-03-10T08:00:00Z", "arrive_at": "2027-03-10T16:30:00Z"}], mandate={"user_id": "user-123", "travelers": [{"name": "Alex Rivera"}], "origin": {"airport": "SFO"}, "destination": {"airport": "JFK"}, "departure_date": "2027-03-10", "max_total_price": 500.00, "currency": "USD", "expires_at": "2027-03-01T00:00:00Z"})
30
+ decision, detail = route_verdict(verdict) # gate both capture and ticket issuance on MATCH
31
+ ```
32
+
33
+ The separate steps are `create_booking_mandate(**fields)` and `verify_booking(mandate_id, merchant, price, travelers=..., legs=..., stays=..., fare=...)`.
34
+
35
+ ## Check a reservation
36
+
37
+ ```python
38
+ from provecart import ProveCartClient, route_verdict
39
+ client = ProveCartClient(api_key="YOUR_API_KEY")
40
+ verdict = client.check_reservation(merchant={"name": "Example Table", "domain": "table.example.com"}, date="2027-03-10", time="19:00", party_size=2, name_on_reservation="Alex Rivera", total_amount=40.00, mandate={"user_id": "user-123", "service_description": "Dinner reservation for two", "date": "2027-03-10", "time": "19:00", "party_size": 2, "name_on_reservation": "Alex Rivera", "max_total_price": 60.00, "currency": "USD", "expires_at": "2027-03-09T00:00:00Z"})
41
+ decision, detail = route_verdict(verdict) # proceed / fix / escalate
42
+ ```
43
+
44
+ The separate steps are `create_reservation_mandate(**fields)` and `verify_reservation(mandate_id, merchant, date, time, party_size, name_on_reservation, total_amount, ...)`.
45
+
46
+ ## What to do with the verdict
47
+
48
+ `route_verdict` encodes the consumer rules of the Agent Correction Format, and they are deliberately strict:
49
+
50
+ - **MATCH → proceed.** Capture the payment (or issue the ticket).
51
+ - **BLOCK → fix, narrowly.** Fix only when every correction in the response is a known action that brings the transaction back inside the buyer's existing authorization. Apply exactly those corrections, then resubmit as a new check carrying the earlier check's `record_id` as `previous_record_id`, within the response's `max_attempts` budget.
52
+ - **REVIEW, unknown verdict, unknown action → escalate.** Stop and ask the buyer. Never guess, and never widen the buyer's authorization to make a verdict go away.
53
+
54
+ ```python
55
+ decision, detail = route_verdict(first_verdict)
56
+ if decision == "fix":
57
+ # ... apply detail["corrections"] to the transaction, nothing else ...
58
+ second = client.check_cart(..., mandate_id=same_id, previous_record_id=detail["record_id"])
59
+ ```
60
+
61
+ ## Offline and mock runs
62
+
63
+ The client takes an optional `transport` callable, `(path, body) -> (status, dict)`, that replaces the HTTP layer entirely. Point it at canned verdicts and the full mandate → check → route → resubmit loop runs with no network and no API key traffic — that is how this package's own test suite works, and it is the recommended way to rehearse an integration before wiring a real key:
64
+
65
+ ```python
66
+ def stub(path, body):
67
+ return 200, {"id": "m-1"} if "mandates" in path else {"verdict": "MATCH", "record_id": "r-1"}
68
+ client = ProveCartClient(api_key="test", transport=stub)
69
+ ```
70
+
71
+ Calls made with a sandbox key against the live API behave exactly as in production and stay sealed under that key, separate from live data.
72
+
73
+ ## Links
74
+
75
+ - Agents and certification: https://provecart.com/agents
76
+ - Correction format standard: https://provecart.com/standards/correction-format
77
+ - API reference: https://api.provecart.com/docs
78
+
79
+ ProveCart. Prove the cart. Patent pending.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "provecart"
7
+ version = "0.1.0"
8
+ description = "ProveCart client: check an AI agent's cart, booking, or reservation against the buyer's signed instructions before payment is captured."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "MIT"}
12
+ keywords = ["ai-agents", "agent-commerce", "verification", "payments", "provecart"]
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Software Development :: Libraries :: Python Modules",
17
+ ]
18
+ dependencies = []
19
+
20
+ [project.urls]
21
+ Homepage = "https://provecart.com"
22
+ Documentation = "https://provecart.com/agents"
23
+ "Correction format" = "https://provecart.com/standards/correction-format"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ """ProveCart client: verify an agent's cart, booking, or reservation
2
+ against the buyer's signed instructions before payment is captured.
3
+ """
4
+ from .client import (
5
+ KNOWN_ACTIONS,
6
+ Client,
7
+ ProveCartClient,
8
+ ProveCartError,
9
+ route_verdict,
10
+ )
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "KNOWN_ACTIONS",
16
+ "Client",
17
+ "ProveCartClient",
18
+ "ProveCartError",
19
+ "route_verdict",
20
+ "__version__",
21
+ ]
@@ -0,0 +1,286 @@
1
+ """ProveCart client + verdict routing.
2
+
3
+ Self-contained (standard library only). Implements the consumer rules
4
+ of the Agent Correction Format (ACF v0.2, com.provecart.conformity):
5
+
6
+ MATCH -> proceed to payment (or ticket issuance).
7
+ BLOCK + fixes -> apply ONLY repairs that bring the transaction
8
+ INTO the buyer's existing authorization, then
9
+ resubmit as a new check carrying
10
+ previous_record_id, within max_attempts.
11
+ REVIEW / unknown -> stop and escalate to the buyer. Never guess,
12
+ action never widen the authorization.
13
+
14
+ API contract (https://api.provecart.com, X-API-Key header):
15
+ POST /mandates -> signed cart mandate (use its "id")
16
+ POST /verify -> cart verdict
17
+ POST /booking-mandates -> signed booking mandate
18
+ POST /verify-booking -> booking verdict
19
+ POST /reservation-mandates -> signed reservation mandate
20
+ POST /verify-reservation -> reservation verdict
21
+
22
+ Every verify response carries {verdict, corrections[], max_attempts,
23
+ record_id, verdict_token, ...}. A corrected resubmission is a new
24
+ verify call against the same mandate with previous_record_id set to
25
+ the earlier check's record_id.
26
+ """
27
+ import json
28
+ import urllib.error
29
+ import urllib.request
30
+
31
+ KNOWN_ACTIONS = {
32
+ "remove_item", "replace_item", "change_variant", "reduce_quantity",
33
+ "reduce_total", "change_currency", "correct_traveler_name",
34
+ "change_airport", "change_travel_date", "change_departure_time",
35
+ "add_itinerary", "rebuild_itinerary", "change_cabin",
36
+ "change_fare_terms", "add_hotel_stay", "change_room",
37
+ "change_provider", "change_location", "change_date", "change_time",
38
+ "change_party_size", "correct_reservation_name", "reduce_deposit",
39
+ "change_cancellation_terms",
40
+ }
41
+
42
+ DEFAULT_BASE_URL = "https://api.provecart.com"
43
+
44
+
45
+ class ProveCartError(RuntimeError):
46
+ """Raised when the ProveCart API returns a non-200 status."""
47
+
48
+
49
+ class ProveCartClient:
50
+ """Client for the ProveCart verification API.
51
+
52
+ Parameters:
53
+ api_key: integrator API key, sent as the X-API-Key header.
54
+ base_url: API base URL (default https://api.provecart.com).
55
+ transport: optional callable ``(path, body) -> (status, dict)``
56
+ replacing the HTTP layer. Intended for offline tests and
57
+ rehearsal runs; when supplied, no network call is made.
58
+ timeout: HTTP timeout in seconds for the default transport.
59
+ """
60
+
61
+ def __init__(self, api_key, base_url=DEFAULT_BASE_URL,
62
+ transport=None, timeout=20):
63
+ self.api_key = api_key
64
+ self.base = base_url.rstrip("/")
65
+ self._transport = transport
66
+ self.timeout = timeout
67
+
68
+ # -- transport ---------------------------------------------------
69
+ def _post(self, path, body, extra_headers=None):
70
+ if self._transport is not None:
71
+ return self._transport(path, body)
72
+ headers = {"Content-Type": "application/json",
73
+ "X-API-Key": self.api_key}
74
+ if extra_headers:
75
+ headers.update(extra_headers)
76
+ req = urllib.request.Request(
77
+ self.base + path,
78
+ data=json.dumps(body).encode(),
79
+ method="POST",
80
+ headers=headers)
81
+ try:
82
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
83
+ return resp.status, json.loads(resp.read())
84
+ except urllib.error.HTTPError as e:
85
+ try:
86
+ return e.code, json.loads(e.read() or b"{}")
87
+ except Exception:
88
+ return e.code, {}
89
+
90
+ def _post_ok(self, path, body, what, idempotency_key=None):
91
+ extra = ({"Idempotency-Key": idempotency_key}
92
+ if idempotency_key else None)
93
+ status, out = self._post(path, body, extra_headers=extra) \
94
+ if extra else self._post(path, body)
95
+ if status != 200:
96
+ raise ProveCartError(f"{what} failed: {status} {out}")
97
+ return out
98
+
99
+ # -- mandate creation ---------------------------------------------
100
+ def create_mandate(self, **fields):
101
+ """Create a signed cart mandate: POST /mandates.
102
+
103
+ Required fields include user_id, item_description, category,
104
+ max_total_price and expires_at; item_keywords, currency,
105
+ max_quantity, merchant_allowlist/blocklist, brand and
106
+ variant_constraints are also accepted. Returns the signed
107
+ mandate dict; use its "id" as mandate_id.
108
+ """
109
+ return self._post_ok("/mandates", fields, "mandate creation")
110
+
111
+ # Backwards-compatible alias: the cart mandate is the original kind.
112
+ create_cart_mandate = create_mandate
113
+
114
+ def create_booking_mandate(self, **fields):
115
+ """Create a signed booking mandate: POST /booking-mandates.
116
+
117
+ Required fields include user_id, travelers, origin,
118
+ destination, departure_date, max_total_price and expires_at.
119
+ Returns the signed mandate dict; use its "id".
120
+ """
121
+ return self._post_ok("/booking-mandates", fields,
122
+ "booking mandate creation")
123
+
124
+ def create_reservation_mandate(self, **fields):
125
+ """Create a signed reservation mandate:
126
+ POST /reservation-mandates.
127
+
128
+ Required fields include user_id, service_description, date,
129
+ time, party_size, name_on_reservation, max_total_price and
130
+ expires_at. Returns the signed mandate dict; use its "id".
131
+ """
132
+ return self._post_ok("/reservation-mandates", fields,
133
+ "reservation mandate creation")
134
+
135
+ # -- verification ---------------------------------------------------
136
+ def verify_cart(self, mandate_id, merchant, items, total_price,
137
+ currency="USD", tax=0.0, shipping=0.0, fees=0.0,
138
+ previous_record_id=None, cart_changed=False,
139
+ idempotency_key=None):
140
+ """Verify a cart: POST /verify. Returns the verdict dict."""
141
+ body = {"mandate_id": mandate_id, "merchant": merchant,
142
+ "items": items, "total_price": total_price,
143
+ "currency": currency, "tax": tax, "shipping": shipping,
144
+ "fees": fees, "cart_changed": cart_changed}
145
+ if previous_record_id:
146
+ body["previous_record_id"] = previous_record_id
147
+ return self._post_ok("/verify", body, "verify",
148
+ idempotency_key=idempotency_key)
149
+
150
+ def verify_booking(self, mandate_id, merchant, price,
151
+ travelers=None, legs=None, stays=None, fare=None,
152
+ currency="USD", previous_record_id=None,
153
+ booking_changed=False, idempotency_key=None):
154
+ """Verify a booking: POST /verify-booking.
155
+
156
+ ``price`` is a dict with at least {"total": ...}; ``legs``
157
+ items carry from_airport, to_airport, depart_at, arrive_at;
158
+ ``travelers`` is a list of traveler names. Returns the
159
+ verdict dict.
160
+ """
161
+ body = {"mandate_id": mandate_id, "merchant": merchant,
162
+ "price": price, "currency": currency,
163
+ "booking_changed": booking_changed}
164
+ if travelers is not None:
165
+ body["travelers"] = travelers
166
+ if legs is not None:
167
+ body["legs"] = legs
168
+ if stays is not None:
169
+ body["stays"] = stays
170
+ if fare is not None:
171
+ body["fare"] = fare
172
+ if previous_record_id:
173
+ body["previous_record_id"] = previous_record_id
174
+ return self._post_ok("/verify-booking", body, "verify-booking",
175
+ idempotency_key=idempotency_key)
176
+
177
+ def verify_reservation(self, mandate_id, merchant, date, time,
178
+ party_size, name_on_reservation, total_amount,
179
+ provider_name=None, location=None,
180
+ deposit_amount=0.0, currency="USD",
181
+ cancellation=None, previous_record_id=None,
182
+ reservation_changed=False,
183
+ idempotency_key=None):
184
+ """Verify a reservation: POST /verify-reservation.
185
+ Returns the verdict dict."""
186
+ body = {"mandate_id": mandate_id, "merchant": merchant,
187
+ "date": date, "time": time, "party_size": party_size,
188
+ "name_on_reservation": name_on_reservation,
189
+ "deposit_amount": deposit_amount,
190
+ "total_amount": total_amount, "currency": currency,
191
+ "reservation_changed": reservation_changed}
192
+ if provider_name is not None:
193
+ body["provider_name"] = provider_name
194
+ if location is not None:
195
+ body["location"] = location
196
+ if cancellation is not None:
197
+ body["cancellation"] = cancellation
198
+ if previous_record_id:
199
+ body["previous_record_id"] = previous_record_id
200
+ return self._post_ok("/verify-reservation", body,
201
+ "verify-reservation",
202
+ idempotency_key=idempotency_key)
203
+
204
+ # -- one-call checks: create-or-take a mandate, then verify --------
205
+ def _resolve_mandate_id(self, mandate_id, mandate, creator):
206
+ if mandate is not None:
207
+ created = creator(**mandate)
208
+ return created["id"]
209
+ if not mandate_id:
210
+ raise ValueError(
211
+ "pass mandate_id= or mandate={...} so the check has a "
212
+ "signed instruction to compare against")
213
+ return mandate_id
214
+
215
+ def check_cart(self, merchant, items, total_price, *,
216
+ mandate_id=None, mandate=None, **verify_kwargs):
217
+ """Check a cart, creating the mandate first when ``mandate``
218
+ (a dict of mandate fields) is given instead of ``mandate_id``.
219
+ Returns the verdict response dict."""
220
+ mid = self._resolve_mandate_id(mandate_id, mandate,
221
+ self.create_mandate)
222
+ return self.verify_cart(mid, merchant, items, total_price,
223
+ **verify_kwargs)
224
+
225
+ def check_booking(self, merchant, price, *, mandate_id=None,
226
+ mandate=None, **verify_kwargs):
227
+ """Check a booking, creating the booking mandate first when
228
+ ``mandate`` is given instead of ``mandate_id``.
229
+ Returns the verdict response dict."""
230
+ mid = self._resolve_mandate_id(mandate_id, mandate,
231
+ self.create_booking_mandate)
232
+ return self.verify_booking(mid, merchant, price, **verify_kwargs)
233
+
234
+ def check_reservation(self, merchant, date, time, party_size,
235
+ name_on_reservation, total_amount, *,
236
+ mandate_id=None, mandate=None, **verify_kwargs):
237
+ """Check a reservation, creating the reservation mandate
238
+ first when ``mandate`` is given instead of ``mandate_id``.
239
+ Returns the verdict response dict."""
240
+ mid = self._resolve_mandate_id(mandate_id, mandate,
241
+ self.create_reservation_mandate)
242
+ return self.verify_reservation(
243
+ mid, merchant, date, time, party_size, name_on_reservation,
244
+ total_amount, **verify_kwargs)
245
+
246
+
247
+ # Short alias.
248
+ Client = ProveCartClient
249
+
250
+
251
+ def route_verdict(response, attempt=1):
252
+ """Return (decision, detail): decision is 'proceed', 'fix' or
253
+ 'escalate'.
254
+
255
+ 'fix' is returned only when the verdict is BLOCK, every
256
+ correction is a known action, no correction is marked
257
+ non-correctable, and the retry budget (max_attempts) has not
258
+ been spent. A fix never widens the buyer's authorization: apply
259
+ only the listed corrections, then resubmit carrying the
260
+ response's record_id as previous_record_id. Anything else —
261
+ REVIEW, an unknown verdict, an unknown action — escalates to
262
+ the buyer.
263
+ """
264
+ verdict = response.get("verdict")
265
+ if verdict == "MATCH":
266
+ return "proceed", {"record_id": response.get("record_id")}
267
+ if verdict == "BLOCK":
268
+ corrections = response.get("corrections") or []
269
+ max_attempts = response.get("max_attempts")
270
+ budget_ok = max_attempts is None or attempt < max_attempts
271
+ usable = (
272
+ bool(corrections)
273
+ and all(c.get("action") in KNOWN_ACTIONS for c in corrections)
274
+ and all(c.get("correctable", True) for c in corrections)
275
+ )
276
+ if usable and budget_ok:
277
+ return "fix", {"corrections": corrections,
278
+ "record_id": response.get("record_id"),
279
+ "max_attempts": max_attempts}
280
+ reason = "BLOCK without usable corrections"
281
+ if usable and not budget_ok:
282
+ reason = "BLOCK retry budget (max_attempts) spent"
283
+ return "escalate", {"reason": reason,
284
+ "record_id": response.get("record_id")}
285
+ return "escalate", {"reason": f"verdict {verdict} needs the buyer",
286
+ "record_id": response.get("record_id")}
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: provecart
3
+ Version: 0.1.0
4
+ Summary: ProveCart client: check an AI agent's cart, booking, or reservation against the buyer's signed instructions before payment is captured.
5
+ License: MIT
6
+ Project-URL: Homepage, https://provecart.com
7
+ Project-URL: Documentation, https://provecart.com/agents
8
+ Project-URL: Correction format, https://provecart.com/standards/correction-format
9
+ Keywords: ai-agents,agent-commerce,verification,payments,provecart
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+
16
+ # provecart
17
+
18
+ Your agent put the wrong variant in the cart — the wrong size, the wrong model, a quantity nobody asked for — and your user paid for it. By the time anyone noticed, the order had shipped, and the return came out of somebody's pocket. That is the failure this package exists to stop: one check, between what the agent built and the payment, that compares the transaction against what the buyer actually said.
19
+
20
+ ProveCart is a neutral verification API for agent commerce. You record the buyer's instruction as a signed mandate, send what the agent actually built, and get back a signed verdict — `MATCH`, `REVIEW`, or `BLOCK` — before payment is captured (or, for bookings, before a ticket is issued). A `BLOCK` or `REVIEW` answer carries machine-readable corrections, so the agent can fix what it built and resubmit instead of losing the sale or guessing. Every check is written to a sealed evidence record. ProveCart never holds or moves money.
21
+
22
+ ```bash
23
+ pip install provecart
24
+ ```
25
+
26
+ Standard library only. No dependencies.
27
+
28
+ ## Check a cart
29
+
30
+ ```python
31
+ from provecart import ProveCartClient, route_verdict
32
+ client = ProveCartClient(api_key="YOUR_API_KEY")
33
+ verdict = client.check_cart(merchant={"name": "Example Shop", "domain": "shop.example.com"}, items=[{"name": "Wireless Headphones", "variant": "black", "quantity": 1, "unit_price": 249.99}], total_price=249.99, mandate={"user_id": "user-123", "item_description": "Wireless noise cancelling over-ear headphones", "item_keywords": ["wireless", "headphones"], "category": "electronics", "max_total_price": 300.00, "currency": "USD", "max_quantity": 1, "expires_at": "2027-01-01T00:00:00Z"})
34
+ decision, detail = route_verdict(verdict) # proceed / fix / escalate
35
+ ```
36
+
37
+ Already holding a mandate id? Pass `mandate_id=` instead of `mandate=` — `check_cart` takes either. The lower-level pair is also there when you want the steps separate: `create_mandate(**fields)` returns the signed mandate, and `verify_cart(mandate_id, merchant, items, total_price, ...)` runs the check.
38
+
39
+ ## Check a booking
40
+
41
+ ```python
42
+ from provecart import ProveCartClient, route_verdict
43
+ client = ProveCartClient(api_key="YOUR_API_KEY")
44
+ verdict = client.check_booking(merchant={"name": "Example Air", "domain": "air.example.com"}, price={"total": 412.00}, travelers=["Alex Rivera"], legs=[{"from_airport": "SFO", "to_airport": "JFK", "depart_at": "2027-03-10T08:00:00Z", "arrive_at": "2027-03-10T16:30:00Z"}], mandate={"user_id": "user-123", "travelers": [{"name": "Alex Rivera"}], "origin": {"airport": "SFO"}, "destination": {"airport": "JFK"}, "departure_date": "2027-03-10", "max_total_price": 500.00, "currency": "USD", "expires_at": "2027-03-01T00:00:00Z"})
45
+ decision, detail = route_verdict(verdict) # gate both capture and ticket issuance on MATCH
46
+ ```
47
+
48
+ The separate steps are `create_booking_mandate(**fields)` and `verify_booking(mandate_id, merchant, price, travelers=..., legs=..., stays=..., fare=...)`.
49
+
50
+ ## Check a reservation
51
+
52
+ ```python
53
+ from provecart import ProveCartClient, route_verdict
54
+ client = ProveCartClient(api_key="YOUR_API_KEY")
55
+ verdict = client.check_reservation(merchant={"name": "Example Table", "domain": "table.example.com"}, date="2027-03-10", time="19:00", party_size=2, name_on_reservation="Alex Rivera", total_amount=40.00, mandate={"user_id": "user-123", "service_description": "Dinner reservation for two", "date": "2027-03-10", "time": "19:00", "party_size": 2, "name_on_reservation": "Alex Rivera", "max_total_price": 60.00, "currency": "USD", "expires_at": "2027-03-09T00:00:00Z"})
56
+ decision, detail = route_verdict(verdict) # proceed / fix / escalate
57
+ ```
58
+
59
+ The separate steps are `create_reservation_mandate(**fields)` and `verify_reservation(mandate_id, merchant, date, time, party_size, name_on_reservation, total_amount, ...)`.
60
+
61
+ ## What to do with the verdict
62
+
63
+ `route_verdict` encodes the consumer rules of the Agent Correction Format, and they are deliberately strict:
64
+
65
+ - **MATCH → proceed.** Capture the payment (or issue the ticket).
66
+ - **BLOCK → fix, narrowly.** Fix only when every correction in the response is a known action that brings the transaction back inside the buyer's existing authorization. Apply exactly those corrections, then resubmit as a new check carrying the earlier check's `record_id` as `previous_record_id`, within the response's `max_attempts` budget.
67
+ - **REVIEW, unknown verdict, unknown action → escalate.** Stop and ask the buyer. Never guess, and never widen the buyer's authorization to make a verdict go away.
68
+
69
+ ```python
70
+ decision, detail = route_verdict(first_verdict)
71
+ if decision == "fix":
72
+ # ... apply detail["corrections"] to the transaction, nothing else ...
73
+ second = client.check_cart(..., mandate_id=same_id, previous_record_id=detail["record_id"])
74
+ ```
75
+
76
+ ## Offline and mock runs
77
+
78
+ The client takes an optional `transport` callable, `(path, body) -> (status, dict)`, that replaces the HTTP layer entirely. Point it at canned verdicts and the full mandate → check → route → resubmit loop runs with no network and no API key traffic — that is how this package's own test suite works, and it is the recommended way to rehearse an integration before wiring a real key:
79
+
80
+ ```python
81
+ def stub(path, body):
82
+ return 200, {"id": "m-1"} if "mandates" in path else {"verdict": "MATCH", "record_id": "r-1"}
83
+ client = ProveCartClient(api_key="test", transport=stub)
84
+ ```
85
+
86
+ Calls made with a sandbox key against the live API behave exactly as in production and stay sealed under that key, separate from live data.
87
+
88
+ ## Links
89
+
90
+ - Agents and certification: https://provecart.com/agents
91
+ - Correction format standard: https://provecart.com/standards/correction-format
92
+ - API reference: https://api.provecart.com/docs
93
+
94
+ ProveCart. Prove the cart. Patent pending.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/provecart/__init__.py
4
+ src/provecart/client.py
5
+ src/provecart.egg-info/PKG-INFO
6
+ src/provecart.egg-info/SOURCES.txt
7
+ src/provecart.egg-info/dependency_links.txt
8
+ src/provecart.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ provecart
@@ -0,0 +1,259 @@
1
+ """Offline unit tests for the provecart client.
2
+
3
+ A stub transport replaces the HTTP layer: no network, no live calls.
4
+ Covers MATCH / BLOCK-fix / REVIEW routing and previous_record_id
5
+ chaining for carts, bookings, and reservations.
6
+ """
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ from provecart import ProveCartClient, route_verdict # noqa: E402
13
+
14
+
15
+ class StubTransport:
16
+ """Records every (path, body) call and replays queued responses."""
17
+
18
+ def __init__(self, responses):
19
+ self.responses = list(responses)
20
+ self.calls = []
21
+
22
+ def __call__(self, path, body):
23
+ self.calls.append((path, dict(body)))
24
+ return self.responses.pop(0)
25
+
26
+
27
+ def mandate_ok(mid="m-1"):
28
+ return (200, {"id": mid, "signature": "sig"})
29
+
30
+
31
+ MERCHANT = {"name": "Example Shop", "domain": "shop.example.com"}
32
+ CART_MANDATE = {
33
+ "user_id": "user-123",
34
+ "item_description": "Wireless noise cancelling over-ear headphones",
35
+ "item_keywords": ["wireless", "headphones"],
36
+ "category": "electronics",
37
+ "max_total_price": 300.00,
38
+ "currency": "USD",
39
+ "max_quantity": 1,
40
+ "expires_at": "2027-01-01T00:00:00Z",
41
+ }
42
+ ITEMS = [{"name": "Wireless Headphones", "variant": "black",
43
+ "quantity": 1, "unit_price": 249.99}]
44
+
45
+
46
+ def test_match_routes_proceed():
47
+ stub = StubTransport([mandate_ok(),
48
+ (200, {"verdict": "MATCH", "record_id": "r-1"})])
49
+ client = ProveCartClient("test-key", transport=stub)
50
+ out = client.check_cart(MERCHANT, ITEMS, 249.99, mandate=CART_MANDATE)
51
+ assert out["verdict"] == "MATCH"
52
+ assert stub.calls[0][0] == "/mandates"
53
+ assert stub.calls[1][0] == "/verify"
54
+ assert stub.calls[1][1]["mandate_id"] == "m-1"
55
+ decision, detail = route_verdict(out)
56
+ assert decision == "proceed"
57
+ assert detail["record_id"] == "r-1"
58
+
59
+
60
+ def test_check_cart_with_existing_mandate_id_skips_creation():
61
+ stub = StubTransport([(200, {"verdict": "MATCH", "record_id": "r-9"})])
62
+ client = ProveCartClient("test-key", transport=stub)
63
+ out = client.check_cart(MERCHANT, ITEMS, 249.99, mandate_id="m-42")
64
+ assert out["verdict"] == "MATCH"
65
+ assert len(stub.calls) == 1
66
+ assert stub.calls[0][1]["mandate_id"] == "m-42"
67
+
68
+
69
+ def test_block_with_known_corrections_routes_fix():
70
+ resp = {"verdict": "BLOCK", "record_id": "r-2", "max_attempts": 3,
71
+ "corrections": [
72
+ {"action": "change_variant", "dimension": "variant",
73
+ "expected": "black", "actual": "white"},
74
+ {"action": "reduce_quantity", "dimension": "quantity",
75
+ "expected": 1, "actual": 2}]}
76
+ decision, detail = route_verdict(resp)
77
+ assert decision == "fix"
78
+ assert detail["record_id"] == "r-2"
79
+ assert detail["max_attempts"] == 3
80
+ assert detail["corrections"] == resp["corrections"]
81
+
82
+
83
+ def test_block_fix_then_resubmit_chains_previous_record_id():
84
+ blocked = (200, {"verdict": "BLOCK", "record_id": "r-2",
85
+ "max_attempts": 3,
86
+ "corrections": [{"action": "change_variant"}]})
87
+ matched = (200, {"verdict": "MATCH", "record_id": "r-3"})
88
+ stub = StubTransport([blocked, matched])
89
+ client = ProveCartClient("test-key", transport=stub)
90
+ first = client.verify_cart("m-1", MERCHANT, ITEMS, 249.99)
91
+ decision, detail = route_verdict(first)
92
+ assert decision == "fix"
93
+ fixed = [{"name": "Wireless Headphones", "variant": "black",
94
+ "quantity": 1, "unit_price": 249.99}]
95
+ second = client.verify_cart(
96
+ "m-1", MERCHANT, fixed, 249.99,
97
+ previous_record_id=detail["record_id"], cart_changed=True)
98
+ assert second["verdict"] == "MATCH"
99
+ assert stub.calls[1][1]["previous_record_id"] == "r-2"
100
+ assert stub.calls[1][1]["cart_changed"] is True
101
+ assert "previous_record_id" not in stub.calls[0][1]
102
+
103
+
104
+ def test_review_routes_escalate():
105
+ decision, detail = route_verdict(
106
+ {"verdict": "REVIEW", "record_id": "r-4"})
107
+ assert decision == "escalate"
108
+ assert detail["record_id"] == "r-4"
109
+
110
+
111
+ def test_unknown_verdict_routes_escalate():
112
+ decision, _ = route_verdict({"verdict": "SOMETHING_ELSE"})
113
+ assert decision == "escalate"
114
+ decision, _ = route_verdict({})
115
+ assert decision == "escalate"
116
+
117
+
118
+ def test_block_with_unknown_action_routes_escalate():
119
+ resp = {"verdict": "BLOCK", "record_id": "r-5", "max_attempts": 3,
120
+ "corrections": [{"action": "raise_the_budget"}]}
121
+ decision, _ = route_verdict(resp)
122
+ assert decision == "escalate"
123
+
124
+
125
+ def test_block_without_corrections_routes_escalate():
126
+ decision, _ = route_verdict(
127
+ {"verdict": "BLOCK", "record_id": "r-6", "corrections": []})
128
+ assert decision == "escalate"
129
+
130
+
131
+ def test_block_non_correctable_routes_escalate():
132
+ resp = {"verdict": "BLOCK", "record_id": "r-7", "max_attempts": 3,
133
+ "corrections": [{"action": "remove_item",
134
+ "correctable": False}]}
135
+ decision, _ = route_verdict(resp)
136
+ assert decision == "escalate"
137
+
138
+
139
+ def test_block_after_budget_spent_routes_escalate():
140
+ resp = {"verdict": "BLOCK", "record_id": "r-8", "max_attempts": 3,
141
+ "corrections": [{"action": "remove_item"}]}
142
+ decision, detail = route_verdict(resp, attempt=3)
143
+ assert decision == "escalate"
144
+ assert "max_attempts" in detail["reason"]
145
+
146
+
147
+ def test_booking_check_creates_mandate_and_verifies():
148
+ stub = StubTransport([mandate_ok("bm-1"),
149
+ (200, {"verdict": "MATCH", "record_id": "br-1"})])
150
+ client = ProveCartClient("test-key", transport=stub)
151
+ mandate = {
152
+ "user_id": "user-123",
153
+ "travelers": [{"name": "Alex Rivera"}],
154
+ "origin": {"airport": "SFO"},
155
+ "destination": {"airport": "JFK"},
156
+ "departure_date": "2027-03-10",
157
+ "max_total_price": 500.00,
158
+ "expires_at": "2027-03-01T00:00:00Z",
159
+ }
160
+ legs = [{"from_airport": "SFO", "to_airport": "JFK",
161
+ "depart_at": "2027-03-10T08:00:00Z",
162
+ "arrive_at": "2027-03-10T16:30:00Z"}]
163
+ out = client.check_booking(
164
+ MERCHANT, {"total": 412.00}, mandate=mandate,
165
+ travelers=["Alex Rivera"], legs=legs)
166
+ assert out["verdict"] == "MATCH"
167
+ assert stub.calls[0][0] == "/booking-mandates"
168
+ assert stub.calls[1][0] == "/verify-booking"
169
+ assert stub.calls[1][1]["mandate_id"] == "bm-1"
170
+ assert stub.calls[1][1]["price"] == {"total": 412.00}
171
+ decision, _ = route_verdict(out)
172
+ assert decision == "proceed"
173
+
174
+
175
+ def test_booking_resubmit_chains_previous_record_id():
176
+ stub = StubTransport([
177
+ (200, {"verdict": "BLOCK", "record_id": "br-2", "max_attempts": 3,
178
+ "corrections": [{"action": "change_airport"}]}),
179
+ (200, {"verdict": "MATCH", "record_id": "br-3"})])
180
+ client = ProveCartClient("test-key", transport=stub)
181
+ first = client.verify_booking("bm-1", MERCHANT, {"total": 412.00})
182
+ decision, detail = route_verdict(first)
183
+ assert decision == "fix"
184
+ client.verify_booking("bm-1", MERCHANT, {"total": 399.00},
185
+ previous_record_id=detail["record_id"],
186
+ booking_changed=True)
187
+ assert stub.calls[1][1]["previous_record_id"] == "br-2"
188
+ assert stub.calls[1][1]["booking_changed"] is True
189
+
190
+
191
+ def test_reservation_check_creates_mandate_and_verifies():
192
+ stub = StubTransport([mandate_ok("rm-1"),
193
+ (200, {"verdict": "MATCH", "record_id": "rr-1"})])
194
+ client = ProveCartClient("test-key", transport=stub)
195
+ mandate = {
196
+ "user_id": "user-123",
197
+ "service_description": "Dinner reservation for two",
198
+ "date": "2027-03-10", "time": "19:00", "party_size": 2,
199
+ "name_on_reservation": "Alex Rivera",
200
+ "max_total_price": 60.00,
201
+ "expires_at": "2027-03-09T00:00:00Z",
202
+ }
203
+ out = client.check_reservation(
204
+ MERCHANT, "2027-03-10", "19:00", 2, "Alex Rivera", 40.00,
205
+ mandate=mandate)
206
+ assert out["verdict"] == "MATCH"
207
+ assert stub.calls[0][0] == "/reservation-mandates"
208
+ assert stub.calls[1][0] == "/verify-reservation"
209
+ assert stub.calls[1][1]["mandate_id"] == "rm-1"
210
+ assert stub.calls[1][1]["total_amount"] == 40.00
211
+
212
+
213
+ def test_reservation_block_routes_fix_and_chains():
214
+ stub = StubTransport([
215
+ (200, {"verdict": "BLOCK", "record_id": "rr-2", "max_attempts": 3,
216
+ "corrections": [{"action": "change_party_size"}]}),
217
+ (200, {"verdict": "REVIEW", "record_id": "rr-3"})])
218
+ client = ProveCartClient("test-key", transport=stub)
219
+ first = client.verify_reservation(
220
+ "rm-1", MERCHANT, "2027-03-10", "19:00", 4, "Alex Rivera", 40.00)
221
+ decision, detail = route_verdict(first)
222
+ assert decision == "fix"
223
+ second = client.verify_reservation(
224
+ "rm-1", MERCHANT, "2027-03-10", "19:00", 2, "Alex Rivera", 40.00,
225
+ previous_record_id=detail["record_id"], reservation_changed=True)
226
+ assert stub.calls[1][1]["previous_record_id"] == "rr-2"
227
+ decision2, _ = route_verdict(second)
228
+ assert decision2 == "escalate"
229
+
230
+
231
+ def test_create_mandate_helpers_hit_the_three_endpoints():
232
+ stub = StubTransport([mandate_ok("a"), mandate_ok("b"),
233
+ mandate_ok("c")])
234
+ client = ProveCartClient("test-key", transport=stub)
235
+ assert client.create_mandate(**CART_MANDATE)["id"] == "a"
236
+ assert client.create_booking_mandate(user_id="u")["id"] == "b"
237
+ assert client.create_reservation_mandate(user_id="u")["id"] == "c"
238
+ paths = [c[0] for c in stub.calls]
239
+ assert paths == ["/mandates", "/booking-mandates",
240
+ "/reservation-mandates"]
241
+
242
+
243
+ def test_api_error_raises():
244
+ stub = StubTransport([(403, {"detail": "bad key"})])
245
+ client = ProveCartClient("test-key", transport=stub)
246
+ try:
247
+ client.create_mandate(**CART_MANDATE)
248
+ except RuntimeError:
249
+ return
250
+ raise AssertionError("expected RuntimeError on non-200")
251
+
252
+
253
+ def test_check_requires_mandate_or_id():
254
+ client = ProveCartClient("test-key", transport=StubTransport([]))
255
+ try:
256
+ client.check_cart(MERCHANT, ITEMS, 249.99)
257
+ except ValueError:
258
+ return
259
+ raise AssertionError("expected ValueError without a mandate")