> ## Documentation Index
> Fetch the complete documentation index at: https://docs.highailabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Dispensaries & Shopping Agent — Behavior Contract

> Every behavior the saved-shops and menu-scanning features must have, and the test that proves each one — shop identity, order history, the consent boundary, scan results, personalized picks, and the Safari extension.

## What this feature is

**Dispensaries** is the user's list of the shops they buy from. A user can save a
shop by name — with address, phone, website, email, receipt domains and up to five
menu URLs — favorite it, edit it, delete it, and open a detail screen showing what
they have spent there and every order they have placed there. Shops also appear
without being typed: saving an order or importing a Gmail receipt creates the shop
automatically.

**The Shopping Agent** is the Pro feature that reads a saved shop's live menu. From
a shop with menu URLs, the user confirms a scan on an explicit consent screen,
watches its progress, and lands on a results screen where every product is either
matched to a strain in the catalog or offered for research. On top of that menu, a
per-shopper pass produces **personalized picks** from the user's own favorites,
dislikes and stash. The same matching engine backs the Safari extension, which
highlights products on real dispensary sites.

## How to read the contract

Every case ID below is a live test. Cases built during the feature-test programme
carry their ID in the test title, so `grep 'DSH-088'` finds the assertion. Rows
whose coverage predates the programme are cited by ID and file; the test is found by
its plain-English name in that file. A few behaviors landed in wave 3 under a defect
id rather than a case id — those cite `BUG-nnn`, which is likewise the literal
prefix of the test title.

## The behavior contract

### Saving a shop

| Behavior                                                                                                                                                                                                               | Verified by                        | Tier(s) | Where (file basename)                                                                              |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------- | -------------------------------------------------------------------------------------------------- |
| A shop can be saved with nothing but a name, and every one of its seventeen fields is stored verbatim when supplied.                                                                                                   | DSH-001, DSH-002                   | A       | `dispensaries.test.ts`, `dispensaries.featuretests.test.ts`                                        |
| Only the name is required in the add form, and a field left blank is sent as absent rather than as an empty string.                                                                                                    | DSH-056                            | B       | `addDispensary.helpers.test.ts`                                                                    |
| The arguments the add form builds are accepted by the deployed validator exactly as built — the form and the backend cannot drift apart.                                                                               | DSH-057                            | A       | `dispensaries.featuretests.test.ts`                                                                |
| Invalid input is refused with the specific message the user is shown and nothing is written, and the form itself offers only backend-valid states, caps menu URLs at five, and rejects a malformed URL before sending. | DSH-005, DSH-006, DSH-061, DSH-062 | A, B    | `dispensaries.test.ts`, `dispensaries.featuretests.test.ts`, `dispensaryForm.featuretests.test.ts` |
| Every one of the twelve public dispensary functions rejects an unauthenticated call.                                                                                                                                   | DSH-007                            | A       | `dispensaries.featuretests.test.ts`                                                                |

### One shop, one identity

| Behavior                                                                                                                                                                                                         | Verified by      | Tier(s) | Where (file basename)               |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ----------------------------------- |
| Saving a name that already exists updates that shop instead of creating a second one, and case, surrounding whitespace and compatibility characters all collapse to a single shop.                               | DSH-003, DSH-004 | A       | `dispensaries.test.ts`              |
| Punctuation at the edges of a name collapses too — an OCR'd `"SUNNYSIDE."` finds the saved `Sunnyside` — while interior punctuation is preserved, so "Rise & Shine" and "Green Thumb Co-op" stay distinct shops. | BUG-143          | A       | `dispensaries.featuretests.test.ts` |
| Re-keying stored identities is a dry-run-by-default migration that reports collisions instead of merging them, and running it twice changes nothing.                                                             | BUG-143          | A       | `dispensaries.featuretests.test.ts` |
| The identity-finalization gate reports "ready" only for a completed scan over unambiguous rows, and carries a collision found on any page into the final verdict.                                                | DSH-040          | A       | `dispensaries.test.ts`              |

### Finding a saved shop

