AI STORE SHOPPER
AI Store Shopper check rubric

AI Store Shopper Rubric v0.4

Dated 2026-08-24, reconciled during the same-day duplicate-build merge (a dispatch error produced two parallel v1 builds; nothing had deployed). Amended 2026-08-24/25 after a full review pass (blockers, scoring integrity, tests) -- see CHANGELOG at the bottom. Amended 2026-08-30: publishes this page at its permanent public URL and extends the clause-7 refund remedy to every run that could not complete the walk -- see CHANGELOG. Amended 2026-09-01 (ruling THREAD_INTAKE_20260901, DB-573): adds a Structured Data / Schema Audit section (restating the existing readable-section checks under a schema-focused lens, honestly labeled measured-vs-defined) and one EU consent observation sentence -- see CHANGELOG. Amended 2026-09-01 (ruling SETUP_STEP_REMOVED_20260901, DB-581, FINDING VERSION) -- a real scoring change, this page's own version bumped 0.1 -> 0.2: removes the merchant "setup step" concept entirely (no setup step exists anywhere, on any platform). Rewrites how a robots-blocked /cart//checkout is graded in the buyable section (see "Robots obedience" and "Platform-aware Buyable" below) and adds a new read-only ucp_discovery check. See CHANGELOG for the exact, dated diff. Amended 2026-09-01 (ruling PRICING_LADDER_APPROVED_20260901, DB-592) -- a real scoring change, this page's own version bumps 0.2 -> 0.3: every buyable-section action (add-to-cart, checkout navigation) now records confirm_ms -- the elapsed time from the click to the DOM state that confirms it -- and a new confirmation_latency check publishes tiers on that timing. See "Confirmation timing" below and CHANGELOG for the exact, dated diff and weight arithmetic. Amended 2026-09-02 (ruling OWNER_DELEGATES_PRODUCT_CALLS_20260902, DB-597) -- an identity change, not a grading change, so this page's own version stays 0.3: our crawler identity was renamed to AIStoreShopperCheck; see "Robots obedience" below and CHANGELOG. Amended 2026-09-02 (DB-605, item 605, I-28 precedent) -- a real, additive change, this page's own version bumps 0.3 -> 0.4: every walk step (find/read/cart/checkout-entry) now records ready_ms/ready_cap_hit -- the same domcontentloaded/15-second-cap readiness _navigate() already used for confirm_ms's own "Readiness disclosure", now recorded and disclosed per step, beside confirm_ms. A step that hits the cap is now also a real, gradeable finding. See "Time to ready" below and CHANGELOG for the exact, dated diff. Public methodology — every point weight is documented here, no hidden scoring. Rubric version string used in run JSON: 0.4. A copy of this document is included alongside every rendered report; the methodology link in each report points to the public methodology home, https://aistoreshopper.com/titan/rubric.

Reviewer sign-off: the client report carries a reviewer-sign-off line. It reads "This report has not yet been independently reviewed by a second reviewer." until a second reviewer (independent of the build) has re-checked the run and signed off; that field is never silently left blank.

01

Two engines, one rubric, one cutoff table

AI Store Shopper v1 ships with two check engines that were originally built independently and graded stelardigital.com differently (D vs F) before this merge:

  1. agent_walk.py — CANONICAL v1 engine. A real Playwright headless-Chromium browser walk: it actually navigates, looks for a product page, attempts add-to-cart, and attempts to reach checkout. This is the engine described in the four sections below (bot_config / findable / readable / buyable).
  2. engine/titan_checker.py — secondary/fallback engine. Static analysis only: HTTP fetch + structured-data parse + robots.txt policy read. No browser, no JS, no click, no cart submit, no impersonation of any bot. Scores four *different* dimensions (discoverability, price_stock, cart_checkout, agent_reachability) with its own weights, documented in engine/titan_checker.py's DIMENSION_WEIGHTS. Useful for a fast, browser-free regression pass, but its output must never be presented as a live purchase-journey result — its own report banner says so.

Why stelardigital.com graded D (agent_walk, 50.0/100) vs F (titan_checker, originally 41.2/100 under its own then-different cutoff table): two separate causes, both fixed here. First, the two engines test genuinely different things — a real 3-hop click-based nav search vs a single-fetch JSON-LD/regex scan — so their underlying section/dimension scores are not directly comparable and never will be. Second, and more simply, the two engines were using *two different letter-grade cutoff tables* (titan_checker.py's D-floor was 60; agent_walk's D-floor was 40) — an unforced inconsistency, not a methodology finding. That second cause is fixed by this merge: both engines now use the single cutoff table below. After the fix, titan_checker's 41.2/100 for stelardigital.com is a D, matching agent_walk's D — the remaining difference in underlying dimension scores is real and expected (different tests), not a bug. Both engines independently agree stelardigital.com has no reachable product-detail page — it is a marketing site, not a storefront, so a low findability/discoverability score is the correct, honest result, not a defect in either build.

