portage-cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +107 -21
  4. data/known-stores/categories.yml +6841 -18
  5. data/known-stores/category-stoplist.yml +40 -0
  6. data/known-stores/category-synonyms.yml +15 -0
  7. data/lib/portage/cli/browser_import/categorize.rb +5 -2
  8. data/lib/portage/cli/buy.rb +217 -39
  9. data/lib/portage/cli/check.rb +11 -1
  10. data/lib/portage/cli/classifier/ranking.rb +135 -0
  11. data/lib/portage/cli/classifier/table.rb +63 -0
  12. data/lib/portage/cli/classifier.rb +23 -44
  13. data/lib/portage/cli/confidence_check.rb +27 -7
  14. data/lib/portage/cli/confidence_state.rb +109 -0
  15. data/lib/portage/cli/doctor.rb +16 -6
  16. data/lib/portage/cli/find.rb +69 -4
  17. data/lib/portage/cli/index/builder.rb +49 -12
  18. data/lib/portage/cli/index/database.rb +150 -0
  19. data/lib/portage/cli/index/entry_product.rb +37 -0
  20. data/lib/portage/cli/index/legacy_import.rb +54 -0
  21. data/lib/portage/cli/index/product_store.rb +73 -35
  22. data/lib/portage/cli/index/schema.rb +70 -0
  23. data/lib/portage/cli/index/search.rb +73 -0
  24. data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
  25. data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
  26. data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
  27. data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
  28. data/lib/portage/cli/index/sources.rb +5 -2
  29. data/lib/portage/cli/index/store.rb +23 -47
  30. data/lib/portage/cli/index.rb +1 -0
  31. data/lib/portage/cli/offer_sources.rb +17 -2
  32. data/lib/portage/cli/version.rb +1 -1
  33. data/lib/portage/cli.rb +111 -12
  34. metadata +30 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c506344f3b17a2ea4199f84efaf70d658b0debec2fe03d167b847c58617e800b
4
- data.tar.gz: 20c00c246e59fa9059611f7bb54380140295eada2c5deb457a11ec94309c1b04
3
+ metadata.gz: 4e0a17085649f21644b4e373afc590ab69b92cfdbf23c4b83fbf7ed33b1cc1c8
4
+ data.tar.gz: 75e4435e369e70f529af29ce9891208615d2f0d458df616dbf11a032f8276ed3
5
5
  SHA512:
6
- metadata.gz: 9a3eff64f57ab81774a2a52034232a4667227aaff13aa7279d26dd41cf52faeafeea691711755fb41fd2f3a0a67f77d144d248d48c2631779a49c10bec9f2882
7
- data.tar.gz: ade0a2ba64ba928ec1272a8902858bad1f79897f5059d428528b8978e4045047ee1a0e6e2579dc2f8a468fa73b0c3491cc66c48f2bc1a18951600e05bd2a25e1
6
+ metadata.gz: d79a48ad6cf39bad956cafbb2e9f3317485af02a14fa3305306a4185839ce3add20326025e9eb4cfa0e0e1709ecbade149b5b2e9109627fb9b3b6112f11e91ed
7
+ data.tar.gz: 5bbf4e713f496fef30925cb4f59de1ad929e1a51b960c804f6bb60bbce39c8dd0856d3efcd5641f175a25bebfd7868358d63a827d0e6123a3d5b9763a7d27ead
data/CHANGELOG.md CHANGED
@@ -6,6 +6,135 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-10-01
10
+
11
+ - **Release note: the confidence check wants `portage-ucp-decision` 0.1.2.** `portage-ucp-decision` stays an optional install, not a dependency, but 0.1.2 is the version that rejects a malformed backend answer (see its changelog); the README says so.
12
+
13
+ - **`find --store URL --query Q [--max-price N]` searches one store, live.** The index-search hint, the `buy` skill and the docs already told agents to re-check an index hit this way, but `find` had no `--store`. It now skips the search backends and retailer offer sources and probes and searches only that store's catalogue (read-only: never a cart or checkout), with offers, `offer_ref`, `search_id` and the history entry shaped like any find, so `pick` and `buy --offer` work on them. A hand-off-only host is never fetched and is reported as such; a store without UCP is reported as having no catalogue; a non-http(s) URL is a usage error. The index-search hint now reads `find --store URL --query ...`.
14
+
15
+ - **Security: a priced line nobody asked for is a checkout mismatch.** `buy` requests exactly one
16
+ line, but only checked that line, so a store that added an upsell, a "shipping protection"
17
+ add-on or a second copy of the item (or, on a WebMCP cart, whatever was already in the store's
18
+ cart) still went through. Any other line now stops a real run with `checkout_mismatch` ("Store
19
+ added ... to checkout, which wasn't requested."), and a dry run flags it with
20
+ `checkout_mismatch: true`. An extra line that costs nothing (its own total, or its unit price
21
+ times quantity, is 0) is allowed, so a free gift or $0 sample doesn't block a purchase; one
22
+ whose cost can't be read counts as priced.
23
+
24
+ - **Security: the confidence check sends an allowlisted summary, and compares against the
25
+ approved quote.** The state sent to the decision backend (`PORTAGE_DECISION_BACKEND`, e.g.
26
+ `jev`, TypeSafe's hosted API) used to be a slice of the raw checkout hash, so whatever a store
27
+ nested under `line_items` or `totals` went along with it. It is now built field by field by the
28
+ new `Portage::Cli::ConfidenceState`: the request (query, store host, quantity, picked item id
29
+ and title), on a `buy --quote` run the approved quote (store, product id, title, quantity,
30
+ total, currency), and the checkout's status, currency, per-line item id, title, unit price,
31
+ quantity, totals and whether it's the requested line, the totals (shipping, tax, fees included),
32
+ applied discounts' titles and amounts and the selected shipping option's title and price, plus
33
+ `warnings`. Never the payment token, the address, the buyer's name, phone or email, ids, links,
34
+ discount codes or environment values; store strings are cut to 200 characters. The question
35
+ now asks the model to say yes only when the checkout matches the request and the approved
36
+ quote. `Buy.new` takes `quote_store:` and `quote_title:`, which `buy --quote` passes from the
37
+ saved quote. The check stays additive: it only sees a checkout the mismatch check, the quote
38
+ cap and the spend policy let through, and can hold it but never let through one they stop.
39
+
40
+ - **Security: the non-preset WebMCP hand-off runs the confidence check too.** A page whose WebMCP
41
+ tools build a checkout (no preset, so `buy` ends in `express_stop` through `finish_checkout`)
42
+ applied the quote cap and the mismatch stop but not the opt-in confidence check. With a decision
43
+ backend enabled it now runs before the hand-off, after those two. A hold reports `low_confidence`
44
+ with the store's `/cart` page as `checkout_url`, as the preset flow's does, and nothing is handed
45
+ to the checkout (a `profile` target is not navigated there). A backend error holds the same way.
46
+ No backend enabled: unchanged.
47
+
48
+ - **Security: the WebMCP hand-off flow runs the quote cap and the confidence check.** Against a
49
+ page whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy`
50
+ now checks, before that tool runs and before autofill: a `--quote` run's cap (`quote_changed`,
51
+ never handed off; this flow never checked the quote before), the mismatch check, then, when a
52
+ decision backend is enabled, the confidence check. A hold reports `low_confidence` with the
53
+ store's `/cart` page as `checkout_url`; checkout isn't opened and nothing is autofilled. A
54
+ backend error holds the same way.
55
+
56
+ - **Security: a quote with no total refuses instead of buying uncapped.** `buy --quote` capped the
57
+ checkout at the quote's total only when the quote had one. A quote saved from a dry run with no
58
+ priced total (a WebMCP preset dry run never has one) set no cap at all, so its `--yes` run
59
+ bought at whatever the store asked. It now ends in `quote_changed`, saying the quote has no
60
+ total, and nothing is bought or handed off.
61
+
62
+ - **Security: a checkout mismatch always stops the purchase.** `buy` used to stop on a checkout
63
+ that didn't match the request (the item dropped, another quantity, another unit price) only under
64
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`. Without it the mismatch was a `warnings` entry and the
65
+ purchase went ahead, so a real `--yes` run could pay for the wrong checkout and report
66
+ `purchased` (reported by ClawHub's security audit of the `portage-buy` skill). Now every
67
+ mismatch on a run that isn't `--dry-run` escalates (`decisions.escalation.reason: "mismatch"`)
68
+ and ends in `checkout_mismatch` before the payment token, policy and completion are reached. The
69
+ quote it ran under is spent, so the person dry-runs again for a new one. There is no opt-out:
70
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored, whatever its value. A
71
+ `--dry-run` keeps its `dry_run` outcome and `warnings`, and now adds `checkout_mismatch: true`
72
+ and says in `message` that a real run would stop. The check also compares currency: a checkout
73
+ in another currency than the catalog's price is a mismatch. The quoted total and currency are
74
+ still `--quote`'s `quote_changed` check, unchanged.
75
+
76
+ - **Security: the WebMCP hand-off flow stops on a mismatched cart too.** Against a WebMCP page
77
+ whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy` read
78
+ the cart back and checked it, but only put a mismatch in `warnings`: it still called the hand-off
79
+ tool, sending the browser tab to the store's checkout, and ran autofill there when it was
80
+ approved. Now any mismatch stops the run before that tool, with outcome `checkout_mismatch` and
81
+ `decisions.escalation.reason: "mismatch"`. Nothing is autofilled, and `checkout_url` is the
82
+ store's `/cart` page, so the shopper can look at the cart that was built. A matching cart hands
83
+ off as `express_stop`, as before. The dry run of this flow builds no cart, so it can't check one
84
+ and never carries `checkout_mismatch: true`.
85
+
86
+ ## [0.11.0] - 2026-10-01
87
+
88
+ - **Category classification uses the whole taxonomy.** `known-stores/categories.yml` is now generated
89
+ by `script/categories` (stdlib only) from Google's product taxonomy, edition 2021-09-21, instead of
90
+ coming from a script that was never committed. It keeps the same ids and the same top-two-level keys
91
+ (so `~/.portage/categories.yml` overrides still work), but a level-2 node's `keywords` now include
92
+ its descendants' names ("Chandeliers" counts for Lighting), and its parent's words sit in a separate
93
+ `parent_keywords` list. The file grows from 20KB to 101KB. A shipped stoplist
94
+ (`known-stores/category-stoplist.yml`, every word with its reason) removes merchandising and url words
95
+ ("new", "collection", "sale", "gift", "accessories", "products", ...) from both the data and the input,
96
+ and a small synonyms file (`category-synonyms.yml`, each word tied to a golden case) adds the few
97
+ words the taxonomy lacks ("pendant", "sconce"). `Classifier.categories_for` keeps its signature (plus
98
+ an optional `stoplist_path:`) and returns at most three ids, best first. Scoring: a keyword counts
99
+ 2 and a `parent_keyword` 1, times how often the input repeats the word (1 + ln count) and, for the
100
+ cut, how rare it is. A word counts once per node, and an id scoring under half the strongest is
101
+ dropped. Ties go to the node whose own name the input covers most, then to one whose name has no
102
+ stoplisted word, then to the smaller node. On a 100-case golden set (Light Yard, JB Hi-Fi, shopper
103
+ queries, browser-import titles and urls) top-1 accuracy goes from 12% to 70%. "Pendant Light" is
104
+ Lighting, "New Collection" is no longer Toll Collection Devices, and 160 of Light Yard's 164 products
105
+ classify as Lighting (1 before). Products already in the index keep the category they were crawled
106
+ with: crawl the store again (`portage index build --sources storefront_products`) to re-classify them.
107
+ - **The local index is now a SQLite file.** `~/.portage/index/index.sqlite3` (mode 0600, WAL)
108
+ replaces `stores.json` and `products.json`, through the new `sqlite3` gem dependency (`~> 2.9`,
109
+ precompiled for macOS and Linux). An existing `stores.json`/`products.json` is imported once on
110
+ first use and renamed to `*.json.migrated`; it is never deleted. `Index::Store` and
111
+ `Index::ProductStore` keep their APIs, and `ProductStore#upsert_many` writes a batch in one
112
+ transaction. Index write failures now raise instead of being dropped. `portage doctor` reports the
113
+ database path, row counts and whether FTS5 is available. The known-stores cache and `index export`
114
+ output stay JSON.
115
+ - **Storefront catalogue crawl and `portage index search`.** A new opt-in index source,
116
+ `storefront_products`, reads a Shopify store's public `/products.json` into the local index. Run it
117
+ with `portage index add URL --crawl` for one store, or `portage index build --sources
118
+ storefront_products` for stores already indexed (25 a run, least recently crawled first). Each
119
+ product is mapped through the UCP `Product` shape and stored with its handle, URL, first image,
120
+ options and variant ids. Price and availability are dropped before anything is written. A crawl
121
+ reads at most 20 pages of 250 a store, 1s apart. It waits out one 429's `Retry-After` (capped at 60s)
122
+ and stops on a second, and it obeys `robots.txt` for every page URL. It never contacts a hand-off-only
123
+ host, and it skips a 404, a redirect or a non-JSON answer (a bot wall). What happened is kept on the
124
+ store entry as `crawl`. `portage index search QUERY [--category ID] [--store HOST] [--limit N]
125
+ [--json]` searches the index locally with SQLite FTS5, ranked by bm25, and sends no request. Without
126
+ FTS5 it falls back to an unranked text match and reports `engine: "like"`. `index show --products` now
127
+ pages (`--page N`, `--per-page N`, default 50). `portage check` suggests the `index add ... --crawl`
128
+ command for a Shopify or native-UCP store (`index_hint`) but never runs it. `find` and `buy` never
129
+ crawl.
130
+ - **Product cards for agents.** `find` offers (and `shopify_catalog` offers) gain a `product` field:
131
+ the store's UCP `Product` wire hash as served, with `media` cut to the first image, so an agent or
132
+ UI can draw a card without another request. The flat fields (`title`, `amount`, `currency`, `url`,
133
+ `product_id`, `store`) are unchanged, the retailer API sources leave `product` out, and `history`
134
+ does not save it. `portage index search` results are marked `live: false` and each hit gains
135
+ `product`, built from the fields the index keeps (title, handle, URL, first image, options, variant
136
+ ids, category), never a price. Text output says the hits are not live.
137
+
9
138
  ## [0.10.0] - 2026-09-29
10
139
 
11
140
  - **`portage check <url> [--json]`.** Reports whether Portage can buy from a store and
data/README.md CHANGED
@@ -29,6 +29,20 @@ portage find --query "burton snowboard" --max-price 400
29
29
 
30
30
  `portage buy` with no URL runs that search and then buys the offer you pick.
31
31
 
32
+ Already know the store? `portage find --store URL --query "..."` skips the search
33
+ backends and offer sources and searches only that store's catalogue, live and
34
+ read-only (catalogue search only, never a cart or checkout). Use it to re-check
35
+ an index hit or an earlier offer before quoting its price or stock. The report
36
+ has the same shape as a normal find (`offer_ref`, `search_id`, a history entry),
37
+ so `pick` and `buy --offer` work on its offers. A hand-off-only host is never
38
+ fetched and is reported as such; a store that doesn't speak UCP is reported as
39
+ having no catalogue, with exit code 1 (as for any find with no offers). `--store`
40
+ must be an http(s) URL (a bare host is read as https).
41
+
42
+ ```bash
43
+ portage find --store https://shop.example --query "cold brew" --json
44
+ ```
45
+
32
46
  Already have the item and want to know where else it's sold? `portage compare`
33
47
  resolves a product you name by URL + product id, then runs the same
34
48
  find pipeline against its title and ranks the results by how confident the
@@ -131,6 +145,7 @@ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
131
145
  portage buy --quote QUOTE_ID --yes [--json] ...
132
146
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
133
147
  portage find --query "..." [--max-price N] [--limit N] [--json]
148
+ portage find --store URL --query "..." [--max-price N] [--json]
134
149
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
135
150
  [--max-price N] [--json]
136
151
  portage check <url> [--json]
@@ -155,8 +170,9 @@ portage policy set [--per-transaction-cap N --currency CUR]
155
170
  portage orders reconcile [--checkout ID] [--json]
156
171
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
157
172
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
158
- portage index show [--stores|--products] [--json]
159
- portage index add <url> [--json]
173
+ portage index show [--stores|--products [--page N] [--per-page N]] [--json]
174
+ portage index search QUERY [--category ID] [--store HOST] [--limit N] [--json]
175
+ portage index add <url> [--crawl] [--json]
160
176
  portage index remove <host> [--json]
161
177
  portage index sources [--json]
162
178
  portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
@@ -478,13 +494,16 @@ portage index build --sources shopify_catalog,stores_file
478
494
  portage index build --queries queries.txt # one query per line, instead of the built-in taxonomy sweep
479
495
  portage index refresh # re-verify entries older than 7 days, add new ones
480
496
  portage index show --stores --json
481
- portage index show --products --json
497
+ portage index show --products --page 2 --json # 50 a page; --per-page N
498
+ portage index search "wall light" --store some-shop.example --json
482
499
  portage index add https://some-shop.example
500
+ portage index add https://some-shop.example --crawl # also read its /products.json catalogue
501
+ portage index build --sources storefront_products # crawl the catalogues of stores already indexed
483
502
  portage index remove some-shop.example
484
503
  portage index sources # name, what each fetches, source file path
485
504
  ```
486
505
 
487
- Stored at `~/.portage/index/{stores,products}.json`, **never in git** and
506
+ Stored in `~/.portage/index/index.sqlite3` (mode 0600), **never in git** and
488
507
  **never containing a price or stock field** — those are always fetched live.
489
508
  Each new origin gets exactly one `/.well-known/ucp` probe (capped at 500 new
490
509
  probes per run), throttled, with progress output. Sources:
@@ -496,6 +515,18 @@ probes per run), throttled, with progress output. Sources:
496
515
  | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | on, but yields nothing unless you've run `browser import` |
497
516
  | `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
498
517
  | `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
518
+ | `storefront_products` | Each indexed Shopify store's own `/products.json`: title, brand, handle, URL, first image, options and variant ids, mapped through the UCP `Product` shape with price and availability dropped | opt-in (`--sources storefront_products`, or `index add URL --crawl`) |
519
+
520
+ **Catalogue crawls are polite and opt-in.** `storefront_products` never runs
521
+ from `find` or `buy` (`portage check` only prints the `index add ... --crawl`
522
+ command). It crawls at most 20 pages of 250 products a store and 25 stores a
523
+ run, least recently crawled first, 1s apart. It waits out one 429's
524
+ `Retry-After` (capped at 60s) and stops that store on a second. It obeys
525
+ `robots.txt` for every page URL, never contacts a hand-off-only host, and
526
+ skips a store that answers 404, a redirect or anything but products JSON (a
527
+ bot wall). What happened is kept on the store entry as `crawl`.
528
+ `portage index search` then searches those products locally (SQLite FTS5,
529
+ or a plain text match if FTS5 is missing), with no request.
499
530
 
500
531
  **The index is untrusted data, on the same footing as any other `find`
501
532
  candidate.** It never feeds `Policy#merchant_allowlist` and never counts as
@@ -795,6 +826,30 @@ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
795
826
  product search: nothing is added to the store's cart, the tab isn't sent to
796
827
  checkout and nothing is autofilled. The `dry_run` report carries a `would:`
797
828
  key with the line item, the hand-off tool and whether autofill would run.
829
+ Because it builds no cart, it can't check one, so it never carries
830
+ `checkout_mismatch: true`.
831
+
832
+ A real run against that kind of page reads the cart back after adding to it
833
+ and checks it the same way `buy` checks any checkout (item, quantity, unit
834
+ price, currency, extra lines). Any mismatch stops the run there, with outcome
835
+ `checkout_mismatch` and `decisions.escalation.reason: "mismatch"`: the
836
+ hand-off tool isn't called, so the tab never goes to checkout, and nothing is
837
+ autofilled. The cart is already on the store, so `checkout_url` is the
838
+ store's `/cart` page, for the shopper to look at. Items already sitting in
839
+ the store's cart count as extra lines, so they stop the run too.
840
+
841
+ The same run then applies the other gates a checkout gets before anything
842
+ leaves the cart: a `--quote` run's cap (`quote_changed`), and, when a
843
+ decision backend is enabled, the confidence check (see "Decisions"). A hold
844
+ from the confidence check reports `low_confidence` with the `/cart` page as
845
+ `checkout_url`; again the tab isn't sent to checkout and nothing is
846
+ autofilled.
847
+
848
+ A page whose WebMCP tools build a checkout (no preset) gets the same confidence
849
+ check before its `express_stop` hand-off, after the quote cap and the mismatch
850
+ stop. A hold reports `low_confidence` with the `/cart` page as `checkout_url`
851
+ and no navigation to the checkout, so a `profile` hand-off target is never sent
852
+ there. With no decision backend enabled, nothing changes.
798
853
 
799
854
  Once the flow hands off to the store's own checkout page, the shopper can
800
855
  opt into having it pre-filled: `--autofill`, or
@@ -826,7 +881,9 @@ gem install portage-ucp-decision # only needed for the confidence gate
826
881
  ```
827
882
 
828
883
  The confidence gate is the one feature that needs the gem, because the
829
- model backends live there.
884
+ model backends live there. Use `portage-ucp-decision` 0.1.2 or newer: it
885
+ rejects a backend answer that isn't a probability between 0 and 1, where
886
+ 0.1.1 let an answer above 1 clear any threshold.
830
887
 
831
888
  Every `portage buy` report carries an `outcome`, so a script or agent loop
832
889
  can branch on data rather than on the message text. The text output leads
@@ -836,12 +893,12 @@ with the same value, as `[outcome]`.
836
893
  | --- | --- | --- |
837
894
  | `purchased` | Completed. | no |
838
895
  | `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
839
- | `dry_run` | Checkout created, `--dry-run` stopped it. | no |
896
+ | `dry_run` | Checkout created, `--dry-run` stopped it. `checkout_mismatch: true` when a real run would stop on a mismatch. | no |
840
897
  | `requires_escalation` | The store wants the shopper to finish. | yes |
841
- | `checkout_mismatch` | Checkout differs from the request (`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`). | yes |
898
+ | `checkout_mismatch` | Checkout differs from the request (item, quantity, unit price, currency, or a priced line nobody asked for); stopped before payment. | yes |
842
899
  | `no_payment_token` | No `--payment-token` and no default payment method. | yes |
843
900
  | `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
844
- | `low_confidence` | The confidence gate held it. | yes |
901
+ | `low_confidence` | The confidence gate held it (before a `--yes` completion, or before a WebMCP hand-off to checkout, preset or not). | yes |
845
902
  | `permission_denied` | The store doesn't let this agent complete checkout. | yes |
846
903
  | `handoff_only` | Tier C: Amazon or another hand-off-only host. `legal_notice` explains why; see "Hand-off targets and hand-off-only hosts". | yes (built, never fetched) |
847
904
  | `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
@@ -868,9 +925,15 @@ Every verdict has a `reason`: `null` when the gate let the purchase through,
868
925
  otherwise a string naming why it stopped it.
869
926
 
870
927
  - **escalation** — `Support::Escalation`. A `requires_escalation` checkout
871
- always escalates (`reason: "requires_escalation"`). A checkout that doesn't
872
- match the request escalates (`reason: "mismatch"`) only under
873
- `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`; otherwise it's reported as `warnings`.
928
+ always escalates (`reason: "requires_escalation"`). So does a checkout that
929
+ doesn't match the request (`reason: "mismatch"`), on every run but
930
+ `--dry-run`, where the mismatch is reported in `warnings` and flagged with
931
+ `checkout_mismatch: true` instead. Nothing turns this off:
932
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored. A mismatch
933
+ is the requested line missing, a different quantity, a different unit
934
+ price or currency than the store's own catalog, or any extra line the
935
+ request didn't include. An extra line that costs nothing (a free gift, a
936
+ $0 sample) is allowed; one whose cost can't be read counts as priced.
874
937
  - **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
875
938
  before any `--yes` completion. `reason` is the guard's own
876
939
  (`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
@@ -885,16 +948,39 @@ otherwise a string naming why it stopped it.
885
948
  when the store answers that it's purchased.
886
949
  - **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
887
950
  `PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
888
- completion it asks the backend whether the checkout matches the request
889
- and is safe to complete unattended. It sends the query, merchant,
890
- quantity, line items, totals and warnings, never the payment token. The
891
- gate fails closed, so three things hold the purchase: a score below the
892
- threshold (`reason: "below_threshold"`), a backend that can't answer
893
- (`"backend_error"`), and naming a backend without `portage-ucp-decision`
894
- installed (`"not_installed"`). For the last two, `error` says what went
895
- wrong. `jev` needs `JEV_API_KEY`; `laya` needs `LAYA_BRIDGE_SCRIPT` (see
896
- `portage-ucp-decision`'s README). `portage doctor` flags whichever of
897
- these is missing for the selected backend.
951
+ completion, and before a WebMCP preset flow sends the browser to the
952
+ store's checkout (and autofills it), it asks the backend whether the
953
+ checkout matches the request, and the approved quote on a `--quote` run,
954
+ and is safe to complete unattended. It is additive only: it runs after
955
+ the mismatch check, the quote cap and the spend policy, on a checkout all
956
+ of them let through, so it can hold a purchase but never let through one
957
+ they would stop. The gate fails closed, so three things hold the
958
+ purchase: a score below the threshold (`reason: "below_threshold"`), a
959
+ backend that can't answer or answers with something that isn't a
960
+ probability (`"backend_error"`), and naming a backend without
961
+ `portage-ucp-decision` installed (`"not_installed"`). For the last two,
962
+ `error` says what went wrong. `jev` needs `JEV_API_KEY`; `laya` needs
963
+ `LAYA_BRIDGE_SCRIPT` (see `portage-ucp-decision`'s README). `portage
964
+ doctor` flags whichever of these is missing for the selected backend.
965
+
966
+ `jev` is TypeSafe's hosted API, so what it's sent is kept to a minimal,
967
+ allowlisted summary (`Portage::Cli::ConfidenceState`); nothing else on
968
+ the checkout is read:
969
+
970
+ - `request`: the search query, the store's host, the quantity, and the
971
+ picked item's id and title.
972
+ - `approved_quote` (`--quote` runs only): the quote's store, product id,
973
+ title, quantity, total and currency.
974
+ - `checkout`: status, currency; per line, item id, title, unit price,
975
+ quantity, line totals and whether it's the requested line; the totals
976
+ (subtotal, shipping, tax, fees, total — whatever the store lists);
977
+ applied discounts' titles and amounts; the selected shipping option's
978
+ title and price.
979
+ - `warnings`.
980
+
981
+ Never sent: the payment token, the shipping address, the buyer's name,
982
+ phone or email, checkout ids, links and URLs, discount codes, or anything
983
+ from your environment. Store-supplied strings are cut to 200 characters.
898
984
  - **policy** also denies a checkout with no `total` line as
899
985
  `total_unknown` whenever a spend cap is set, rather than skipping the cap.
900
986