| Behavior                                                                                                                                                                                     | Verified by      | Tier(s) | Where (file basename)                                               |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ------------------------------------------------------------------- |
| The shop list contains only the signed-in user's shops and never an archived one, and one person can never read another person's shops, favorites, stats or search results.                  | DSH-008, DSH-011 | A       | `dispensaries.featuretests.test.ts`, `dispensaries.test.ts`         |
| Search matches name, city, address, website, email and saved receipt domains — not just the name — and a limit outside 1–100 is rejected rather than silently clamped.                       | DSH-010, DSH-012 | A       | `dispensaries.featuretests.test.ts`                                 |
| Search examines the user's whole shop list and stops at the requested number of **matches**, so a matching shop saved long ago is still findable.                                            | DSH-013          | A       | `dispensaries.featuretests.test.ts`                                 |
| Looking a shop up by exact name is scoped to the owner, returns nothing for a blank or unknown name, and deliberately still finds archived shops so a historical order can resolve its shop. | DSH-016, DSH-017 | A       | `dispensaries.featuretests.test.ts`                                 |
| The list renders loading, populated and genuinely-empty as three distinct states, and an empty result during the auth handshake is never shown as "no shops".                                | DSH-048, DSH-049 | B       | `Dispensaries.test.tsx`, `useDispensariesSubjectIsolation.test.tsx` |
| A search that matches nothing shows a no-results message, not the first-run empty state.                                                                                                     | DSH-050          | B       | `Dispensaries.test.tsx`                                             |
| Favoriting from the list applies immediately and reconciles with the server; a failed toggle reverts and tells the user.                                                                     | DSH-051, DSH-052 | B       | `Dispensaries.test.tsx`                                             |

### Editing, renaming and visiting

| Behavior                                                                                                                                                                                         | Verified by               | Tier(s) | Where (file basename)                                       |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | ------- | ----------------------------------------------------------- |
| Editing sends only the fields the user actually changed, and an edit with no changes sends no mutation at all.                                                                                   | DSH-058, DSH-059          | B       | `editDispensary.helpers.test.ts`, `Dispensaries.test.tsx`   |
| Clearing a field in the form clears it on the shop rather than leaving the old value behind.                                                                                                     | DSH-060                   | A       | `dispensaries.featuretests.test.ts`                         |
| Renaming a shop updates the shop name on every past order in bounded batches however many there are, and a second rename abandons the first so orders never carry a name the shop no longer has. | DSH-021, DSH-022, DSH-023 | A       | `dispensaries.test.ts`, `dispensaries.featuretests.test.ts` |
| Renaming a shop onto another saved shop's name is refused.                                                                                                                                       | DSH-024                   | A       | `dispensaries.test.ts`                                      |
| Editing or favoriting an archived shop brings it back to the list.                                                                                                                               | DSH-025, DSH-026          | A       | `dispensaries.test.ts`                                      |
| One person can never edit, favorite, delete or record a visit on another person's shop, and recording a visit increments the count and stamps the time.                                          | DSH-009, DSH-032          | A       | `dispensaries.featuretests.test.ts`, `dispensaries.test.ts` |

### Deleting a shop

| Behavior                                                                                                                                                                                  | Verified by               | Tier(s) | Where (file basename)                                       |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ----------------------------------------------------------- |
| Deleting a shop with no orders removes it entirely; deleting one with orders archives it so the order history keeps its shop — and that holds for a shop with thousands of linked orders. | DSH-027, DSH-028, DSH-031 | A       | `dispensaries.test.ts`, `dispensaries.featuretests.test.ts` |
| Deleting also un-favorites, so an archived shop cannot reappear in the favorites strip.                                                                                                   | DSH-030                   | A       | `dispensaries.featuretests.test.ts`                         |
| Re-adding a shop that was archived restores the original row and re-syncs the order names onto it.                                                                                        | DSH-029                   | A       | `dispensaries.test.ts`                                      |
| The delete confirmation says what will actually happen, and double-tapping it deletes once.                                                                                               | DSH-053, DSH-054          | B       | `Dispensaries.test.tsx`                                     |
| A shop that disappears underneath the detail screen bounces the user out, but the user's own delete does not bounce them mid-animation.                                                   | DSH-065                   | B       | `Dispensaries.test.tsx`                                     |
| Deleting the account purges every saved shop and every stored scan result.                                                                                                                | DSH-046                   | A       | `dispensaries.featuretests.test.ts`                         |

### Shop detail, spend and order history