02

Overall grade, and the ONE na-handling rule (applies everywhere)

A check/dimension/section marked na (or not_tested_robots_disallow -- see Robots obedience below) is excluded from BOTH the numerator and the denominator of whatever score it would have contributed to. The remaining TESTED items are reweighted proportionally so they still sum to 100. This is the single rule used at every level (per-section check weights, per-dimension in titan_checker, and the overall four-section average) -- an untestable item never silently raises OR lowers a grade by being dropped in an inconsistent way. If NOTHING in a section/dimension/run was testable, that level scores None (not 0, not 100) and the run status is incomplete (no letter grade -- see Incomplete runs below), and the report states explicitly which dimensions/sections were tested and which were not (tested_sections/ untested_sections, tested_dimensions/untested_dimensions in every run JSON and report).

Overall score = average of the four TESTED section scores (bot_config, findable, readable, buyable), each weighted equally (25% each) before any exclusion. na sections are excluded and the remaining sections are reweighted equally; if all four are na, overall is None and the run is incomplete.

Score rangeLetter
>= 90A
>= 75B
>= 60C
>= 40D
< 40F

No letter grade is ever issued for a None overall score. This includes incomplete runs (browser crash, offsite-navigation abort) -- letter_grade(None) returns None, never "F".

03

Incomplete runs

A run's status is "incomplete" (with an incomplete_reason) whenever:

An incomplete run's overall_score and grade are both null. Whatever partial checks/ sections were collected before the interruption are still written to the run JSON and shown in the report, clearly banner-labeled INCOMPLETE, for transparency -- they are never presented as a finished, letter-graded result.

04

Coverage floor and status: "partial"

A "complete" run and a full letter grade require BOTH of the two sections/dimensions that actually establish whether a product could be found and bought to have been genuinely exercised -- not merely to have produced some numeric score:

"Tested" is not the same thing as "genuinely exercised": buyable can legitimately produce a real number (e.g. 0.0) from a single weighted check (add_to_cart_succeeds failing because no control was found) while its other weighted checks (checkout_navigation_reached, journey_uninterrupted) are na -- never attempted at all, because the journey short-circuited at the very first gate. score_weighted()'s reweighting makes "we tested 40% of the section and failed it" indistinguishable, from the section score alone, from "we tested all of it and failed all of it." A section counts toward the coverage floor only if EVERY weighted check DEFINED for it ran to a pass/fail verdict (no na among them).

### "partial" is OUR failure to finish, never the store's failure to pass

Ruling PARTIAL_MEANS_OUR_FAILURE_20260902 (DB-608, owner-caught 2026-09-02): terms clause 7 promises a refund when "the walk can't complete" -- an unqualified promise about OUR side failing to finish the audit, never about a store failing the audit it was actually delivered. Missing the coverage floor is not by itself enough to set status = "partial" anymore: agent_walk.py also checks store_caused_stop (agent_walk._store_caused_stop(checks, sections)), which is True from EXACTLY two sources, never a broader scan of every check in the run:

  1. A real buy-journey stop. report_journey.select_step_states_and_stop's own has_stop -- restricted to findable/readable/buyable's real ids (report_journey.JOURNEY_STEPS, which never includes a bot_config id at all) -- the SAME computation the client report's own report_journey._effective_complete reads its has_stop input from at render time, so the writer and the renderer can never disagree about what counts as a genuine stop.
  2. A genuine, actually-evaluated robots block. citation_fetchers_allowed / training_crawlers_reported (the two weighted, scored bot_config ids) failing on a real, fetched robots.txt that explicitly disallows this walk's own token -- WITHOUT measured.robots_unreachable set on that check.

That last qualifier matters: when robots.txt is merely UNREACHABLE (5xx, timeout, a WAF, or any other fetch failure -- never a real 404/absent response) check_bot_config fails those same two checks CLOSED, per RFC 9309, as a defensive default -- but that is OUR OWN tooling failing to read a config file, never a fact about the store, and it also leaves findable/readable/ buyable completely unmeasured (not_tested_robots_disallow, our own navigation obeying the same fail-closed default). A robots-UNREACHABLE run is therefore never store_caused_stop, no matter how many checks reached "fail" -- status is "partial", overall_score/grade are null, and the client report's refund notice renders: robots-unreachable decides refund eligibility under clause 7, the same as any other genuinely our-side failure to finish the audit.

"incomplete" (crash / off-allowlist abort / no section ever testable, overall_score still null after grading) is unchanged by this ruling -- it was already an our-side failure and stays one. No RUBRIC_VERSION bump accompanies this change: the scoring semantics of an individual TESTED check are unchanged; only which run-level status a given set of results maps to changed.

The report renderer is a separate, independent enforcement point, re-derived from the run's own real facts rather than trusting a stale field: report_journey._effective_complete(status, has_stop) treats a raw status == "complete" run as complete, AND a raw status == "partial" run as effectively complete too when it carries a real, genuine has_stop (the same buy-journey signal above) -- so a store-caused stop renders a real, SCORED report (a real grade, a real gauge, no partial tag, no refund notice) even off an older run JSON written before this ruling existed. The renderer no longer forces NONE/n/a onto every status != "complete" run; it forces that only when the run is NOT effectively complete by this same re-derivation. The client report's refund notice (titan_client_report._partial_refund_notice_html) renders only when the run is NOT effectively complete by that re-derivation -- so after this ruling it renders only for a genuine our-side failure (including a robots-unreachable run, see above), never for a store that simply failed its walk.

05

Robots obedience

Both engines fetch and obey the target's own robots.txt for THEIR OWN identity, the token AIStoreShopperCheck (kept equal in agent_walk.py and titan_checker.py), before fetching or navigating any path. A robots.txt group naming the pre-rename legacy tokens StelarTitanCheck or TitanCheck is honored exactly the same way through 2026-11-30 -- ignored starting 2026-12-01 -- so a merchant's existing rule against the old identity keeps working during the transition (ruling OWNER_DELEGATES_PRODUCT_CALLS_20260902, DB-597, 2026-09-02). A path disallowed to us is never fetched/navigated -- this never changes; the engine never overrides any robots.txt, on any platform, under any ruling. For every path EXCEPT /cart//checkout, a disallowed path's relevant check(s) are reported with result not_tested_robots_disallow -- excluded from scoring per the na-handling rule above, never scored as a penalty. Absence of a robots.txt is treated as "allowed" (silence is not a disallow).

/cart and /checkout are the one documented exception to not_tested_robots_disallow (ruling SETUP_STEP_REMOVED_20260901, DB-581, added 2026-09-01) -- see "Platform-aware Buyable" below. The engine still never navigates or clicks toward a robots-disallowed /cart or /checkout -- no override, ever -- but the result is graded, not marked not-tested, because a platform-aware finding is available either way (Shopify: what its own UCP manifest advertises; everywhere else: a real, fixable robots.txt finding).

06

Platform-aware Buyable (added 2026-09-01, ruling SETUP_STEP_REMOVED_20260901 / DB-581)

There is no merchant setup step, before or after a walk, on any platform. A walk runs on a store exactly as it is. What changed is how a robots-blocked /cart or /checkout is graded:

A 3xx redirect on the endpoint reachability probe is read for its Location header rather than treated as automatically live: a redirect to a password- or login-wall (Shopify's own /password, or an off-host sign-in/challenge page) never earns full credit, because that endpoint is not one a real shopping agent can actually complete a purchase against, while a legitimate redirect -- a canonical 301, a same-path upgrade to https, a plain regional 302 that names no login page -- still does. The redirect target is read from a single response header on the same one-hop, no-retry probe already described above; it is never followed.

07

Robots.txt group/rule evaluator

Both engines share one parser (engine/robots_rules.py), not two divergent ones. Group selection: an exact case-insensitive User-agent match if the target names the bot specifically, else the User-agent: * wildcard group if one exists, else "no applicable group" (reported as unmeasured for that bot, not assumed-allowed). This fixes the earlier bug where a blanket User-agent: * / Disallow: / block (with no bot named at all) was scored as "not blocked" for every named citation bot, because the old parser only matched a literal Disallow: / line under a bot's own explicitly-named group.

Rule selection within a group: LONGEST-MATCH WINS, Allow wins ties, per RFC 9309 section 2.2.2 -- not the first-match-in-file-order convention this document previously described. Every rule in the selected group is evaluated against the tested path; the rule with the longest matched rule_path (octet length, after */$ wildcard expansion) wins, and an Allow wins an exact-length tie against a Disallow. Example: Disallow: / then Allow: /products -- the old first-match evaluator matched Disallow: / first and published a false "robots.txt policy disallows ClaudeBot" claim about a third party for every path, including /products/*; longest-match correctly lets /products/* through. Conversely Allow: / then Disallow: /admin -- the old evaluator matched Allow: / first and our own crawl fetched /admin (a real fail-open crawl bug, not just a reporting error); longest-match correctly blocks it.

Matching is linear-time, not regex-backtracking. Path rules are matched by a two-pointer scan over literal segments split on * (using str.find(), no regex), not by compiling to a regex and calling .match(). A pattern with many *s matched against a long non-matching path (e.g. Disallow: /*a*a*a*...*a$ against a long run of as with no trailing match) triggered catastrophic backtracking in the old regex-based matcher -- unbounded, proved at 22s for 10 stars. No pattern, however adversarial, can make the current matcher super-linear.

A leading UTF-8 BOM, and CRLF/lone-CR line endings, no longer defeat parsing. A robots.txt served with a leading BOM made the very first User-agent: line parse as key "user-agent", which never equals "user-agent" -- the whole file silently parsed to zero groups, and a robots.txt that blocks everyone was reported (including in third-party citation-bot claims) as blocking nobody. The BOM is now stripped and line endings normalized before parsing.

Percent-encoding and query strings are normalized before comparison. A rule path and a tested path that differ only in percent-encoding (e.g. /caf%C3%A9 vs /café) are treated as equivalent; query strings (?...) are matched literally as part of the path, same as any other character.

08

Section: bot_config (0-100)

Checks /robots.txt.

CheckPointsPass condition
citation_fetchers_allowed50None of the REAL-TIME citation fetchers -- ClaudeBot, PerplexityBot, OAI-SearchBot -- are disallowed (per the group/rule evaluator above, incl. wildcard-group fallback)
training_crawlers_reported15Informational: full points awarded regardless of GPTBot/CCBot block status, AND regardless of Google-Extended/Applebot-Extended block status; the check exists to REPORT which class (real-time citation vs AI-training) is blocked, never to penalize a store for blocking training crawlers or opting out of AI training
sitemap_present20sitemap.xml referenced in robots.txt or reachable at /sitemap.xml
llms_txt_present15/llms.txt reachable (informational — emerging convention, not yet standard; still scored as a bonus signal)

Google-Extended and Applebot-Extended are AI-TRAINING opt-out tokens, not real-time citation fetchers. They govern whether this content may train future models, unrelated to whether an AI shopping agent can find the page right now -- blocking only those two is the merchant's own business/IP decision, not a defect. citation_fetchers_allowed is scored on ClaudeBot/PerplexityBot/OAI-SearchBot only; a block on Google-Extended and/or Applebot-Extended is reported informationally in training_crawlers_reported's evidence (same always-pass, never-penalized pattern as GPTBot/CCBot) and never drags the citation_fetchers_allowed score down.

If /robots.txt itself 404s or is unreachable, citation_fetchers_allowed AND training_crawlers_reported BOTH pass by default (no disallow rules exist for either class) -- this is scored as an explicit pass, never na, matching this rubric line. sitemap/llms.txt are scored independently either way.

When robots.txt disallows our own crawl token from /sitemap.xml or /llms.txt (or robots.txt itself is unreachable, fail-closed), that check's result is not_tested_robots_disallow, not fail -- the question was never actually tested, so score_weighted() excludes it from both numerator and denominator (same treatment as product_reachable_within_3_hops gets in the findable section) instead of scoring it a defect.

09

Section: findable (0-100)

CheckPointsPass condition
product_reachable_within_3_hops70A product-detail page is reached from the homepage by following nav/collection links, in <= 3 hops
hop_count_bonus3030 pts if reached in 1 hop, 20 pts if 2 hops, 10 pts if 3 hops, 0 if not reached or >3 hops

If no product page is reachable within 3 hops (or homepage itself fails to load, e.g. password wall), section = 0 and dependent sections (readable, buyable) are scored na. If the homepage/every candidate page is not_tested_robots_disallow, the section is untested (None), not 0.

10

Section: readable (0-100)

Evaluated on the product page found by findable.

CheckPointsPass condition
jsonld_present15A <script type="application/ld+json"> block containing @type: Product or Offer exists
title_present_descriptive15JSON-LD or og:title/<title> present and >= 3 words (not just a SKU/blank)
price_present20JSON-LD Offer.price or equivalent (og:price / visible price element) present and numeric
currency_present15JSON-LD Offer.priceCurrency or equivalent present
availability_present20JSON-LD Offer.availability (or equivalent in-stock/out-of-stock text/schema) present
og_tags_fallback15og:title, og:description, og:image present as a fallback signal even when JSON-LD is absent/incomplete

If findable could not reach a product page, this section is na.

11

Structured Data / Schema Audit

Added 2026-09-01 (ruling THREAD_INTAKE_20260901, DB-573). This is a documentation lens on the readable section above, not a new scored section -- "does this page carry machine-readable structured data an AI agent can parse" is a distinct question buyers ask, but it maps entirely to checks the engine already runs. No new engine code was added to produce this section; it restates what readable's existing checks (above) already measure, at the level the code can actually do today, and states plainly which parts are not yet measured.

Measured today -- traces directly to a readable check already listed above, evaluated on the ONE product page the walk reaches (see findable; a walk that never reaches a product page cannot audit structured data on one, and this section is na under the same rule as readable):

Structured-data signalMeasured by (readable check, above)What "pass" means
JSON-LD / Schema.org presencejsonld_presentA <script type="application/ld+json"> block with @type: Product or Offer exists
Product nametitle_present_descriptiveJSON-LD or og:title/<title> present and descriptive (>= 3 words)
Priceprice_presentJSON-LD Offer.price or an equivalent visible/og price signal, present and numeric
Availabilityavailability_presentJSON-LD Offer.availability or an equivalent in-stock/out-of-stock signal
Currencycurrency_presentJSON-LD Offer.priceCurrency or equivalent
Open Graph fallbackog_tags_fallbackog:title/og:description/og:image present as a fallback signal even when JSON-LD is absent/incomplete

Defined here for a future build, not implemented today (honest limitation, not a promise): whole-page Schema.org type coverage beyond Product/Offer (e.g. BreadcrumbList, Organization, AggregateRating), and structured data on any page OTHER than the single product page the walk reaches. agent_walk.py does not currently persist the full raw page HTML of every page it visits into the run JSON -- only the specific fields the checks above already extract -- so scoring a broader schema graph across the whole site would require capturing and parsing full page HTML per visited page, a real engine change out of scope for this documentation revision. Marked here as defined, not implemented, never silently claimed as already covered.

12

Section: buyable (0-100)

CheckPointsPass condition
add_to_cart_succeeds36An add-to-cart action (form submit / button / fetch) changes cart state (cart count, cart page, or cart API response reflects the item), OR the purchase control hands off directly to a KNOWN third-party payment processor (a skip-cart "Buy Now" pattern -- see "Egress classifier for the checkout click" below)
checkout_navigation_reached36From cart, a checkout page loads (URL/path indicates checkout, a checkout button lands on one, OR the checkout control hands off to a KNOWN third-party payment processor -- see "Egress classifier for the checkout click" below)
journey_uninterrupted18No bot wall / CAPTCHA / Cloudflare challenge / login wall / real JS error (via an attached page.on("pageerror") listener) stopped the journey before checkout-page-reached
confirmation_latency10The slowest of the add-to-cart and checkout-navigation confirm_ms readings this walk actually observed falls in the "good" or "slow" tier (<= 8 seconds) -- see "Confirmation timing" below

Weight arithmetic (rubric v0.3, DB-592): confirmation_latency is a NEW check; its 10 points are taken FROM the other three, not added on top, so the section still sums to 100: add_to_cart_succeeds 40 -> 36 (-4), checkout_navigation_reached 40 -> 36 (-4), journey_uninterrupted 20 -> 18 (-2), confirmation_latency 0 -> 10 (+10). 36 + 36 + 18 + 10 =

  1. This arithmetic lives in exactly one place in code, agent_walk.BUYABLE_WEIGHTS.

journey_uninterrupted is scored na (not pass) whenever add_to_cart_succeeds failed: if there was never a cart/checkout journey to interrupt because no add-to-cart control was found, we do not claim the journey was "uninterrupted" -- we did not test that. journey_uninterrupted's "no JS error" claim is only ever made because a real page.on("pageerror") listener was attached for the run and recorded zero errors -- never asserted without that listener actually running.

Journey never proceeds past "checkout page reached" — no payment fields are touched, no order is ever submitted, regardless of score. This is a hard rule, not a scoring choice.

If findable could not reach a product page, this section is na.

13

Confirmation timing (added 2026-09-01, ruling PRICING_LADDER_APPROVED_20260901, DB-592)

What is measured. For every buyable-section action the walk takes -- the add-to-cart click, and the checkout-navigation click/redirect -- agent_walk.py records confirm_ms: the elapsed milliseconds from the moment the action is issued (the click fires) to the moment the page's own DOM state confirms it -- the SAME pass condition already documented for that check above (a cart-count change or /cart navigation for add-to-cart; a checkout page/known-processor handoff for checkout-navigation). confirm_ms is written into that check's own measured field in the run JSON. It is null whenever the action was never tested (no product page reached, robots blocked /cart//checkout, a bot-wall/challenge stopped the walk before the action) -- there is no time-to-confirmation for an action that never fired.

"Acted, no confirmation." If the action's click genuinely fires but no confirmation ever appears within the check's existing timeout, the check is graded fail with the evidence explicitly stating "acted, no confirmation" -- this was already today's behavior for that case (a click landing with no observed cart-state change, or a checkout click with no checkout page ever reached, already failed); rubric v0.3 makes the reason explicit in the evidence sentence and leaves confirm_ms null for that check (no confirmation event occurred to time).

Published thresholds (confirmation_latency check). Sourced from the agent-operator's own rule of thumb (IH commenter Workletterco, 09-01 -- BUYERS-CHAIR reference buyer for this build): agents retry or double-act when confirmation is slow. The confirmation_latency check takes the SLOWEST of the add-to-cart and checkout-navigation confirm_ms readings this walk actually observed (that is the step most likely to trigger a retry) and tiers it:

TierThresholdCheck result
good<= 2,000mspass
slow2,001ms - 8,000mspass
at_risk> 8,000msfail

"good" and "slow" both pass (the action DID confirm); only "at_risk" fails -- the tier itself, not the pass/fail split alone, is the disclosure. confirmation_latency is na when neither action was ever confirmed (nothing to time). Thresholds live in exactly one place in code, agent_walk.CONFIRMATION_LATENCY_GOOD_MS / CONFIRMATION_LATENCY_SLOW_MS.

In practice, at_risk is reachable only through checkout navigation, not add-to-cart. The add-to-cart click's own actionability timeout (5s) plus its fixed post-click confirmation wait (1.5s) structurally caps its measured confirm_ms at roughly 7.5 seconds -- inside the "slow" tier, never "at_risk". Checkout navigation's confirmation can genuinely take longer (the destination checkout page itself has to load), so a real at_risk reading in a run's evidence means the checkout step, not the add-to-cart step.

Readiness disclosure. "Confirmed" for a page load specifically means Playwright's domcontentloaded event, with a 15-second cap (agent_walk.py:802, the single _navigate() chokepoint every navigation in this module goes through -- see "Consent" below). A page that has not fired domcontentloaded within 15 seconds is treated as a navigation failure for that check, not as a slow-but-eventual load; confirmation_latency's "at_risk" tier is a DIFFERENT, shorter-fuse signal about clicks specifically, not about this 15-second page-load cap.

Fresh-browser disclosure. Every walk launches a NEW headless-Chromium browser process and browser context for that one store, that one walk (agent_walk.py:2325-2337, inside main()'s with sync_playwright() block) -- no cookies, cache, or session state carries over from a prior store or a prior run. Confirmation timing measured here reflects a cold, first-visit browser state, the same as a real first-time shopper (or shopping agent) would experience, not a warmed-up session.

Client report. The rendered report includes a plain-language "How fast your store confirmed" block listing each step's confirmed time and the overall tier, with no internal check ids, no "rubric"/"agent_walk"/engineering naming -- see engine/titan_client_report.py's _confirmation_speed_html().

14

Time to ready (added 2026-09-02, DB-605, item 605, I-28 precedent)

What is measured. For each of the four buyer-journey steps a walk can navigate through -- finding your store (find), loading the product page (read), loading your cart (cart), and loading checkout (checkout-entry) -- agent_walk.py records ready_ms: the elapsed milliseconds from just before that step's navigation starts to the moment Playwright's own domcontentloaded event fires for it. This is the SAME single _navigate() chokepoint and the SAME 15-second cap "Confirmation timing"'s own "Readiness disclosure" above already documents -- ready_ms makes that existing readiness signal an explicit, per-step, disclosed reading instead of only an internal cap. ready_ms/ready_cap_hit are written into walk_json["step_timing"], keyed by the four step names above (agent_walk.STEP_TIMING_KEYS), beside confirm_ms's own per-action measured fields -- same shape, same units, a null ready_ms whenever that step's navigation never ran this walk. That null is genuinely two different things, and the client report (below) tells them apart rather than collapsing both into one sentence: (1) the walk never reached that step at all (a real, gradeable "not reached"), or (2) cart/checkout-entry specifically -- the ONLY two steps a click can complete without ever calling _navigate() (a same-page add-to-cart/checkout click that never triggers a full navigation) -- were reached, just not by a page load this run had anything to time. A run that reaches checkout by a plain click (the best case on many stores) must never read "not reached" for that step -- CERT-49 -- so the report reads journey["reached"]'s own stage (none < product < cart < checkout) to tell the two apart: reached-by-click renders "not measured (reached by click, not a page load)", genuinely-never-reached renders "not reached this walk". find/read have no click path at all (every real completion of either goes through _navigate()), so a null ready_ms for either one always means "not reached this walk", with no such ambiguity to resolve.

ready_cap_hit. True only when a step's own navigation hit the existing 15000ms cap without ever firing domcontentloaded -- ready_ms stays null in that case (no completed load to time). This never changes the cap itself, what gets navigated to, or the walk's own actions -- it is purely an observation recorded around the existing, unmodified _navigate() call.

Finding: page_ready_within_cap. Appended to the findable section, real and gradeable, ONLY when at least one step's navigation genuinely hit the cap this walk (conditionally appended, same discipline the tool-snag ids already use for "only present when it actually happened") -- its evidence names exactly which step(s) capped. Never weighted into any section score (it exists outside every section's own weights dict, so it can never inflate or deflate a grade by its own presence) -- a disclosure and a fix-card finding, not a scoring change to any existing check. Remediation lives in engine/report_recipes.RECIPES["page_ready_within_cap"] (bin: developer, scope: Site-wide -- a slow page load is a theme/infra property, not specific to one product's data), the same recipe mechanism every other fix card already uses.

Client report. ready_ms/ready_cap_hit render as a plain-language "How fast your store loaded" list, right beside "How fast your store confirmed" in the same collapsed buyable-group body under "Everything we checked" -- no new section, no new layout -- see engine/titan_client_report.py's _page_readiness_html().

15

Egress classifier for the checkout click

Proven live, 2026-08-25: a real stelardigital.com/x402-doctor Stripe button was recorded as chrome-error://chromewebdata/ / aborted_offsite, as if the merchant's own purchase button were broken -- when a direct Playwright click against the same button, outside this consent-gate wrapper, reached real Stripe Checkout. The cause: install_consent_guards' context.route() interceptor correctly refuses the navigation REQUEST toward buy.stripe.com (off the v1 consent allowlist, which is a crawl allowlist, not a "sites we may hand off a shopper to" list) -- but the refusal itself then lands the real page on Chromium's own internal error page, and the OLD code re-derived "what happened" from THAT landed URL, misattributing our own gate's refusal to the merchant.

Also proved live, same re-verification run (2026-08-25): x402-doctor's actual purchase control ("Get the Conformance Report -- $79") is a skip-cart "Buy Now" pattern -- the ADD-TO-CART click itself hands off straight to Stripe Checkout, with no on-site cart ever populated. The classifier below therefore applies at BOTH the add-to-cart click and the checkout click: a known-processor handoff recognized on the add-to-cart click scores add_to_cart_succeeds AND checkout_navigation_reached pass together (the handoff IS both actions for this pattern), and short-circuits the rest of the cart/checkout journey (there is no cart page to check).

agent_walk.py's add-to-cart and checkout clicks now classify the terminal state of that ONE click into exactly three outcomes, never conflated:

  1. Merchant-site defect -- the checkout control could not be found, or clicking it landed on an on-site page that is not a checkout page, or a bot-wall/login-wall/JS-error interrupted the journey. Scored normally (checkout_navigation_reached / journey_uninterrupted: fail).
  2. Environment/egress failure -- our OWN scanner failed to complete a navigation with NO off-allowlist host ever identified: a genuine DNS/TLS/connection/timeout failure, OR our own consent-gate route interceptor refusing an off-allowlist navigation request. Chromium renders its internal error page (chrome-error:/chrome: scheme) either way -- there is no real host to name. Raised as ScannerUnreachable, reported honestly as incomplete_reason: "environment_unreachable" (a checks[] entry, id: "scanner_unreachable", result: "environment"), status incomplete, no letter grade -- never scored against the merchant, and never folded into aborted_offsite (that bucket is reserved for a REAL, NAMED off-allowlist host the gate actually caught).
  3. Legitimate known-processor handoff -- SUCCESS. If the checkout control's navigation REQUEST targets a host on the versioned engine/known_payment_processors.py allowlist (Stripe, Shopify-hosted checkout, PayPal, Square, Google Pay, Amazon Pay, Adyen -- see that file for the exact list and its own version number), this is recognized -- during the checkout/add-to-cart decision point ONLY, never during ordinary crawling -- as the ordinary, legitimate ecommerce pattern of handing a shopper off to a hosted third-party checkout page. checkout_navigation_reached PASSES, journey["reached"] becomes "checkout", and the evidence sentence names the processor host and the list version explicitly. The engine still never fetches, renders, or interacts with the processor's own page (the navigation request is still aborted, exactly as for any other off-allowlist host) -- per the HARD RULE ("stop at checkout-page-reached"), recognizing the destination is itself sufficient; going further would be pointless and outside consent regardless. This does NOT widen titan_consent_gate. ALLOWLIST_HOSTS (the crawl/consent allowlist) -- membership in known_payment_processors.KNOWN_PAYMENT_PROCESSOR_HOSTS grants zero crawl privileges anywhere else in this codebase; it is consulted at exactly one decision point.

A checkout redirect to any OTHER off-allowlist host (not a known processor) is still a hard abort exactly as before (aborted_offsite) -- the fix narrows the exception to a versioned, owner-reviewable list; it does not weaken the gate for anything unrecognized (Law Zero default: undeclared/unrecognized = refuse).

16

Bot-wall / challenge-page findings

A "bot wall / challenge" finding requires a SPECIFIC, structural marker from a known challenge-page product (a literal phrase like "checking your browser before accessing", or a script-src like challenges.cloudflare.com/turnstile) -- never a bare substring like "captcha" or "access denied" that can appear incidentally in a real business's own copy (an FAQ line, a support article, marketing copy). The finding states exactly which marker string was observed and where, never a character judgment ("this is a bot wall") about the business.

17

Citation-bot identification

Neither engine ever sends a request identifying itself as ClaudeBot, OAI-SearchBot, PerplexityBot, or any other named third party. Every request either engine sends identifies truthfully: AIStoreShopperCheck/1.0 (+https://aistoreshopper.com/titan/rubric) (agent_walk.py) or AIStoreShopperCheck/1.0 (+https://aistoreshopper.com/titan/rubric; static-analysis) (titan_checker.py). Citation-bot reachability is assessed by parsing the target's OWN published robots.txt directives for those bot names via the shared group/rule evaluator above -- never by impersonating them. Live reachability (whether a request claiming to be e.g. ClaudeBot would actually be served content, past WAF/bot-management fingerprinting) cannot be established this way and is explicitly scored unmeasured per bot, rather than guessed at or faked.

18

Journey field

journey.reached is one of none | product | cart | checkout. journey.stopped_by is one of bot_wall | cloudflare_challenge | login_wall | js_error | none.

19

Consent

Every run requires a consent.kind (e.g. own-store) and consent.evidence (a documented authorization reference or the store owner's request) recorded in the run JSON as provenance/metadata. That metadata is NOT the authorization itself: both engines, AND the report renderer, independently call the same hardcoded, module-level, no-bypass-parameter allowlist in engine/titan_consent_gate.py before touching any URL or rendering any report. The renderer does not trust the run JSON/CheckReport's own claim of consent -- it re-runs check_consent() on the subject URL itself and refuses to render for a non-consented subject, regardless of what the object being rendered claims.

agent_walk.py funnels EVERY navigation (initial goto, redirect, and any navigating click) through one _navigate() / _click_and_reconsent() chokepoint that checks consent BEFORE navigating and RE-CHECKS consent on the resulting page.url AFTER navigating, so a redirect from an allowlisted host to an unlisted one aborts the run immediately (status: incomplete, incomplete_reason: aborted_offsite) rather than continuing to walk and score an off-allowlist target. No public function in agent_walk.py may call page.goto() or perform a navigating click directly -- calling any internal navigation function (e.g. find_product_page()) with an off-allowlist URL is refused the same way main() is, because the gate is inside the navigation primitive itself, not just at the CLI entry point.

An off-allowlist --url/target is refused with no browser launched and no HTTP request sent, regardless of what the consent-kind/evidence args claim.

20

Known limitations (stated honestly, not claimed as protection we don't have)

21

CHANGELOG

This page names the modules that do the checking (agent_walk.py, titan_checker.py) so the methodology is traceable, but the deeper engineering detail behind them (env vars, internal test names, internal review history) is kept in a separate internal record, not published here; this is the buyer-facing summary of what changed and when.

Same-day fix (CERT-49, independent review): the first cut of the client-report "How fast your store loaded" block read a null cart/checkout-entry ready_ms as "not reached this walk" unconditionally -- false on any run that reached checkout by a plain click (cart and checkout-entry are the only two steps a click can complete without ever calling _navigate(), so a null reading there is often a genuine best-case success, not a miss). Fixed to read journey["reached"]'s own stage first: reached by click renders "not measured (reached by click, not a page load)"; genuinely never reached renders "not reached this walk". No change to what is measured or recorded -- render-time wording only.

A buyable score for the exact same store can shift upward under v0.3 vs. v0.2 -- read a score as a snapshot under its own rubric_version, never compared naively across versions. This is a direct, expected consequence of the reweighting above, not a scoring bug: a store that only ever passed add_to_cart_succeeds (checkout genuinely failing/not reached) scored 40.0 under v0.2 (40 of 100 tested points). Under v0.3 the same store scores 46.0 -- 36 points for the reweighted add_to_cart_succeeds pass, plus 10 points because confirmation_latency is tested and passes from the add-to-cart confirmation alone (a real, tested reading -- not a free credit -- but it now contributes points a v0.2 report never had access to). Verified against this exact fixture shape (add_to_cart_succeeds pass, checkout_navigation_reached and journey_uninterrupted fail, confirmation_latency pass from the add-to-cart confirmation alone): 40.0 -> 46.0, with no change to the underlying add-to-cart/checkout/journey behavior at all -- see test_score_weighted_buyable_partial_fail_shifts_40_to_46_under_v0_3 in tests/test_titan.py.