| Behavior                                                                                                                                                               | Verified by               | Tier(s) | Where (file basename)                                             |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ----------------------------------------------------------------- |
| A shop with no orders reports zeroed stats rather than failing, and money totals are rounded to cents.                                                                 | DSH-018, DSH-019, DSH-020 | A       | `dispensaries.featuretests.test.ts`, `dispensaries.test.ts`       |
| The order-history screen renders its five states distinctly — loading, empty, populated, loading-more and error.                                                       | DSH-067                   | B       | `Dispensaries.test.tsx`                                           |
| Paging the order history never duplicates a row across pages, asks for more only when more exists, and opening the full history twice in one tap-burst navigates once. | DSH-068, DSH-069          | B       | `dispensaryOrderHistory.helpers.test.ts`, `Dispensaries.test.tsx` |
| Directions open in the platform's own maps app, and the long-press menu offers the same actions on iOS and on the fallback sheet.                                      | DSH-055, DSH-066          | B       | `Dispensaries.test.tsx`, `dispensaryDetail.helpers.test.ts`       |

### Receipt domains

| Behavior                                                                                                                                                                | Verified by      | Tier(s) | Where (file basename)                                       |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ----------------------------------------------------------- |
| Receipt domains are normalized on save, replaceable and clearable, and a malformed domain is rejected on update as well as on create.                                   | DSH-038, DSH-039 | A       | `dispensaries.test.ts`, `dispensaries.featuretests.test.ts` |
| A suggested receipt domain is never silently attached — the user has to confirm it, and a confirmed-but-unmerged suggestion either merges on submit or raises an error. | DSH-063, DSH-064 | B       | `dispensaryForm.helpers.test.ts`                            |
| The crowdsourced domain list is cached and fails soft: a failed fetch leaves the form usable rather than blocking it.                                                   | DSH-070          | B       | `dispensaryForm.featuretests.test.ts`                       |
| The public domain list is served from the safe RPC and falls back to the seeded list when that RPC errors, rather than returning an error to the app.                   | DSH-149, DSH-150 | B       | `index.test.ts`                                             |

### Shops created for you

| Behavior                                                                                                                                                                  | Verified by                        | Tier(s) | Where (file basename)                           |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------- | ----------------------------------------------- |
| Saving an order with a shop name reuses the existing shop when one matches — including on a case-only difference — and revives an archived one instead of duplicating it. | DSH-033, DSH-034, DSH-041, DSH-042 | A       | `dispensaries.test.ts`, `orderPostSave.test.ts` |
| An over-long shop name coming from an order is rejected before anything is written.                                                                                       | DSH-043                            | A       | `dispensaries.featuretests.test.ts`             |
| Two order saves naming the same new shop at the same time produce one shop, not two.                                                                                      | DSH-044                            | A       | `dispensaries.featuretests.test.ts`             |
| A shop created from a Gmail receipt is created with the sender's domain already attached, so the next receipt from that sender matches it.                                | DSH-045                            | A       | `dispensaries.featuretests.test.ts`             |
| Auto-creation enriches only the fields the shop is missing — never overwriting what the user typed — and appends a new receipt domain deduped and capped at ten.          | DSH-035, DSH-036, DSH-037          | A       | `dispensaries.featuretests.test.ts`             |

### Starting a scan — the consent boundary

| Behavior                                                                                                                                                                                                                                  | Verified by                        | Tier(s) | Where (file basename)                                                                                                                  |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| The Shopping Agent entry screen separates shops that can be scanned from shops that still need menu URLs, and sends a user with no shops to Add Shop.                                                                                     | DSH-089, DSH-090                   | B       | `ShoppingAgent.test.tsx`                                                                                                               |
| **No paid work happens before the user explicitly confirms.** Opening the consent screen scans nothing.                                                                                                                                   | DSH-093                            | B       | `ShoppingAgentScanConsent.test.tsx`                                                                                                    |
| Confirmation is single-shot: it produces exactly one normalized request, and remounting the screen never replays it.                                                                                                                      | DSH-094, DSH-099                   | B       | `ShoppingAgentScanConsent.test.tsx`                                                                                                    |
| Cancelling from the consent screen authorizes nothing.                                                                                                                                                                                    | DSH-098                            | B       | `ShoppingAgentScanConsent.test.tsx`                                                                                                    |
| A guest or free account never even mounts the paid action.                                                                                                                                                                                | DSH-095                            | B       | `ShoppingAgentScanConsent.test.tsx`                                                                                                    |
| Menu URLs pointing at a different host than the shop, missing parameters, and more than five URLs are each refused with their own screen rather than scanned — and a URL containing a comma survives being passed between screens intact. | DSH-091, DSH-096, DSH-097, DSH-100 | B       | `ShoppingAgentScanConsent.test.tsx`, `scanConsent.test.ts`, `useShoppingAgent.shared.test.ts`, `useShoppingAgent.featuretests.test.ts` |
| All three screens in the scan lane — scanner, results and research — are gated behind the same Shopping Agent flag, so no sibling can be reached with the feature off.                                                                    | BUG-113                            | B       | `routeContracts.test.ts`                                                                                                               |

### Running a scan

| Behavior                                                                                                                                                                                                                                                                                 | Verified by                        | Tier(s) | Where (file basename)                                                              |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------- | ---------------------------------------------------------------------------------- |
| The scan endpoint rejects over-limit and mixed-host URL sets, refuses a caller who names a different user, and refuses the legacy client-supplied trigger switch.                                                                                                                        | DSH-130, DSH-131                   | B       | `shopping-security.test.ts`                                                        |
| A non-Pro caller is denied **before** any cache read, claim, meter or task trigger.                                                                                                                                                                                                      | DSH-132                            | B       | `shopping-security.test.ts`                                                        |
| A cache hit costs no claim, no meter, no token and no task run, and the task returns the cached menu without scraping anything.                                                                                                                                                          | DSH-133, DSH-153                   | B       | `shopping-security.test.ts`, `shopping-menu-scan.featuretests.test.ts`             |
| A second shopper arriving while an identical scan is in flight joins that run with a read-only token and starts no second run.                                                                                                                                                           | DSH-134, DSH-171                   | B, C    | `shopping-security.test.ts`, `dispensaries.integration.test.ts`                    |
| The task payload carries the server-verified subject and a host derived from the URL, never anything the caller supplied.                                                                                                                                                                | DSH-135                            | B       | `shopping-security.test.ts`                                                        |
| A contended claim waits for the winner and joins it, and one that never resolves returns a named pending error rather than hanging.                                                                                                                                                      | DSH-137, DSH-138                   | B       | `shopping-routes.featuretests.test.ts`                                             |
| An unavailable claim store fails with 503 before metering, and a rate-limited request, a trigger failure or a failed source-run registration all release the claim — a failed attempt never wedges the shop for the next shopper.                                                        | DSH-136, DSH-139, DSH-140, DSH-141 | B       | `shopping-security.test.ts`, `shopping-routes.featuretests.test.ts`                |
| **A finished run releases its lease and is never re-joined as if in progress**: only that run can clear it, an unknown key reports absent, a lease with no run id yet returns a retry, a lease already owned by a successor is left alone, and an unauthenticated completion is refused. | DSH-143, DSH-144                   | B       | `shopping-routes.featuretests.test.ts`                                             |
| The API and the scan task compute the same cache key from the same real code, and asking for a cached scan that does not exist answers 404 rather than a server error.                                                                                                                   | DSH-142, DSH-145                   | B       | `shopping-routes.featuretests.test.ts`                                             |
| The task accepts the five-URL ceiling, rejects internal-network and other SSRF-shaped URLs, passes only unique normalized URLs onward, and re-filters its sitemap fallback through the same guard.                                                                                       | DSH-151, DSH-152, DSH-157          | B       | `shopping-menu-scan.security.test.ts`, `shopping-menu-scan.featuretests.test.ts`   |
| The task emits every stage in order with progress that only moves forward, and the client's stage map covers every stage it emits.                                                                                                                                                       | DSH-101, DSH-102, DSH-155          | B       | `useShoppingAgent.featuretests.test.ts`, `shopping-menu-scan.featuretests.test.ts` |
| A saved scan writes every expected column, the completion callback posts the cache key the API can clear, and a transient failure reporting completion is retried rather than treated as terminal.                                                                                       | DSH-154, DSH-158, DSH-191          | B       | `shopping-menu-scan.featuretests.test.ts`                                          |
| **A page with no menu yields an empty menu, never an invented one** — zero products is reported as "no products", not as a failure and not as fabricated items.                                                                                                                          | DSH-156, DSH-172                   | B, C    | `shopping-menu-scan.featuretests.test.ts`, `dispensaries.integration.test.ts`      |
| A missing scraper key fails the run loudly instead of reporting "no products found", and a very long shop domain does not break the run's tagging.                                                                                                                                       | DSH-159, DSH-160                   | B       | `shopping-menu-scan.featuretests.test.ts`                                          |
| A progress stream that closes without a terminal event is reported as a connection error rather than left spinning and never silently reconnects, and a run past fifteen minutes is aborted and reported as an abort, not an error.                                                      | DSH-128, DSH-129                   | B       | `runProgressStream.featuretests.test.ts`                                           |

### Keeping and re-opening a scan's results

| Behavior                                                                                                                                                                                                                                 | Verified by                        | Tier(s) | Where (file basename)                                               |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------- | ------------------------------------------------------------------- |
| Results are saved before the user is navigated to them, and nothing private travels through the route.                                                                                                                                   | DSH-103                            | B       | `ShoppingResultRecovery.test.tsx`                                   |
| A failed save can be retried with identical arguments and never re-runs the scan, while the same attempt id carrying different bytes is refused rather than overwriting.                                                                 | DSH-071, DSH-072, DSH-104          | A, B    | `shoppingResults.test.ts`, `ShoppingResultRecovery.test.tsx`        |
| A result can only be stored against a server-created, owned, unexpired, single-use run — a client cannot forge one.                                                                                                                      | DSH-073, DSH-083, DSH-178          | A, C    | `shoppingResults.test.ts`, `dispensaries.integration.test.ts`       |
| Registering a source run is idempotent and rejects oversize or non-object bodies; the run is removed at its TTL, removal is idempotent, and a consumed run cannot be revived after expiry.                                               | DSH-084, DSH-085, DSH-086, DSH-087 | A       | `dispensaries.featuretests.test.ts`                                 |
| Generated attempt ids always satisfy the storage validator — a short random segment can never collide or be rejected.                                                                                                                    | DSH-190                            | A       | `dispensaries.featuretests.test.ts`                                 |
| Storage enforces its quotas without leaving partial rows: at most five active results, a byte ceiling, and a daily write quota whose rejection does not consume the source run.                                                          | DSH-074, DSH-075, DSH-177          | A, C    | `shoppingResults.test.ts`, `dispensaries.integration.test.ts`       |
| Exactly 2 MiB across six chunks is accepted and one byte more is rejected, astral Unicode survives a chunk boundary, out-of-order chunks still reconstruct, and every corruption mode reports a corrupt result rather than partial data. | DSH-077, DSH-078, DSH-079, DSH-080 | A       | `shoppingResults.test.ts`                                           |
| A result becomes unreadable at exactly four hours, and cleanup is idempotent.                                                                                                                                                            | DSH-081                            | A       | `shoppingResults.test.ts`                                           |
| One person can never read or delete another person's result, and owner identity is recorded on both the header and every chunk.                                                                                                          | DSH-076, DSH-082                   | A       | `shoppingResults.test.ts`                                           |
| The results screen renders its five recovery states distinctly, hides the previous owner's result the instant the signed-in user changes, and handles the expiry boundary and foregrounding after it.                                    | DSH-107, DSH-108, DSH-109          | B       | `ShoppingResultRecovery.test.tsx`, `shoppingResultRecovery.test.ts` |
| An oversize result, a quota-exceeded save, and a completed run that produced no output each leave an explained, escapable screen rather than a dead end.                                                                                 | DSH-105, DSH-106, DSH-127          | B       | `ShoppingAgent.test.tsx`                                            |

### Reading the menu

| Behavior                                                                                                                                                                                                                         | Verified by               | Tier(s) | Where (file basename)                                                                |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ------------------------------------------------------------------------------------ |
| A real pipeline payload round-trips through the storage envelope and back onto the results screen unchanged.                                                                                                                     | DSH-088, DSH-113          | A, B    | `dispensaries.featuretests.test.ts`, `shoppingAgentTransformer.featuretests.test.ts` |
| Results are readable whether the pipeline emitted camelCase run output or a snake\_case cached menu row.                                                                                                                         | DSH-110, DSH-111          | B       | `shoppingAgentTransformer.featuretests.test.ts`                                      |
| A low-confidence strain match is demoted to unmatched rather than presented as a match.                                                                                                                                          | DSH-112                   | B       | `shoppingAgentTransformer.featuretests.test.ts`                                      |
| Several matched products that share no strain slug stay separate products instead of collapsing into one.                                                                                                                        | DSH-114                   | B       | `useMenuProducts.featuretests.test.ts`                                               |
| The product list dedupes repeated SKUs, sorting and filtering apply to what is actually there, category tabs are derived from this result rather than a fixed list, and empty sections render nothing instead of an empty shell. | DSH-115, DSH-116, DSH-117 | B       | `useMenuProducts.featuretests.test.ts`, `ShoppingAgent.test.tsx`                     |
| Tapping a matched product opens that strain, tapping an unmatched one confirms first and then offers research, and a recommendation with no matched strain is inert rather than navigating somewhere wrong.                      | DSH-118, DSH-119, DSH-120 | B       | `ShoppingAgent.test.tsx`                                                             |
| A product's outbound link opens only `http`/`https`.                                                                                                                                                                             | DSH-121                   | B       | `productUrl.helpers.test.ts`                                                         |

### Personalized picks

| Behavior                                                                                                                                                                                                                                                        | Verified by      | Tier(s) | Where (file basename)                                                                                        |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| **Menu data is per-domain and cached; personalization is per-user and never shared** — the menu is loaded server-side from the stored scan, and a caller cannot submit products as "the menu".                                                                  | BUG-110          | B       | `shopping-routes.featuretests.test.ts`                                                                       |
| Personalization requires a verified identity and Pro, and is metered at the last boundary before the model call, so every early return is free.                                                                                                                 | BUG-110          | B       | `shopping-routes.featuretests.test.ts`                                                                       |
| An account with no history spends nothing and is reported as having nothing to personalize from rather than as still loading, and every list sent is bounded so a large account cannot send an unbounded prompt.                                                | BUG-110          | B       | `shopping-routes.featuretests.test.ts`, `shoppingPersonalization.helpers.test.ts`                            |
| The prompt contains only the sections the shopper actually has and never invents a purchase date or a nameless stash line; an empty menu short-circuits without a model call; and a provider failure surfaces as a failure instead of as an empty set of picks. | BUG-110          | B       | `recommendations.test.ts`, `shoppingPersonalization.helpers.test.ts`, `shopping-routes.featuretests.test.ts` |
| A cache hit and a fresh scan reach personalization by the identical path, and a request that expires, is rate-limited or fails degrades to an unpersonalized results screen rather than an error screen.                                                        | BUG-081, BUG-110 | B       | `shoppingPersonalization.request.test.ts`, `shopping-routes.featuretests.test.ts`                            |

### Researching unmatched products

| Behavior                                                                                                                                                                | Verified by               | Tier(s) | Where (file basename)                |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ------------------------------------ |
| "Research All" and "Select All" both respect the five-strain ceiling, and queueing confirms on success while leaving the screen in place on failure so nothing is lost. | DSH-122, DSH-123, DSH-124 | B       | `ShoppingAgent.test.tsx`             |
| Selection dedupes names case-insensitively, while two genuinely different products with the same name stay separately selectable.                                       | DSH-125, DSH-126          | B       | `discoverySelection.helpers.test.ts` |

### The Safari extension

| Behavior                                                                                                                                                                                                  | Verified by               | Tier(s) | Where (file basename)                  |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | -------------------------------------- |
| Product matching runs three tiers with a bounded fuzzy fallback, so a near-miss name still matches and a wild one does not.                                                                               | DSH-147                   | B       | `shopping-extension.test.ts`           |
| The match endpoint is reachable only when its feature flag is on, and its per-IP rate limit refuses excess requests with a retry hint carried in both the body and the response header, which must agree. | DSH-146, DSH-148, BUG-155 | B       | `shopping-routes.featuretests.test.ts` |
| The extension's sync lists contain only the signed-in user's own strains.                                                                                                                                 | DSH-184                   | C       | `dispensaries.integration.test.ts`     |

## What the live tier proves end-to-end

Tier C signs in as a real, disposable `+clerk_test` identity, drives the deployed
staging Convex deployment and the production Hono API, and deletes everything it
creates. The free lane spends nothing; the paid lane is opt-in.

* **The whole saved-shop lifecycle works on the deployed backend** — create, read, favorite, edit, rename, archive-on-delete and re-add (DSH-161, DSH-164).
* **The deployed validators reject exactly what the client rejects**, and duplicate prevention behaves on a real deployment as it does in memory (DSH-162, DSH-163).
* **One deployed identity cannot touch another's shops**, and the Revyl launch-smoke fixture seeds idempotently on staging (DSH-165, DSH-166).
* **The scan endpoint's gates are real**: an absent or unverified Clerk token is refused, and a free identity is stopped at the entitlement gate before any credit is spent — measured with a deliberately invalid body so the status names the gate reached (DSH-168, DSH-167).
* **A scan result's storage lifecycle holds on the deployed backend** — create, read, delete, the active-result quota, and a source-run binding that cannot be forged (DSH-176, DSH-177, DSH-178).
* **A real cold scan of a known-good menu completes and decodes on the results screen**, a second identical scan is served from cache, two concurrent identical scans collapse into one run, a menu with no products yields an empty menu, and a failing scan surfaces an error and releases its lease (DSH-169, DSH-170, DSH-171, DSH-172, DSH-175).
* **Queueing discovery research reaches the real research pipeline** (DSH-179), and a Gmail receipt really does create the shop it came from (DSH-181).

## Deliberate gaps & conditions

* **The paid lane is opt-in and skipped by default.** DSH-169–172, DSH-175 and
  DSH-179 each spend Firecrawl credits or real research runs and take minutes; they
  run only when `TIER_C_PAID` is set deliberately. A default Tier C run reports them
  as skipped, which is not evidence they pass.
* **Which gate the deployed API stops a Tier C identity at is probed, not pinned.**
  That is a deployment fact that has already moved once — first the auth-realm split,
  then the subscription gate flipping from bypass to enforce — so DSH-167 measures it
  and reports it rather than asserting a status a config change would falsify.
* **Age-gated dispensary sites are not scanned in CI.** DSH-173 and DSH-174 are
  deferred: they depend on a live third party, are non-deterministic, and the
  interactive scraping mode is materially more expensive.
* **`/extension/match` is not exercised on staging.** DSH-183 would require flipping
  a feature flag on a deployment shared with every other suite, so the endpoint is
  proven at Tier B only.
* **Two behaviors are contracted here but proven in the Orders and Gmail suites** to
  avoid asserting the same rule against two copies of it: order-driven shop
  auto-creation on staging (DSH-180 → ORD-205) and the live email-domain endpoint
  (DSH-182 → GML-226).
* **Two P3 rows are deliberately unbuilt**: an over-long search query (DSH-014, one
  validator string) and "data export includes dispensaries" (DSH-047, already covered
  structurally by the export-policy test EXP-001).
* **The Shopping Agent is still flag-gated off in production builds.** Everything
  above is proven, but the feature is reachable today only in development and on
  staging — which is why the live tier is the only place the end-to-end decode is
  observed.
* **The stored-result delete path has no UI.** It is contracted and tested (DSH-076,
  DSH-176) but nothing in the app calls it; results expire instead.
* **Tier C skips cleanly without credentials**, naming exactly what is missing, and
  is never part of `pnpm test`, `pnpm check`, or a cached Turbo run.

## Where to extend

A new saved-shop or scan-storage rule gets its Tier A case in
`apps/mobile/convex/tests/dispensaries.featuretests.test.ts`. A new form, hook or
screen-state rule goes in the co-located `*.featuretests.test.ts` next to its helper
(`dispensaryForm`, `useShoppingAgent`, `shoppingAgentTransformer`, `useMenuProducts`)
or in `apps/mobile/test/ui/features/Dispensaries.test.tsx` /
`ShoppingAgent.test.tsx` for rendering; API and pipeline rules go in
`apps/api/src/routes/shopping/__tests__/shopping-routes.featuretests.test.ts` or
`packages/trigger/src/tasks/shopping-menu-scan.featuretests.test.ts`. Reach for
`apps/mobile/test/integration/suites/dispensaries.integration.test.ts` only when the
claim is about the **deployed** backend — and put anything that spends money behind
the paid-lane gate. Then add the row to the master matrix; see
[Mobile Feature Test Platform](/planning/testing/feature-test-platform) for naming,
tags, and the tier decision.
