ArchitectureIntelligence Pipeline — Shopify Store Intelligence

Intelligence Pipeline — Shopify Store Intelligence

Pipeline d'intelligence Shopify pour BoostEcom : prend une URL en entrée, retourne tout ce qu'on sait/déduit du store, alimente le chat multi-agents et une page discovery filtrable. Spec finalisée 2026-05-12…

Pipeline d'intelligence Shopify pour BoostEcom : prend une URL en entrée, retourne tout ce qu'on sait/déduit du store, alimente le chat multi-agents et une page discovery filtrable. Spec finalisée 2026-05-12. Implémentation en trois phases.

Mise à jour 2026-05-22 (branche claude/elegant-sagan-TRd77) : Phase 1.5 livrée, voir §20 "Phase 1.5, Mega-PR" en bas de doc pour le diff complet vs la spec originelle.

Branche de travail historique : claude/integrate-shared-algorithm-LiTmh.


1. North star

Un merchant ou un agent BoostEcom paste une URL Shopify. En ≤ 15 secondes, on retourne :

  • Catalogue complet + collections + structure de pricing
  • Apps installées (Klaviyo, Judge.me, Loox, Yotpo, Recharge, …) + thème + customisations
  • Stack pixels (Meta, TikTok, Google, Snap, Klaviyo Onsite) + setup server-side
  • Activité publicitaire Meta (ad library) + signaux TikTok visibles
  • Volume reviews + vélocité (Judge.me / Loox / Yotpo / Trustpilot)
  • Estimation sales velocity + AOV + chiffre d'affaires (avec intervalle de confiance explicite)
  • Cadence email (si dispo) + popup capture + post-purchase apps
  • Trajectoire de croissance (7 / 30 / 90 j)
  • Anomalies détectées (changement de thème, restock massif, burst d'ads, etc.)

Cette intelligence nourrit :

  1. Le chat multi-agents — chaque agent reçoit la slice qui le concerne et propose des actions concrètes
  2. Une page discovery filtrable type outil de référence, mais avec recherche en langage naturel et similarity search vectorielle
  3. Une boucle de calibration OAuth : chaque merchant connecté en Shopify Admin API devient un point de vérité terrain qui ré-entraîne les modèles d'inférence

2. Peut-on réellement battre l'outil de référence + SimilarWeb ?

Cette question doit être tranchée honnêtement avant de construire quoi que ce soit. Sinon on construit un produit qui prétend battre un benchmark qu'il n'atteindra pas.

Dimensions où on perdra (à accepter)

DimensionPourquoi on perdPosition défendable
Panel trafic globalSimilarWeb a un panel multi-millions d'utilisateurs + deals FAI. Coût d'entrée à 8-9 chiffres.On ne reconstruit pas. On licence SimilarWeb en wholesale (~$0.10-1/domain selon volume) ou on s'en passe et on se différencie ailleurs.
Couverture stores indexésL'outil de référence a des millions de stores en pré-indexage continu depuis des années.On démarre en on-demand (un user submit, on fetch live). On pré-indexe progressivement uniquement les stores fréquemment demandés.
HistoriqueL'outil de référence a 3+ ans de delta sur des millions de marques.On démarre à T=0. Notre historique se construit avec nos users. C'est OK pour un product de niche.
Email cadence corpusL'outil de référence a une flotte honeypot warmée depuis des années.Voir §17 (risques) : honeypot massif = €20M / 4% de revenu global de fines RGPD. On n'y va pas. On limite à ce qu'on peut obtenir sans honeypot (popups + landing) ou via deals partenaires (Klaviyo benchmark API).

Dimensions où on écrase structurellement (le vrai moat)

DimensionPourquoi on gagneReproductibilité par eux
Précision-par-store-connectéOAuth Shopify Admin = vraie data. Pas d'estimation. Revenu réel, AOV réel, CVR réel, source réelle de chaque commande, LTV, cohortes.Impossible. L'outil de référence n'a pas d'OAuth vers le store Shopify du marchand — ils ont bien un OAuth 2.1 vers leur propre workspace pour leur MCP (cf. mcp-oauth.md §outil de référence), ce qui est une autre chose : on s'y authentifie pour lire leur base, pas pour lire son store. SimilarWeb non plus. C'est notre moat irréplicable.
Boucle de calibrationChaque merchant connecté → on compare prédiction vs vraie data → on ré-entraîne. À 100 stores on bat SimilarWeb sur la précision Shopify spécifique. À 1000 stores l'écart est définitif.Impossible sans pivoter leur business model.
Capacité d'actionMarco peut mutate le theme, Maya peut lancer une campagne, Otis peut shipper un flow Klaviyo. Ils montrent des chiffres, on agit.Hors scope pour eux. C'est une question de positionnement, pas de tech.
Multi-agents experts6 spécialistes (Atlas + Maya/Marco/Otis/Faye/Sam). Chacun lit la couche d'intelligence avec sa lentille métier.Hors scope pour eux. C'est notre architecture native.
Recherche en langage naturel"Stores skincare français qui scalent sur TikTok avec Klaviyo et <50 produits" → parsed en filtres structurés via LLM.Reproductible mais ne fait pas partie de leur DNA.
Similarity searchEmbedding par store (positionnement sémantique). Lookalikes instantanés.Reproductible mais inexistant chez eux à ce jour.
Cadence de refreshPour notre set actif on poll à 1-3h. L'outil de référence annonce 24h, et avec 1-2 semaines de lag sur le trafic (page publique de l'outil).On gagne sur les signaux non-trafic.

Reformulation honnête du positionnement

L'outil de référence est un outil de discovery & spy pour agences/dropshippers. Leur valeur = couverture + filtres.

SimilarWeb est une traffic intelligence généraliste. Leur valeur = panel + historique multi-vertical.

BoostEcom Intelligence est une operator intelligence pour merchants Shopify connectés. Notre valeur = précision sur ton store + concurrents pertinents + capacité d'action multi-agents.

On ne joue pas le même jeu. Et c'est exactement ce qui rend le combat gagnable.

3. Synthèse de la conversation source (avec critique)

La conversation Claude partagée par l'utilisateur (référence : share/1ca8b595-9c30-47a1-a185-80573133458a) pose le cadre. Ce qu'on garde, ce qu'on rejette :

Ce qu'on garde

  • L'outil de référence = wrapper SimilarWeb + orchestrateur de scraping : démystification correcte. Pas de magie technique, c'est de l'agrégation + UX.
  • 40 workers fan-out + façade unifiée : bon cadrage. Pas "1 algo monolithique".
  • Boucle de calibration OAuth comme moat : central. C'est notre arme.
  • LLM-as-extractor pour résilience aux changements de thème : bon usage 2026.
  • Vision models sur creas pour clustering ad creatives : bon usage 2026.
  • Distinction consommer MCP / exposer MCP: on consomme SimilarWeb MCP + MCP de tendances (si abonnement viable) en backend. On n'expose pas notre MCP en v1.

Ce qu'on rejette ou nuance

Point de la conversationNotre positionRaison
"Delta order ID via reviews datées = mesure précise non-extrapolée"Largement obsolèteShopify a fermé l'exposition publique des order IDs sur les checkouts modernes. La technique marche sur <20% des stores en 2026. À ne pas mettre en pierre angulaire.
"inventory_quantity exposé publiquement = signal stock fiable"MinoritaireSur Dawn par défaut oui, sur thèmes premium / customs / Hydrogen non. Disponibilité réelle ~30-40% des stores.
"Stack 4 stores Phase 1 : Postgres + ClickHouse + Qdrant + S3"OverkillNeon Postgres + pgvector + Vercel Blob suffisent. Benchmarks 2026 montrent pgvector + pgvectorscale à 471 QPS vs Qdrant 41 QPS sur 50M vecteurs à 99% recall — 11.4× plus rapide (tigerdata). ClickHouse on l'ajoute quand on aura le volume time-series, pas avant.
"Flotte email honeypot pour capter sequences"À ne pas faire en v1Risque RGPD majeur. €20M / 4% de revenu global de fines. France a déjà fined KASPR €240K pour contact scraping (Apollo). On limite à popup + landing capture.
"Phase 1 en 4-6 semaines"3-4 sem pour scope minimal, 8-12 sem pour Phase 2Sous-estimé pour scope étendu. On découpe plus serré.
"Coût $0.10-0.30 par lookup à froid"$0.50-1.50 réalisteIgnore proxies résidentiels (Browserbase $10-12/GB), browser time ($0.10-0.12/h), Cloudflare bypass sur 99.2% des stores Shopify (scrapfly).
Routing "Faye = Intelligence"Confirmé dans le codeidentity-registry.ts définit explicitement Faye = Intelligence AI. La CLAUDE.md (Faye=Finance) est en retard, à corriger.

4. État du repo existant (audit)

L'audit complet (mené par l'agent Explore sur /home/user/boostecom.app/src/) montre que ~70% de l'infrastructure existe déjà et est production-ready. À réutiliser plutôt qu'à reconstruire :

Brique existanteLocalisationRôle dans le pipeline
Scan orchestrator (Browserbase + Playwright, phases, SSE, rate-limit, SSRF)src/features/tracking/scan/orchestrator.ts v1.8.0Base du crawler. On ajoute des phases.
Probes Shopify (catalog /products.json, theme, framework)src/services/algorithms/intelligence/probes/Fondations catalog crawl. On étend.
Shopify SDK + token refresh OAuthsrc/features/shopify/sdk/, src/features/connectors/OAuth Admin API pour calibration ground truth.
MCP Shopify (18 tools, 0 resource — compte derive par la claim mcp.tools de scripts/check-doc-claims.mjs)src/app/api/mcp/[storeId]/register-tools.tsCôté exécution (Marco). Pas touché.
Job queue QStash + dispatchersrc/services/jobs/dispatcher.ts, services/jobs/types.tsWorkers asynchrones. On ajoute des job types.
Browser/scraper utilitiessrc/lib/browser/, src/lib/scraper/store-meta.tsHeadless + parsing. Réutilisable.
SSE public scan routesrc/app/api/tracking/scan/route.tsPattern pour /api/intelligence/scan.
Agents Atlas + 5 spécialistes (avec mascots, bios, tool catalog)src/features/ai/agents/identity-registry.tsRouting intelligence vers Faye + slices vers les autres.
Prisma : Store, StoreFact, AlgoTrace, StoreReport, Taskprisma/schema.prismaStockage. On étend avec type="INTELLIGENCE" sur StoreReport ou nouveau modèle CompetitorScan.
Memory layer (OrgFact/StoreFact/UserFact + RAG Upstash Vector)spec docs/architecture/memory-layer.mdPersistence facts. On pousse les facts intelligence dedans.
Cron infrasrc/app/api/cron/*, vercel.jsonRefresh asynchrone.

Ce qui manque (le scope réel à construire)

ManqueAction
Probes apps / pixels / reviews / Meta Ad LibraryCréer dans src/services/algorithms/intelligence/probes/
Modèle de données pour snapshots intelligenceÉtendre StoreReport avec type="INTELLIGENCE" ou nouveau CompetitorScan
Sales velocity inference engineCréer src/services/algorithms/intelligence/inference/
Calibration OAuth ground truthService dans src/services/algorithms/intelligence/calibration/
Tools agents pour appeler l'intelligenceAjouter à src/features/ai/agents/agents/specialists.ts
Dashboard discovery (UI filtres + NL search)Créer src/app/(dashboard)/[orgSlug]/intelligence/
Vector index pour similarity searchpgvector sur Neon, namespace store-intelligence

Implication architecturale : on ne crée pas un nouveau pipeline parallèle. On étend l'orchestrator de tracking/scan avec 4 nouvelles phases, on étend le schema Prisma avec un champ ou un modèle, et on étend chaque agent avec 1-2 tools ciblés. Le scope réel est ~30% de code nouveau.

5. Architecture cible

5.1 Vue d'ensemble

[User submit URL]                            [Agent ask via chat]
       ↓                                            ↓
       └──────────────┬─────────────────────────────┘
                      ↓
           [/api/intelligence/scan]
              (SSE, rate-limit, SSRF)
                      ↓
            [Cache check Redis]
                ↓ HIT → return (<500ms)
                ↓ MISS
       [Validation Shopify fingerprint] (200ms)
                      ↓
       [Parallel fan-out, budget 15s max]
       ├─ catalog_crawler          (3-8s)
       ├─ sitemap_parser           (1-3s)
       ├─ storefront_renderer + LLM extract (2-3s)
       ├─ apps_detector            (1-2s)
       ├─ pixels_detector          (1-2s)
       ├─ theme_detector           (1s)
       ├─ reviews_aggregator       (2-3s)
       ├─ meta_ad_library_fetcher  (2-3s)
       └─ traffic_proxy (SimilarWeb wholesale, optionnel)
                      ↓
       [Inference engine]          (1-2s)
       ├─ sales_velocity_estimator
       ├─ revenue_estimator (avec CI)
       ├─ growth_trajectory
       └─ anomaly_detector
                      ↓
       [Persist to Prisma + pgvector index]
                      ↓
       [Stream response → user / agent]
                      ↓
       [Background jobs (QStash)]
       ├─ periodic_recrawl (1-6h selon priorité)
       ├─ ad_library_refresh
       └─ calibration_compare (si store OAuth-connected)

Latence cible :

  • Cold path : 12-20 secondes
  • Hot path (cache) : <500 ms
  • Background refresh : asynchrone, non bloquant

5.2 Workers par domaine

Domaine A — Catalog & inventory

WorkerMéthode validéeSource
shop_resolverFingerprint Shopify : cdn.shopify.com, Shopify.theme, __st ; normalise myshopify.com vs custom domainecomm.design
catalog_full_crawler/products.json?limit=250&page=N, paginé. Délais 1-2s entre pages, 5s+ entre stores. Public, pas d'auth.DEV/agenthustler
collections_crawler/collections.json + /collections/{handle}/products.json. L'ordre = signal best-seller.Validation au Phase 2
sitemap_watchersitemap.xml, sitemap_products_*.xml. Dates published_at / updated_at.Standard Shopify
catalog_delta_watcherRepoll 1-6h selon priorité, émet diffs (new product, removed, price change, available flip).Phase 2
variant_inventory_sniffer/products/{handle}.js quand inventory_quantity exposé. ~30-40% des stores seulement en pratique.Mesure terrain à valider

Domaine B — Storefront stack

WorkerMéthode
homepage_rendererBrowserbase headless, screenshot + DOM. Coût ~$0.10-0.12/h browser time.
theme_detectorLecture Shopify.theme JS object + fingerprint Dawn/Symmetry/Impulse/Prestige/Showcase/Empire/Motion/Pipeline.
app_detectorPatterns DOM/scripts pour ~200 apps mainstream (Klaviyo, Judge.me, Loox, Yotpo, Postscript, Recharge, Bold, Vitals, ReConvert, Zipify, …).
pixel_detectorMeta fbq, TikTok ttq, Google gtag, Snap snaptr, Pinterest, Klaviyo Onsite. Server-side via Addingwell. Déjà partiellement implémenté dans tracking/scan.
tech_stack_detectorCDN, currency converter, A/B testing, headless setup, custom checkout.
pdp_rendererRender Product Detail Page : variantes visibles, reviews intégrées, cross-sells.

Domaine C — Reviews & social proof

WorkerMéthode
judge_me_scraperPublic API + Apify fallback. Cap rate ~1 req/s.
loox_scraperAPI publique + Apify.
yotpo_scraperPublic review widget API.
stamped_scraper, okendo_scraper, trustpilot_scraperPatterns équivalents.
review_velocity_aggregatorCombine sources → reviews/jour × inverse-rate niche (5-15%) → orders/jour estimés. Signal critique pour sales velocity.
social_handle_extractorFooter + structured data → IG/TikTok/YT handles.

Domaine D — Ad intelligence

WorkerMéthodeLimites
meta_ad_library_fetcherImplémenté — deux adaptateurs chaînés (ad-library/index.ts). meta-graph = l'API officielle ads_archive, gratuite, synchrone, avec les disclosures DSA (reach, ciblage, payeur) ; apify = l'acteur navigateur payant en repli. Gratuit d'abord, payant seulement si le gratuit revient vide. Clé : META_AD_LIBRARY_TOKEN.Rate limit 200 calls/heure (Meta). ad_type=ALL ne rend les pubs COMMERCIALES que pour l'UE/UK — ailleurs l'endpoint renvoie du politique uniquement, d'où le repli Apify pour les marchés hors UE.
tiktok_creative_center_fetcherScrape Creative Center + CCL (Commercial Content Library) pour EU/EEA/UK/Switzerland.Pas d'API commerciale : Commercial Content API réservée chercheurs/journalistes accrédités. Apify scrapers ou crawl direct.
creative_vision_analyzerCLIP embeddings + LLM extract (hooks, format, UGC/studio, text overlay, faces).Phase 2. Coût ~$0.001-0.005/créa.
creative_clustererpgvector + HDBSCAN sur embeddings → familles créatives.Phase 2.
landing_page_matcherLie chaque créa à la PDP poussée.Phase 2.
ad_velocity_trackerDays running, scaling, freeze/unfreeze.Phase 2.

Domaine E — Email & lifecycle (scope réduit)

WorkerMéthodeNote
popup_capture_extractorRender homepage avec exit-intent, capture l'offre d'entrée.OK légalement.
signup_flow_extractorDétecte les SDKs (Klaviyo Onsite, Privy, …) et les paramètres de capture.OK légalement.
klaviyo_benchmark_consumerSi deal Klaviyo conclu : benchmark API officielle.À explorer en Phase 3.
email_honeypot_subscriberHors scopeRisque RGPD majeur. €20M / 4% global revenue.

Domaine F — Traffic & audience

Phase 1 : on licence. SimilarWeb a une API + un MCP server officiel (similarweb.com/corp/ai/mcp). Plans Starter $149/mo (limité), Pro $399/mo, Enterprise ~$16K/an. Datos et Semrush sont des alternatives. Pour les stores connectés en OAuth, on remplace l'estimation par la vraie data Shopify Admin.

Phase 3+ : exploration d'un signal trafic propriétaire via :

  • Referral network mapping (Ahrefs/Majestic backlinks)
  • Social attention proxy (mentions IG/TikTok/Reddit)
  • Ad spend visible → proxy directionnel

Domaine G — Pricing & commercial

WorkerDescription
price_trackerChangements de prix par variante dans le temps.
discount_pattern_analyzerFréquence + profondeur discount, codes détectés.
currency_market_detectorCurrency switcher → marchés servis.
shipping_policy_extractorSeuils free shipping, zones, délais.
subscription_detectorRecharge / Bold / Loop fingerprint.
brand_age_estimatorTrois ancres : la date de création au registre (RDAP, sonde domain_registration), la première capture archive.org, le plus ancien published_at du catalogue. Le registre décide quand il répond — voir la note ci-dessous.

La date de création : deux bornes, pas une mesure

La date d'ouverture d'une boutique n'est observable par aucune sonde. Trois signaux l'encadrent, et ils ne tombent pas du même côté :

SignalSondeCe qu'il borne
identity.domain_registered_atdomain_registration (RDAP)borne basse — la boutique ne peut pas précéder l'adresse à laquelle elle est servie
identity.first_archive_atwaybackborne haute — la boutique existait au plus tard à cette date
catalog.first_product_atcatalogborne haute également, et instable : un ré-import de catalogue fait naître de nouveau une boutique de quatre ans

La vraie date vit dans [registered_at, min(archive, product)]. Le hub affichait le haut de cet intervalle : valisedemo.com sortait en « Nov 2025 » là où le registre date le domaine de juillet. Le choix vit désormais dans une seule fonction, creationAnchor (src/lib/hub/helpers.ts), lue par les trois surfaces qui affichent une date de création (l'en-tête du détail, la ligne de listing, le tri « nouvelles boutiques »).

RDAP (RFC 9082 / 9083) est le remplaçant IETF du WHOIS port-43 : HTTPS, JSON, aucune clé, aucun contrat. La sonde ne figure donc dans aucun tableau de intelligence-env-matrix.md, il n'y a pas de variable à poser. Elle se saute elle-même une fois les deux champs enregistrés : un évènement de création est immuable.

5.3 Couche storage

DonnéeStoreJustification
Entités (Store, Product, Brand, Snapshot)Neon Postgres (existant)ACID, relations propres, déjà en place.
Time-series snapshots (catalog versions, prices, ad runs)Neon Postgres avec partitions Phase 1, ClickHouse Phase 3 si volume >50M lignesÉvite l'ops d'un store supplémentaire jusqu'au moment où c'est vraiment nécessaire.
Embeddings (store positioning, products, créas)pgvector + pgvectorscale sur Neon11.4× plus rapide que Qdrant à 50M vecteurs (tigerdata). Pas de service séparé.
Object brut (HTML rendu, screenshots, créas vidéo)Vercel Blob (existant)Coût marginal.
Cache hot-pathUpstash Redis (existant)TTL court (5-30 min) sur les lookups récents.
Full-text + faceted filters (page discovery)Postgres tsvector + GIN indexes Phase 1, Meilisearch Phase 2 si volumeÉvite encore un store en v1.

5.4 Couche inférence

sales_velocity_estimator

Modèle bayésien qui combine signaux pondérés par confiance :

P(orders/day | store) = weighted_combine([
  inventory_delta_signal × C_inv,        // 0.0 à 0.4 selon exposition publique
  review_velocity_signal × C_review,     // 0.3 à 0.6 selon plateforme
  ad_spend_proxy_signal × C_ad,          // 0.2 à 0.4
  best_seller_rank_signal × C_rank,      // 0.1 à 0.3
  oauth_groundtruth_signal × 1.0         // 1.0 si store OAuth-connected
])

Pour les stores OAuth-connected : on utilise la vraie data Shopify Admin. C'est le ground truth.

Pour les stores non-connectés : on retourne la prédiction avec son intervalle de confiance. Pas un chiffre unique. L'outil de référence affiche un chiffre, on affiche 42 ± 18 orders/day, confidence 0.6. C'est plus honnête et c'est un signal différenciant.

calibration_engine

Pour chaque store OAuth-connected :

  1. À chaque scan complet, calcule la prédiction "comme si non-connecté"
  2. Compare à la vraie data Admin API
  3. Persiste l'erreur par dimension dans AlgoTrace (table déjà existante !)
  4. Une fois par semaine, recalcule les coefficients de pondération du modèle

À 100 merchants connectés on a un signal exploitable. À 1000 on bat SimilarWeb sur la précision Shopify spécifique. C'est notre moat irréplicable : ni l'outil de référence ni SimilarWeb n'ont accès à l'Admin API en masse.

5.5 Routing vers les agents

L'agent receveur est défini dans src/features/ai/agents/identity-registry.ts. Mapping confirmé :

AgentRôleSlice intelligence consommée
AtlasOrchestrator (CRO)Vue méta + revenue trajectory + anomalies cross-domain. Route les questions.
MayaTraffic AI (Acquisition)Ad creatives + creative families + testing cadences + landing match + pixels + traffic sources
MarcoConversion AI (Merchandising)Theme + apps + funnel fingerprint + pixel coverage + catalog structure + pricing + benchmarks niche
OtisLifecycle AI (CRM)Email/SMS apps détectées + popup capture + signup flow + post-purchase apps + reviews lifecycle
FayeIntelligence AIRouteur intelligence + détection anomalies + fan-out aux autres agents. C'est l'agent qui détient le pipeline.
SamRetention AI (Support)Reviews velocity + sentiment + social proof + support stack + post-purchase apps

Note de correction : la CLAUDE.md actuelle décrit "Faye = Finance". Le code (identity-registry.ts) dit explicitement "Faye = Intelligence AI". On suit le code. La CLAUDE.md sera corrigée dans la même PR.

5.6 Surfaces produit

Surface 1 — Chat multi-agents

Dès qu'un user paste une URL dans le chat, OU dès qu'un merchant connecte son store Shopify :

  1. Atlas reçoit l'évent → délègue à Faye
  2. Faye lance /api/intelligence/scan (SSE)
  3. Pendant le scan, Atlas peut déjà dire au user : "Je suis en train d'auditer ton store, je reviens dans 15s avec un brief complet."
  4. Quand le scan est complet, Faye fan-out les slices aux agents pertinents
  5. Atlas synthétise un message d'audit + propose 3 actions priorisées

Le wow effect : la première vraie interaction avec @Atlas n'est jamais "comment puis-je t'aider ?". C'est un audit complet en 30s avec recommandations. Ça, aucun outil ne le fait aujourd'hui.

Surface 2 — Page discovery /[orgSlug]/intelligence

Reproduit l'UX de l'outil de référence mais nourrie par notre inférence :

Filtres v1 (parité de l'outil de référence) :

  • Pays / langue / devise
  • Niche (clustering vectoriel)
  • Plage trafic / plage CA estimé (avec IC)
  • Croissance % (7 / 30 / 90 j)
  • Nombre d'ads actives
  • Apps installées (multi-select)
  • Thème détecté
  • Pixels actifs
  • Note Trustpilot / Judge.me
  • Vélocité reviews
  • Âge marque, nombre de produits, AOV
  • Multi-pays oui/non
  • Headless oui/non

Filtres différenciants v1 :

  • "Stores en accélération" : sales_velocity ↑ 30%+ semaine sur semaine
  • "Anomalies récentes" : changement stack détecté
  • "Similar to X" : vector search sur l'embedding store
  • "Connected merchants only" : montre seulement les stores avec calibration ground truth → précision maximale (utile pour benchmarks internes)

Arme nucléaire UX — recherche en langage naturel :

"trouve-moi des stores skincare français qui scalent sur TikTok avec Klaviyo et qui font moins de 50 produits"

Parsing via LLM (Haiku ou Gateway) en filtres structurés → query Postgres + pgvector. L'outil de référence n'a pas ça, et c'est trivial à construire si les indexes sont bons.

La porte des surfaces — readStoreIntelligence

Depuis intelligence/2760 (audit docs/audits/2026-09-17-une-source-de-verite-pour-le-store.md), une surface qui veut « ce que BoostEcom sait d'un store » passe par services/algorithms/intelligence/read-store-intelligence.ts : une lecture Prisma, la porte de partage appliquée pour une audience public, l'identité démasquée pour une audience member (l'appelant a déjà vérifié l'appartenance), et deux formes en retour — record pour une page qui itère les sections, hub (HubStore) pour tout chiffre affiché. getItemByDomain lit par lui. Les lecteurs directs restants sont énumérés par src/test/store-intelligence-has-one-reader.test.ts, une liste qui ne peut que rétrécir.

Trois audiences, pas deux. Un AGENT TIERS (un client MCP branché sur /api/mcp/intelligence) est plus restreint qu'un visiteur : la règle est isAgentVisible, strictement PUBLIC, là où la page publique accepte aussi UNLISTED. La différence est le sens d'UNLISTED — « partagé par lien » veut dire que l'opérateur donnera le lien à quelqu'un, et un porteur de jeton MCP n'est pas ce quelqu'un, d'autant que ce qu'il demande est la projection par champ (secrets de stack, URL de créatives, domaines d'envoi).

AppelantPorteVisibilités admises
Page publique, fiche orgreadStoreIntelligence(…, { audience: "public" })PUBLIC, UNLISTED
Agent tiers (MCP Intelligence)readPublicIntelligence(domain) ou intelligenceIsPublic(domain)PUBLIC
Le marchand, sur sa propre boutiquereadStoreIntelligence(…, { audience: "member" })toutes, identité démasquée

intelligenceIsPublic existe pour que la garde ne coûte pas une lecture du blob JSONB quand l'appelant lit le record par une autre porte : elle rend un booléen, jamais un champ. agentVisibilityFilter() est la même règle en fragment where, pour les requêtes de liste.

Un enregistrement retiré et un domaine inconnu répondent la même chose à un agent. Les distinguer est un oracle : un appelant peut énumérer les domaines que nous détenons en les demandant un par un.

6. Cold-start strategy (T=0 à T=12+ mois)

À T=0 : zéro merchant connecté, zéro index propriétaire, zéro ground truth. La section §1 dit "on démarre on-demand" : correct mais incomplet. La vraie question : comment on délivre un wow effect dès le premier user, avant d'avoir notre propre data ?

Réponse : pendant 0-12 mois, on consomme des sources externes en backend, on les enveloppe dans notre orchestration multi-agents, et on construit progressivement notre propre couche. Pattern wrapper → puis émancipation.

6.1 Sources bootstrap disponibles à T=0

SourceCoûtUsageLégitimité
Direct fetch endpoints publics Shopify (/products.json, /collections.json, sitemap)Gratuit80% des stores, pas d'authOK — endpoint documenté Shopify, public
Meta Ad Library APIGratuit (200 calls/h)Tous paid ads actifs EU (DSA mandate)OK — API officielle Meta
Firecrawl$16/mo (Hobby 3K pages) à $83/mo (Standard 100K pages)HTML + LLM extract (theme, apps, pixels, copy) avec anti-bot CF géréOK — service commercial managed
Apify actors (Judge.me, Loox, Yotpo, Trustpilot, Shopify Product Scraper)Pay-per-use ~$30-50/moReviews + catalog scraping prêt à l'emploiOK — marketplace officiel
MCP de tendances backend$99-249/moConsommé en backend par Faye. Le user ne voit pas la source derrière.OK — abonnement payant licite
SimilarWeb MCP backend$149-399/moTrafic + audience pour stores non-connectésOK — abonnement payant licite
Ta propre store Nacre Bijoux (déjà connectée)$0Premier point de calibration ground truthAcquis
Beta merchant program (10-50 merchants early access)6 mois gratuit en échange de calibration dataPremiers vrais points OAuth ground truth + feedback produitOK avec consent UI explicite

6.2 Trajectoire wrapper → émancipation

PériodeStack data dominant% propriétaire% externe
T=0 à T=3 moisMCP de tendances (backend) + SimilarWeb MCP (optionnel) + nos probes basiques (catalog, theme, apps)30%70%
T=3 à T=6 moisNos probes étendues (reviews, ads via Meta API, creas via vision) + l'outil de référence en fallback60%40%
T=6 à T=12 moisPipeline propriétaire dominant + 100-1000 merchants OAuth-connected → calibration active80%20%
T=12+ moisIndépendance technique sur signaux Shopify-spécifiques. SimilarWeb reste consommé pour trafic global.90%10%

C'est exactement le pattern que l'outil de référence a suivi (ils sont toujours wrapper SimilarWeb pour le trafic à T+3 ans). La différence : on annonce cette trajectoire en interne pour ne pas se perdre dans un narratif "tout propriétaire dès le jour 1" intenable.

6.3 Beta merchant program (recrutement calibration)

Phase 1-2 : on recrute 20-50 merchants Shopify en accès gratuit 6 mois contre :

  • Consent explicite à utiliser leur data OAuth comme point de calibration (jamais publié, jamais partagé avec d'autres users, agrégats internes only)
  • Feedback structuré sur les briefs intelligence

Cible : merchants $50K-$2M ARR (mid-market). Trop petit = pas de signal exploitable ; trop gros = on n'aura jamais accès.

Cette phase est non-tech mais critique stratégique : sans ground truth, l'inférence en Phase 2 sera médiocre. À traiter comme une couche du produit, pas comme du marketing.


7. Stack scraping — décision tranchée

Bench des 4 providers managed leaders 2026. Recommandation finale ci-dessous (cf. ma reco utilisateur confirmée).

7.1 Comparaison des providers

ProviderCoût entréeCoût scale (100K pages/mo)Anti-bot CFLLM extract natifUse case BoostEcom
Firecrawl$16/mo (Hobby 3K)$83/mo Standard✓ Géré✓ /extract endpoint avec schémaPrimaire : HTML + extraction structurée robuste aux changements de thème
BrowserbasePay-per-use$0.10-0.12/h browser + proxy résidentiel $10-12/GB✓ Géré✗ (browser raw)Secondaire : cas browser-heavy (interaction, scroll, CF challenge interactif). Déjà dans src/lib/browser/.
Scrapfly$30/mo$99-199/mo✓ Géré⚠ Via post-processingAlternative à Firecrawl, plus enterprise. Pas d'avantage net en Phase 1.
ApifyPay-per-use~$30-100/mo selon volume⚠ Variable selon actorVia actors customTertiaire : actors prêts Judge.me / Loox / Yotpo / Trustpilot. Évite 4-6 sem de redev.
Bright Data Scraping Browser$0.05/h + proxy$1000+/mo enterprise✓✓ Le plus robuste✗Quaternaire : tier 3 escalation pour <5% stores high-end protected. Pas Phase 1.

7.2 Décision finale : stack hybride 4 tiers

Tier 0 — Direct fetch (Node undici)
  → Shopify public endpoints (/products.json, /collections.json, sitemap)
  → Meta Ad Library API
  → ~80% des cas accessibles ainsi, coût $0

Tier 1 — Firecrawl (primaire)
  → HTML rendering + LLM extract (theme, apps, pixels, copy)
  → Robustesse aux changements de thème Shopify
  → Budget : Hobby $16/mo → Standard $83/mo après volume

Tier 2 — Apify actors (spécialisés reviews)
  → Judge.me actor, Loox actor, Yotpo actor, Trustpilot actor
  → ~$30-50/mo pay-per-use Phase 1
  → Évite 4-6 sem de dev custom

Tier 3 — Browserbase (browser-heavy)
  → Interaction (scroll, click "view more reviews", checkout simulation)
  → ~15-20% des cas qui ne tiennent pas en HTTP simple
  → Déjà dans `src/lib/browser/`, aucune nouvelle intégration

Tier 4 — Bright Data Scraping Browser (escalade)
  → Stores avec Datadome ou Cloudflare Bot Management agressif
  → <5% des cas, coût ×10
  → Pas en Phase 1, on signale et on skip

7.3 Coût mensuel Phase 1 (estimation 200 scans/jour, 60% cache hit)

PosteMensuel
Firecrawl Standard$83
Browserbase (proxy ~30% scans)$40-80
Apify (reviews scrapers)$30-50
MCP de tendances backend (cold-start hack §6)$99-249
SimilarWeb MCP (optionnel Phase 1)$0 (skip) ou $149
Bright Data (escalade)$0 Phase 1
Total Phase 1 sans SimilarWeb~$250-460/mo
Total Phase 1 avec SimilarWeb~$400-610/mo

7.4 Pourquoi pas du tout-DIY ?

DIY (Selenium Stealth / Puppeteer Stealth / proxies résidentiels directs) = clampdown 2026 sur Cloudflare. Coût d'ops humain >> coût des services managed. ROI net : managed providers, on focus sur l'inférence et l'UX qui sont notre vrai moat.


8. Modèle Prisma

Extension de StoreReport (déjà existant) avec un nouveau type discriminant et un champ JSON typé via Zod. Pas de nouveau modèle pour minimiser la dette schema.

8.1 Schéma Prisma

model StoreReport {
  id          String   @id @default(cuid())
  storeId     String
  store       Store    @relation(fields: [storeId], references: [id], onDelete: Cascade)
  type        StoreReportType
  status      StoreReportStatus
  source      StoreReportSource  // NEW: tier scraping utilisé
  result      Json               // Typé Zod en runtime (§9)

  // Discovery-search optimization (NEW)
  domain      String?            @db.VarChar(255)
  niche       String?            @db.VarChar(120)
  apps        String[]           // GIN index pour multi-select filter
  themeId     String?
  countryCode String?            @db.VarChar(2)

  // Confidence & calibration (NEW)
  overallConfidence Float?       // [0.0, 1.0]
  isGroundTruth     Boolean      @default(false)  // True si OAuth-connected

  // Embeddings vectoriels (NEW, pgvector)
  embedding   Unsupported("vector(1536)")?

  scannedAt   DateTime @default(now())
  expiresAt   DateTime?           // TTL cache (§10)

  @@index([storeId, type, scannedAt(sort: Desc)])
  @@index([domain])
  @@index([niche])
  @@index([type, isGroundTruth])
  @@index([apps], type: Gin)
  // Vector index créé manuellement via migration :
  // CREATE INDEX ON "StoreReport" USING hnsw (embedding vector_cosine_ops);
}

enum StoreReportType {
  TRACKING        // existant — scan tracking pipeline
  COMPETITOR      // existant
  INTELLIGENCE    // NEW
}

enum StoreReportStatus {
  PENDING
  RUNNING
  SUCCEEDED
  PARTIAL          // NEW — certaines phases ont échoué, le brief reste utile
  FAILED
}

enum StoreReportSource {
  PROPRIETARY      // notre pipeline
  MARKET_MCP   // wrappé en backend
  SIMILARWEB_MCP   // wrappé en backend
  HYBRID           // mix proprio + externe
}

8.2 Migration SQL

ALTER TABLE "StoreReport"
  ADD COLUMN "source" TEXT NOT NULL DEFAULT 'PROPRIETARY',
  ADD COLUMN "domain" VARCHAR(255),
  ADD COLUMN "niche" VARCHAR(120),
  ADD COLUMN "apps" TEXT[] DEFAULT ARRAY[]::TEXT[],
  ADD COLUMN "themeId" TEXT,
  ADD COLUMN "countryCode" VARCHAR(2),
  ADD COLUMN "overallConfidence" DOUBLE PRECISION,
  ADD COLUMN "isGroundTruth" BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN "embedding" vector(1536),
  ADD COLUMN "expiresAt" TIMESTAMP(3);

CREATE EXTENSION IF NOT EXISTS vector;

CREATE INDEX IF NOT EXISTS "StoreReport_domain_idx" ON "StoreReport"("domain");
CREATE INDEX IF NOT EXISTS "StoreReport_niche_idx" ON "StoreReport"("niche");
CREATE INDEX IF NOT EXISTS "StoreReport_type_groundtruth_idx" ON "StoreReport"("type", "isGroundTruth");
CREATE INDEX IF NOT EXISTS "StoreReport_apps_gin_idx" ON "StoreReport" USING GIN("apps");
CREATE INDEX IF NOT EXISTS "StoreReport_embedding_hnsw_idx" ON "StoreReport" USING hnsw(embedding vector_cosine_ops);

ALTER TYPE "StoreReportType"  ADD VALUE IF NOT EXISTS 'INTELLIGENCE';
ALTER TYPE "StoreReportStatus" ADD VALUE IF NOT EXISTS 'PARTIAL';

8.3 AlgoTrace réutilisé pour calibration

Le modèle existant capture déjà ce qu'il faut pour la calibration OAuth (cf. §5.4). Pas de migration ici. Champs expected (prédiction) + actual (Admin API ground truth) + metrics (erreur par dimension).


9. API contracts (TypeScript + Zod)

9.1 IntelligenceObject — shape de StoreReport.result pour type=INTELLIGENCE

À placer dans src/services/algorithms/intelligence/canonical/schema.ts :

import { z } from "zod";

export const ConfidenceInterval = z.object({
  point: z.number(),
  low: z.number(),
  high: z.number(),
  confidence: z.number().min(0).max(1),
});

export const CatalogSnapshot = z.object({
  productCount: z.number().int(),
  collectionCount: z.number().int(),
  priceMedian: z.number(),
  priceP90: z.number(),
  currency: z.string().length(3),
  publishedRecent7d: z.number().int(),
  newProductsRecent30d: z.number().int(),
});

export const StorefrontStack = z.object({
  theme: z.object({
    id: z.string(),
    name: z.string(),
    isCustomized: z.boolean(),
    version: z.string().optional(),
  }),
  apps: z.array(z.object({
    handle: z.string(),
    name: z.string(),
    category: z.enum([
      "email", "reviews", "upsell", "subscription", "shipping",
      "search", "analytics", "popup", "loyalty", "wishlist", "other",
    ]),
    confidence: z.number().min(0).max(1),
  })),
  pixels: z.array(z.object({
    platform: z.enum(["meta", "tiktok", "google", "snap", "pinterest", "klaviyo_onsite"]),
    id: z.string().optional(),
    isServerSide: z.boolean().optional(),
  })),
  techStack: z.object({
    cdn: z.array(z.string()),
    headless: z.boolean(),
    abTesting: z.array(z.string()),
    customCheckout: z.boolean(),
  }),
});

export const AdActivity = z.object({
  meta: z.object({
    activeAdsCount: z.number().int(),
    spendBandEU: z.string().optional(),
    audienceCountries: z.array(z.string()),
    formats: z.record(z.string(), z.number()),
    creativeCount: z.number().int(),
  }).optional(),
  tiktok: z.object({
    visibleActiveAds: z.number().int(),
  }).optional(),
});

export const ReviewsSignal = z.object({
  platforms: z.array(z.object({
    platform: z.enum(["judge_me", "loox", "yotpo", "stamped", "okendo", "trustpilot"]),
    totalReviews: z.number().int(),
    rating: z.number().min(0).max(5),
    velocityPerDay: z.number(),
    recent30dCount: z.number().int(),
  })),
  aggregateVelocityPerDay: z.number(),
});

export const SalesVelocityEstimate = z.object({
  ordersPerDay: ConfidenceInterval,
  aovEstimate: ConfidenceInterval,
  revenuePerMonth: ConfidenceInterval,
  modelVersion: z.string(),
  signals: z.object({
    inventoryDelta: z.number().nullable(),
    reviewVelocity: z.number(),
    adSpendProxy: z.number().nullable(),
    bestSellerRank: z.number().nullable(),
    oauthGroundTruth: z.number().nullable(),
  }),
});

export const GrowthTrajectory = z.object({
  classification: z.enum(["scaling", "stable", "decline", "unknown"]),
  velocityChange7d: z.number().nullable(),
  velocityChange30d: z.number().nullable(),
  velocityChange90d: z.number().nullable(),
});

export const Anomaly = z.object({
  type: z.enum([
    "theme_change", "stack_change", "pixel_added", "pixel_removed",
    "ad_burst", "restock_massive", "pricing_shift", "stockout_persistent",
  ]),
  detectedAt: z.string().datetime(),
  details: z.record(z.string(), z.unknown()),
  severity: z.enum(["info", "notable", "high"]),
});

export const IntelligenceObject = z.object({
  schemaVersion: z.literal("1.0"),
  storeDomain: z.string(),
  shopifyShop: z.string().optional(),
  scannedAt: z.string().datetime(),
  source: z.enum(["PROPRIETARY", "MARKET_MCP", "SIMILARWEB_MCP", "HYBRID"]),

  catalog: CatalogSnapshot.nullable(),
  storefront: StorefrontStack.nullable(),
  ads: AdActivity.nullable(),
  reviews: ReviewsSignal.nullable(),
  salesVelocity: SalesVelocityEstimate.nullable(),
  growth: GrowthTrajectory.nullable(),
  anomalies: z.array(Anomaly),

  overallConfidence: z.number().min(0).max(1),
  isGroundTruth: z.boolean(),
  partialPhases: z.array(z.string()),
});

export type IntelligenceObject = z.infer<typeof IntelligenceObject>;

9.2 Tools agents : signatures Zod pour specialists.ts

// src/features/ai/agents/tools/intelligence.ts
import { z } from "zod";
import { tool } from "ai";

export const auditStore = tool({
  description: "Run a full intelligence audit on a Shopify store URL. Returns catalog, stack, ads, reviews, sales velocity estimate (with CI), growth trajectory, anomalies. Use for both the merchant's own store and competitor stores. Cached for 6h.",
  inputSchema: z.object({
    url: z.string().url(),
    forceRefresh: z.boolean().default(false),
  }),
  execute: async ({ url, forceRefresh }, ctx) => { /* /api/intelligence/scan */ },
});

export const getCompetitorCatalog = tool({
  description: "Get catalog snapshot of a competitor Shopify store: products, collections, pricing distribution, recent additions. Faster than full audit. Cached for 1h.",
  inputSchema: z.object({
    url: z.string().url(),
    filters: z.object({
      collection: z.string().optional(),
      priceRange: z.tuple([z.number(), z.number()]).optional(),
      publishedAfter: z.string().datetime().optional(),
    }).optional(),
  }),
  execute: async ({ url, filters }) => { /* ... */ },
});

export const getCompetitorAds = tool({
  description: "Get Meta + TikTok ad activity of a competitor: active ads count, spend bands EU (DSA), audience countries, top creatives. Cached for 4h.",
  inputSchema: z.object({
    url: z.string().url(),
    platforms: z.array(z.enum(["meta", "tiktok"])).default(["meta", "tiktok"]),
    sinceDays: z.number().int().min(1).max(90).default(30),
  }),
  execute: async ({ url, platforms, sinceDays }) => { /* ... */ },
});

export const searchSimilarStores = tool({
  description: "Find Shopify stores similar to a given one based on semantic positioning (niche, customer type, price tier, USP). Phase 2.",
  inputSchema: z.object({
    referenceUrl: z.string().url(),
    limit: z.number().int().min(1).max(50).default(10),
    filters: z.object({
      country: z.string().length(2).optional(),
      minProductCount: z.number().int().optional(),
      hasActiveAds: z.boolean().optional(),
    }).optional(),
  }),
  execute: async ({ referenceUrl, limit, filters }) => { /* ... */ },
});

9.3 Routing par agent (Phase 1)

AgentTools attribués Phase 1Slice IO consommée
AtlasauditStore (orchestration) + délégation FayeVue méta + revenue trajectory + anomalies
FayeauditStore, searchSimilarStoresFull IntelligenceObject
MarcogetCompetitorCatalogcatalog + storefront + pricing
MayagetCompetitorAdsads + storefront.pixels
Otis(slice only Phase 1)storefront.apps filtré email/popup + lifecycle apps
Sam(slice only Phase 1)reviews + social proof

10. Cron & refresh policy

10.1 Tiers de priorité

État réel, dérivé de refresh-tiers.ts et de vercel.json. La table qui figurait ici annonçait WARM toutes les 12h, COLD « pas de refresh auto » et un tier FROZEN : les trois étaient faux.

TierStores éligiblesCronMode de scan
HOTStores OAuth-connected des merchants BoostEcom, vivantsintelligence/refresh-hot, 0 */1 * * *quick
hot-deepLes mêmes, plus un working set spy-full (même barre de demande que WARM, plus un cooldown de 14j par store)intelligence/refresh-hot-deep, 20 1,7,13,19 * * *full
WARMStores externes PUBLIC vivants, ≥ 3 lookups en 7jintelligence/refresh-warm, 0 */6 * * *quick
COLDStores externes PUBLIC vivants portant déjà un maxLayer, non WARMintelligence/refresh-cold, 30 3 * * *quick

Il n'y a pas de tier FROZEN : le sélecteur n'a jamais été écrit, et l'en-tête de refresh-tiers.ts explique ce que son absence coûte.

10.2 Implementation

Un seul job type Intelligence existe dans src/services/jobs/types.ts :

type JobPayloadMap = {
  // ... existant
  "intelligence-scan-store": {
    domain: string
    mode?: "quick" | "full" | "competitor"
    probeSet?: "on_demand"
    source?: string
    storeId?: string
  };
};

Les trois autres job types annoncés ici (intelligence-refresh-hot, intelligence-refresh-warm, intelligence-calibrate-oauth) n'ont jamais été écrits : les crons de refresh appellent directement runRefreshBatch.

Cron schedules réellement déclarés dans vercel.json :

{
  "crons": [
    { "path": "/api/cron/intelligence/refresh-hot",      "schedule": "0 */1 * * *" },
    { "path": "/api/cron/intelligence/refresh-warm",     "schedule": "0 */6 * * *" },
    { "path": "/api/cron/intelligence/refresh-cold",     "schedule": "30 3 * * *" },
    { "path": "/api/cron/intelligence/refresh-hot-deep", "schedule": "20 1,7,13,19 * * *" }
  ]
}

Il n'y a pas de cron intelligence/calibrate : ce chemin, annoncé ici en 0 4 * * 0, n'a jamais existé.

10.3 Runs ad-library : start-now / collect-later

Les actors ad-library (Apify) tournent dans un vrai navigateur et prennent des MINUTES. Aucune requête de scan ne peut attendre ça. Le contrat est donc : le provider démarre le run et rend la main, puis un collecteur lit le résultat plus tard.

C'est la moitié « lire plus tard » qui manquait, et c'est ce qui a brûlé le budget Apify :

ÉtageCe qui se passaitConséquence
Le handle du run vivait dans une clé KV à TTL 30 minRien ne le relisait avant 1h (tenant) ou 6h (store espionné)Le handle expirait toujours avant qu'on lise le dataset
Aucun cooldown au démarrage (seulement à la complétion, jamais atteinte)Le scan suivant repartait sur un nouveau runUn run payé, jeté, puis repayé — en boucle
Le rapport opérateur disait « re-scanne dans quelques minutes »Le re-scan démarrait un second run facturableL'instruction de debug était elle-même une source de dépense

Ce que ça donne maintenant :

  • Un seul hash KV (apify:adruns, champ <domain>::<network>) au lieu de N clés à TTL. Énumérable — un run que personne ne peut lister est un run que personne ne peut collecter. Voir ad-library/pending-runs.ts.
  • Âge, pas TTL. Une entrée n'est jamais évincée en silence : elle n'est lâchée que par le collecteur, après qu'il ait essayé (MAX_RUN_AGE_MS = 3h).
  • Cooldown au démarrage. Un run est facturable dès qu'il est mis en file, donc le domaine est marqué payé immédiatement (6h, lib/provider-budget).
  • Cron dédié intelligence/ad-runs-collect, toutes les 10 min : il draine le hash, lit les datasets des runs SUCCEEDED, écrit les créatives. Il ne démarre jamais d'actor, ce cron ne peut donc rien dépenser.
  • Par domaine, tout ou rien. Un domaine n'est ingéré que quand TOUS ses runs (Meta + réseaux) ont abouti : les champs agrégés (active_creatives, hook_clusters, primary_format) se calculent sur l'ensemble, donc ingérer Meta puis TikTok laisserait la plus petite écriture gagner.
  • Les non-Meta aussi. TikTok / Google / Pinterest appelaient encore run-sync-get-dataset-items (bloquant) dans une probe plafonnée à 25s : même défaut, une copie en retard. Ils passent par le même registre.

Récupérer un run déjà payé : automatique

Les runs démarrés avant ce correctif ont réussi, écrit leur dataset et facturé le compte : les datasets sont toujours chez Apify dans sa fenêtre de rétention. ad-library/recover.ts les rattrape, et le cron ad-runs-collect le fait tourner tout seul (balayage throttlé à 1×/h, recoverPaidAdRunsIfDue).

C'est délibérément un cron et pas un bouton : ces datasets sont déjà payés et la fenêtre de rétention Apify tourne, donc une récupération qui attend que quelqu'un pense à la déclencher est une corvée avec une deadline, pas un correctif. L'auth cron de Vercel est le seul credential impliqué.

Les mêmes actions restent disponibles à la main pour forcer un passage ou inspecter avant d'écrire :

# Ce qui serait récupéré, sans rien écrire
POST /api/admin/intelligence/ad-runs  {"action":"recover","apply":false}
# Forcer maintenant (le cron le fera de toute façon dans l'heure)
POST /api/admin/intelligence/ad-runs  {"action":"recover"}

Le résultat du balayage apparaît sous recovery dans la réponse du cron : visible dans /admin/platform/crons.

Deux façons pour un run de nommer son store : le champ boostecomTarget qu'on estampille désormais dans l'input de chaque run (exact), ou, pour les runs antérieurs, le mot-clé de recherche, réduit en slug et comparé au premier label exact du domaine. Exact, pas préfixe : attribuer les créatives d'un store à un autre est pire que laisser un run non récupéré.

GET /api/admin/intelligence/ad-runs liste ce qui est en file, avec l'âge de chaque run.

10.4 Dedup idempotence

Le dedup se fait au niveau QStash, via deduplicationId, et les clés réelles sont datées au jour UTC : ondemand:<rowId>:<YYYY-MM-DD> pour un re-scan déclenché depuis une page détail, index:<domain>:<YYYY-MM-DD> pour l'indexation d'un store (cf. on-demand-warming.ts). La clé intelligence-scan-${storeId}-${dayBucket} annoncée ici n'a jamais existé.


11. Billing impact (modèle crédits BoostEcom)

Rien de cette section n'est en place. Aucun scan Intelligence n'est facturé en crédits : reason: "intelligence_audit" (§11.4) n'apparaît nulle part dans le code, le module cost-estimator.ts de la §11.3 n'a jamais été écrit, et les plafonds de la §11.2 citent un plan « Unlimited » que le catalogue (src/types/billing-plans.ts) ne connaît pas. À lire comme l'intention de 2026-05-12, pas comme l'état courant : la gratuité des scans est une décision produit de fait, non tracée ailleurs.

Le pipeline Intelligence consomme des crédits du merchant. Mapping conforme au modèle Credit ledger + pre-stream estimate + mid-stream hard cap déjà en place (cf. CLAUDE.md "garde-fous").

11.1 Mapping coûts → crédits

ActionCoût brut estiméCrédits facturés (markup 1.5×)
Audit on-demand cold path$0.10-0.4060 crédits ($0.60)
Audit hot path (cache hit)<$0.001Gratuit
Refresh background HOT$0.10Inclus dans abonnement Unlimited
NL search query (Haiku parsing + vector)$0.0055 crédits ($0.05)
Similar stores search1 vector query5 crédits ($0.05)

11.2 Plafonds par plan

PlanAudits cold path inclus / moisRecherches NL incluses / moisCrédits achat consommables
Free1 (essai)5N/A (pas de BYOK)
Unlimited ($49/mo)50IllimitéOui, markup 1.5×
CustomNégociéIllimitéMarkup 1.0×

11.3 Pre-stream estimate + mid-stream cap

Avant chaque scan cold path :

  1. Estimer le coût total (workers prévus + LLM extract)
  2. Si > balance crédits → 402 Payment Required avec proposition d'upgrade
  3. Pendant le scan, abort si running cost dépasse le balance

Reuse de l'infra existante pre-stream estimate (cf. CLAUDE.md). Ajouter un module src/services/algorithms/intelligence/cost-estimator.ts.

11.4 Tracking dans Credit ledger

Append-only conventionné. Une ligne par scan facturé :

await db.credit.create({
  data: {
    orgId,
    userId,
    delta: -60,
    reason: "intelligence_audit",
    metadata: { storeId, intelligenceReportId, source: "PROPRIETARY" },
  },
});

12. Privacy / RGPD / opt-out

12.1 Données collectées et bases légales

CatégorieDonnéesBase légale RGPDRétention
Données publiques de stores tiers (catalog, theme, pixels, ads)Pas de données nominativesIntérêt légitime + ToS publics12 mois rolling
Données nominatives de reviewersAgrégats seulement, jamais nom/email individuelsN/A — agrégats anonymesN/A
Données Admin API du merchant OAuth-connectéCommandes, customers (PII), produitsConsentement explicite + contrat merchantSelon contrat
Embeddings de storesVecteurs 1536d non-identifiantsIntérêt légitime12 mois rolling

12.2 Flow opt-out concret

La page publique /intelligence/transparency existe depuis juin 2026 (elle était encore annoncée ici « à créer ») et porte :

  1. Explication claire de ce qu'on fait (audit on-demand, pas de pré-indexage continu sauf cache)
  2. Formulaire opt-out : input URL store → POST /api/intelligence/opt-out → le store passe immédiatement en PRIVATE et l'opt-out est enregistré avec status: "pending"

Le modèle réel porte quatre champs que ce doc ignorait, et c'est eux qui font la différence entre un outil de conformité et une arme de délisting :

model IntelligenceOptOut {
  id              String    @id @default(cuid())
  domain          String    @unique @db.VarChar(255)
  reason          String?
  ipHash          String
  requestedAt     DateTime  @default(now())
  status          String    @default("pending") // "pending" | "verified"
  verifyToken     String?
  verifiedAt      DateTime?
  method          String?   // "dns" une fois prouvé
  priorVisibility IntelligenceVisibility?

  @@index([domain])
  @@index([status, requestedAt])
}
  1. Vérification de la demande : DNS TXT, pas email. Le demandeur publie boostecom-verify=<verifyToken> sur le domaine, puis POST /api/intelligence/opt-out/verify passe la ligne en verified. Un opt-out non vérifié reste provisoire et expire (balayage dans le cron intelligence/prune-history) ; priorVisibility permet de le défaire sans republier un store que son propre propriétaire avait rendu privé.
  2. Logging dans AuditLog (modèle existant)

12.3 User-Agent identifiable et politique robots (hybride)

Ce paragraphe promettait l'UA BoostEcomIntelligence/1.0 (+https://boostecom.app/intelligence/transparency), le « respect strict robots.txt » et 1s entre requêtes. Les trois étaient faux. L'état réel :

  • User-Agent : BoostEcom-Scanner/1.0 (+https://www.boostecom.app/about/scanner) (lib/fetch-providers/types.ts, DEFAULT_UA). C'est cette chaîne, et cette URL, qu'un opérateur verra dans ses logs ; /about/scanner renvoie vers /intelligence/transparency.
  • robots.txt : politique hybride, décidée en août 2026 (probes/shared/robots-policy.ts). Une lecture publique unique, celle qu'un visiteur déconnecté ferait (la home, UN /products.json, le favicon), est toujours autorisée et ne consulte pas robots.txt. Seules les sondes agressives multi-pages (pagination catalogue, parcours de sitemap, balayage de pages de funnel) honorent Disallow pour notre UA.
  • Espacement : MIN_HOST_INTERVAL_MS = 500, soit 500 ms par hôte, et uniquement sur le chemin agressif.
  • Couverture incomplète, assumée ici plutôt que masquée : seuls catalog.ts et agentic-readiness.ts passent par aggressiveFetchAllowed / getRobotsRules. hidden, landings, sitemap_watcher et advertorials paginent aujourd'hui sans ce garde. Suivi dans backlog/intelligence/0319.

12.4 Pas de honeypot email (rappel)

Cf. §3 et §17. Décision documentée et défendable en cas de question CNIL.


13. UI components spec (registry-first)

Conformément à la section « Stack UI canonique (registry-first) » de CLAUDE.md, toute UI utilise la stack canonique (shadcn/ui, AI Elements, Motion v12, recharts, sonner, lucide). Pas de framer-motion, pas de react-hot-toast, pas de Modal/Tooltip custom.

13.1 Surface 1 : Audit panel dans le chat

Quand Atlas/Faye lance un audit en réponse à une URL collée :

ComposantSource registryUsage
Message agent streamingai-elements/messageStream les phases au fur et à mesure
Phase indicatorai-elements/reasoning + shadcn progress + lucide Loader2"Crawl catalog… ✓ / Detect apps… ⏳"
Section collapsible par domaineshadcn accordionCatalog / Stack / Ads / Reviews / Sales velocity
Card sommaire KPIshadcn cardHeadline numbers avec sparklines
Sparkline tendances 7/30jrecharts via shadcn chartAd velocity, review velocity
Confidence interval barcustom basé sur shadcn progress + tooltip42 ± 18 orders/day · conf 0.6
Animation entrymotion/reactStagger reveal des sections au streaming
Empty/partial stateshadcn alert + lucide AlertCircle / Info"SimilarWeb returned no traffic data"
Toast à la complétionsonner"Audit complete · 9 / 11 phases succeeded"

13.2 Surface 2 : Page discovery /[orgSlug]/intelligence

Layout type liste filtrable façon outil de référence :

ComposantSource registryUsage
Search bar (NL + structured)shadcn input + popover + commandNL query → filtres extraits visibles
Filter panel stickyshadcn sheet (mobile) / pane latéral (desktop)15-20 filtres en multi-select
Filter chips multi-selectshadcn command + badgeApps, themes, pixels
Range slidersshadcn sliderCA estimé, croissance %, AOV
Toggle groupsshadcn toggle-groupMulti-pays oui/non, Headless oui/non
Result gridTailwind grid + shadcn cardListe stores avec preview
Sort dropdownshadcn selectTrier par CA, croissance, ad activity, similarité
Paginationshadcn paginationPage-based
"Connected" badgeshadcn badge default + lucide ShieldCheckStores avec ground truth
"Estimated" badgeshadcn badge outline + lucide BarChart3Stores avec inference seule
Hover card previewshadcn hover-cardQuick peek au survol
Empty stateshadcn empty + lucide"0 résultat"
Toast erreursonner"Recherche échouée"

13.3 Surface 3 : Store profile /[orgSlug]/intelligence/[domain]

Cliquer sur un store dans la discovery ouvre un profile détaillé :

ComposantSource registryUsage
Header sticky logo + KPIs primairesshadcn card + custom layoutDomain, niche, CA estimé (avec CI), growth tag
Tabs sectionsshadcn tabsOverview / Catalog / Stack / Ads / Reviews / Anomalies
Time-series chartsrecharts via shadcn chartAd velocity, review velocity, price history
Catalog tableshadcn tableProduits avec sortable columns
Apps gridshadcn card × NApps détectées avec catégorie + confidence
Anomaly timelinecustom + motion/reactAnomalies datées avec severity color
Comparison overlayshadcn dialog ou sheetComparer ce store avec un autre (Phase 2)
Action buttonsshadcn button"Analyser avec @Atlas", "Ajouter au watchlist"
Confidence badges everywherecustom sur badge + tooltipSur chaque chiffre, IC au hover

13.4 Surface 4 : Public scan results /scan/intelligence/[id]

Réutilise le pattern existant /scan/tracking/[id] (src/app/(minimal)/scan/tracking/[id]/page.tsx). Adapter pour l'IntelligenceObject. Soft auth gate après 2-3 scans gratuits (pattern déjà conventionné dans le repo).

13.5 Loading & error states

ÉtatComposant
Scan en cours (cold path 15s)shadcn skeleton × N + motion/react stagger reveal
Partial resultshadcn alert variant default + Info + sections affichées sans failed
Total failureshadcn alert variant destructive + XCircle + retry button
Quota dépasséshadcn alert + CTA upgrade + lien /pricing

13.6 Mobile responsive

  • Filter panel sheet slide-in
  • Result grid 1 col mobile, 2-3 col desktop
  • Tabs scrollables horizontalement
  • Charts recharts responsive

13.7 Dark mode

next-themes (déjà en place). Tous les composants shadcn supportent natif. Charts recharts utilisent les CSS vars Tailwind 4.


14. Testing strategy

14.1 Fixtures de stores connus

Captures HTML/JSON statiques de 20-30 stores réels, stockés dans src/services/algorithms/intelligence/__fixtures__/. Permet :

  • Tests de regression sur les probes (parsing theme, apps, pixels)
  • Tests sans hitting le réseau en CI
  • Golden files pour l'IntelligenceObject de chaque fixture

14.2 Tests par couche

CoucheTypeOutils
Probes individuellesUnit tests sur fixturesvitest
Orchestrator fan-outIntegration tests (probes mockées)vitest
Inference engineGolden tests (in/out fixture pairs)vitest
API route /api/intelligence/scanE2E avec MSWvitest + msw
Calibration vs vraie dataProperty-based avec Nacre Bijoux en ground truth réellevitest + fast-check
Page discovery filtresE2E navigateurplaywright

14.3 Calibration regression tests

Phase 2+, une fois la calibration OAuth active :

  • Pour chaque store OAuth-connected du calibration set, calcule la prédiction
  • Compare à la vraie data Admin API
  • Assertion : erreur médiane < seuil (à définir empiriquement, ex. 30% sur revenue/month)
  • Run en CI weekly, alerting si dérive

15. Observability

15.1 OpenTelemetry spans

Le repo utilise déjà Vercel OpenTelemetry. Instrumentation :

intelligence.scan (root span)
├── intelligence.cache_check
├── intelligence.shop_resolver
├── intelligence.fan_out
│   ├── intelligence.probe.catalog
│   ├── intelligence.probe.theme
│   ├── intelligence.probe.apps
│   ├── intelligence.probe.pixels
│   ├── intelligence.probe.reviews
│   └── intelligence.probe.meta_ads
├── intelligence.inference
│   ├── intelligence.inference.sales_velocity
│   └── intelligence.inference.growth_trajectory
└── intelligence.persist

Attributs span :

  • store.domain, store.shopify_shop
  • scan.source (PROPRIETARY / MARKET_MCP / etc.)
  • scan.tier (1 direct / 2 Firecrawl / 3 Browserbase)
  • scan.cache_hit (bool)
  • scan.cost_estimate_cents
  • scan.confidence

15.2 Métriques structured logger

MétriqueTypeAlerte
intelligence.scan.duration_mshistogramp95 > 25s
intelligence.scan.cost_centshistogramp95 > 150
intelligence.scan.cache_hit_rategauge< 30% sur 24h
intelligence.scan.failure_rategauge> 5% sur 24h
intelligence.scan.partial_rategauge> 20% sur 24h (probe dégradée)
intelligence.probe.{name}.failure_rategauge> 10% (probe à fixer)
intelligence.calibration.median_errorgauge> 50% (drift modèle)

15.3 Cost-alert cron

Le repo a déjà src/app/api/cron/cost-alerts/. Étendre pour :

  • Total mensuel intelligence services (Firecrawl + Browserbase + Apify + MCP de tendances)
  • Alerting si > budget configuré dans serverEnv.INTELLIGENCE_MONTHLY_BUDGET_CENTS

16. Phasing

Phase 1 — Audit on-demand (3-4 semaines)

Scope : un user submit une URL ou un merchant connecte son store → on retourne un brief complet en ≤ 15s.

Workers livrés :

  • shop_resolver, catalog_full_crawler, sitemap_watcher
  • theme_detector, app_detector (top 50 apps), pixel_detector (réutilise tracking/scan)
  • meta_ad_library_fetcher basique (rate-limited à 200 calls/h, queue)
  • review_velocity_aggregator (Judge.me + Loox + Yotpo)
  • sales_velocity_estimator v1 (sans calibration encore : coefficients par défaut)

Infra livrée :

  • Route /api/intelligence/scan (SSE) — pattern tracking/scan réutilisé
  • Extension de StoreReport avec type="INTELLIGENCE" + champ JSON typé
  • 3 tools dans specialists.ts : auditStore(url) (Atlas/Faye), getCompetitorCatalog(url) (Marco), getCompetitorAds(url) (Maya)
  • Page minimale /scan/intelligence/[id] (réutilise le pattern /scan/tracking/[id])

Livrable démontrable : un merchant arrive, paste une URL, voit en 15s un audit complet. Démo ready pour pitch.

Ce qu'on ne fait PAS en Phase 1 :

  • Pas de pré-indexage continu (uniquement on-demand)
  • Pas de page discovery filtrable
  • Pas de calibration OAuth active (on logue les écarts, on ne ré-entraîne pas encore)
  • Pas de creative vision analyzer
  • Pas de honeypot email
  • Pas de ClickHouse, pas de Qdrant, pas de Meilisearch

Phase 2 — Persistence & inference (8-12 semaines)

Scope : on construit l'avantage durable.

  • catalog_delta_watcher + variant_inventory_sniffer
  • creative_vision_analyzer + creative_clusterer (CLIP + HDBSCAN sur pgvector)
  • sales_velocity_estimator v2 avec calibration OAuth active
  • growth_trajectory + anomaly_detector
  • Page discovery v1 avec ~10 filtres + similarity search
  • Background jobs : recrawl périodique 1-6h selon priorité
  • Embeddings store positionnement (pgvector namespace store-intelligence)

À ce stade on tient face à l'outil de référence sur ses cas d'usage core, et on commence à les dépasser sur la précision Shopify-spécifique.

Phase 3 — Scale & moat (3-6 mois)

  • Recherche en langage naturel sur la page discovery
  • Tous les workers pricing/commercial
  • klaviyo_benchmark_consumer (si deal Klaviyo)
  • social_metrics_fetcher (followers, engagement, cadence)
  • Migration ClickHouse pour time-series (si volume >50M lignes)
  • Migration Meilisearch (si index >100K stores)
  • Exposition MCP en lecture pour partenaires/agences (sélectif, payant)

Phase 4+ — Vision long terme

  • Panel trafic propriétaire (si volume merchants connectés justifie l'investissement)
  • API publique
  • Marketplace de signaux (deals avec acteurs verticaux)

17. Risques & garde-fous

7.1 Légal / RGPD

RisqueMitigation
Scraping massif /products.json 24/7 sur stores tiers → zone grise ToS ShopifyOn scrape on-demand (un user le demande). Pré-indexage limité aux stores fréquemment demandés. User-Agent identifiable BoostEcomIntelligence/1.0 (+contact). Respect robots.txt.
Stocker données nominatives (reviewers, emails honeypot) → RGPDPas de honeypot en v1. Pas de stockage de noms/emails de reviewers individuels — agrégats uniquement.
Concurrence déloyale / parasitisme (jurisprudence FR)Donnée brute publique + analyse propriétaire. On ne republie pas la donnée, on l'analyse.
Risque réputationnel si merchant découvre qu'on a son store en base sans qu'il soit userPage transparence /intelligence/transparency + opt-out par URL submitted. Tag noindex sur les profiles publiques.

7.2 Anti-bot

99.2% des stores Shopify sont derrière Cloudflare. Open-source stealth plugins (Selenium Stealth, Puppeteer Stealth, Playwright Stealth) sont clampdown en 2026 (scrapfly).

Stratégie :

  • Path tier 1 (~80% des stores) : fetch direct /products.json avec User-Agent identifiable + délais. Pas besoin de bypass.
  • Path tier 2 (~15%) : Browserbase ou Scrapfly managed API avec proxy résidentiel ($0.10-0.12/h browser + $10-12/GB proxy).
  • Path tier 3 (~5% high-end) : escalade Bright Data Scraping Browser ou équivalent. Coût ×10.

Budget infra estimé Phase 1 : $0.50 à $1.50 par lookup à froid, $0.001 amorti après cache.

7.3 Précision

RisqueMitigation
User compare nos chiffres à ses vraies datas Shopify et trouve un écartToujours afficher l'intervalle de confiance. Jamais un chiffre unique. Pour les stores OAuth-connected, afficher "ground truth" + delta vs notre prédiction.
Stores <5K visites/mois → SimilarWeb retourne vide / l'outil de référence inventeOn hérite si on consomme leur API. Pour nos signaux propres, on les exclut sous un seuil de confiance minimal.
Inférence sales velocity biaisée sans calibrationPhase 1 : on annote "v1 model, calibration coming in Phase 2". On est honnête.

7.4 Coûts unitaires (Phase 1, on-demand)

PosteCoût par lookup
Compute + scraping infra$0.02-0.05
Browser time (cas tier 2)$0.05-0.10
LLM extract (Haiku via Gateway)$0.001-0.005
Meta Ad Library API$0 (rate-limited)
Traffic API (SimilarWeb)$0.05-0.20 si activé
Total cold path$0.10-0.40
Total hot path (cache)<$0.001

À comparer au prix de l'abonnement BoostEcom (Unlimited à $49/mo). Pour ne pas se faire saigner, plafond à 50 lookups cold path / mois inclus dans le plan, au-delà → décompté en crédits (markup 1.5×).

7.4.2 Les aperçus de boutique — une capacité, pas un proxy ouvert

/api/intelligence/og-image sert l'image d'aperçu d'un store. Son en-tête promettait de ne proxifier que « l'og:image de la boutique elle-même » et n'appliquait aucune contrainte : il lisait ?url= dans la query string et servait n'importe quelle URL https:// qui passait le garde SSRF et répondait avec un image/*. Pas de prisma, pas de StoreIntelligence, pas de signature. C'était un proxy d'images ouvert, caché 24 h par le CDN, 8 Mo par requête, 120 requêtes/min/IP, sur notre bande passante.

Deux mécanismes rendent la phrase vraie, dans cet ordre :

  1. Contrainte à la capture / projection. hub-projection ne publie og_image que si l'asset est sur l'hôte de la boutique (ou un CDN Shopify). Sans ça, une boutique indexée qui hotlinke l'image d'un tiers transformerait la projection en oracle de signature.
  2. Capacité. Ce qui survit est signé (signOgImageUrl, HMAC-SHA256 tronqué à 128 bits, clé NEXTAUTH_SECRET), et la route vérifie avant le fetch : une URL non signée ne coûte même pas la requête sortante.

La clé est délibérément différente d'INTELLIGENCE_ANON_SECRET : celle-ci sert les identifiants irréversibles et anon-secret.ts est le seul module autorisé à la lire. Sans clé, rien n'est signé donc rien n'est servi — la tuile retombe sur le favicon. Ça échoue fermé, ce qui est la seule direction acceptable pour une capacité.

backlog/intelligence/0317 (intelligence-surfaces-10), gardes src/lib/security/og-image-token.test.ts et src/app/api/intelligence/og-image/route.test.ts.

7.4.1 Ce que dépense une recherche anonyme (état du code, 2026-09-05)

/api/intelligence/hub/scan ne demande aucun compte. Ce qu'il dépense est décidé par deux listes de services/algorithms/intelligence/shopify-deep-scan.ts et par reserveOnDemandPaidScan.

SondesDépense
ON_DEMAND_PROBES (réservation accordée)quick + wayback + domain_registration + emails + reviews_vendor + traffic_provider + storefront_screenshot + social_metrics_fetcher (+ meta_ad_library_fetcher si une source pub est clé)plusieurs renders, dont deux sur le pool stealth
ON_DEMAND_FREE_PROBES (réservation refusée)le même set moins ON_DEMAND_PAID_PROBESun render Firecrawl, pas zéro

La deuxième ligne est le point qui a été faux en commentaire pendant des mois. Six sondes de quick — app_detector, pixel_detector, analytics, reviews_aggregation, social_handle_extractor, popup_detector — appellent probeFetch(base, { requireRenderedHtml: true }), et lib/fetch-providers jette une réponse direct valide dès qu'un provider de rendu est configuré. quick est dans tous les sets. Le cache de réponse par scan (cacheKeyFor(url, rendered, stealth)) fusionne ces six requêtes identiques en un seul appel : le plancher est d'un render, pas de six, et il est nul seulement si aucun renderer n'est câblé.

Ce qui justifie une place dans ON_DEMAND_PAID_PROBES est donc une dépense que cette entrée de cache ne peut pas servir : une autre URL, stealth: true, un appel direct à api.firecrawl.dev, ou un acteur Apify payant. emails n'en fait pas partie — son unique fetch est la homepage déjà payée par app_detector. La règle est dérivée des modules de sondes par shopify-deep-scan-spend.test.ts, pas maintenue à la main.

Conséquence non résolue : la réservation quotidienne borne le surplus, pas le plancher. Un scan refusé coûte toujours un render, borné uniquement par le rate limit de la route (8/h/IP). Trois options chiffrées dans backlog/intelligence/0354.

18. Décisions tranchées avant Phase 1 (historique)

Section close. La Phase 1.5 a été livrée le 2026-05-22 (§20) et la Phase 2 a suivi (§21) ; chaque ligne ci-dessous porte déjà sa recommandation, et le point « Corriger CLAUDE.md (Faye=Intelligence) » est appliqué depuis. Le titre a continué d'annoncer des décisions ouvertes des mois après leur clôture.

DécisionOptionsRecommandation
Scope Phase 1 minimal ou étendu ?Minimal (4 phases scan, 3 tools) vs étendu (+ creative vision + discovery v1)Minimal. Livre dans 3-4 sem, démontrable, dette tech minimale.
Licence SimilarWeb wholesale dès Phase 1 ?Oui ($149-399/mo) ou nonNon en Phase 1. On se concentre sur ce qu'on fait mieux, on ajoute SimilarWeb en Phase 2 si nécessaire pour les démos.
Browserbase vs Scrapfly vs Bright Data pour le tier 2 ?Tranché §7Stack hybride 4 tiers : direct fetch + Firecrawl primaire + Apify (reviews) + Browserbase (browser-heavy).
Nouveau modèle CompetitorScan ou extension de StoreReport ?Nouveau modèle vs type="INTELLIGENCE"Extension StoreReport. Pattern déjà utilisé (TRACKING, COMPETITOR).
Page discovery dans Phase 1 ou Phase 2 ?Phase 1 minimale vs Phase 2Phase 2. Trop large pour 3-4 sem si on veut un audit on-demand solide.
Corriger CLAUDE.md (Faye=Intelligence) dans la même PR ?Oui / nonOui. La doc est en retard sur le code, ça crée de la confusion (cf. mon erreur de routing initiale).

19. Décision honnête finale

On ne fait pas "mieux que l'outil de référence + SimilarWeb réunis" au sens absolu. On fait différent et structurellement supérieur sur la dimension qui compte pour notre cible (les merchants Shopify connectés) :

  • Plus précis sur les stores connectés (ground truth Admin API)
  • Plus actionnable (les agents mutent le store, ils ne montrent pas que des chiffres)
  • Plus intelligible (équipe de 6 agents experts vs un dashboard avec 200 filtres)
  • Plus honnête (intervalle de confiance explicite, pas un chiffre unique inventé)

C'est ce positionnement qu'on défend. Le reste (couverture mondiale, panel trafic, historique 5 ans) on l'accepte comme acquis chez eux et on n'essaie pas de répliquer.


Sources validées


20. Phase 1.5 — Mega-PR (livrée 2026-05-22)

Mise à jour majeure shipée sur la branche claude/elegant-sagan-TRd77. Boucle 21 items du backlog audit (cf. conversation utilisateur), exécutée en 12 commits atomiques. Tout en TS coté coeur ; pas de Python ajouté. Tous les changements de schéma Prisma sont additifs (aucun drop).

Décisions architecturales clés (corrections à la spec initiale)

Spec d'origine §XDécision Phase 1.5Raison
§5.3 pgvector + pgvectorscale sur NeonUpstash Vector namespace intelligence:storesLa spec D5.5 de docs/architecture/memory-layer.md interdit explicitement pgvector : "We do NOT introduce pgvector to Neon — splits the vector surface across two stores". On réutilise l'infra existante.
§8.1 Extension de StoreReport avec type="INTELLIGENCE"Modèle dédié StoreIntelligence (déjà créé en PR #97)Découplage propre, visibilité gate dédié, indexes natifs sans type discriminant.
§16 Phase 1 "Faye = Intelligence routing à corriger dans CLAUDE.md"CLAUDE.md + 6 locales + tous les call-sites code relabellés"Faye = Finance" était devenu un patchwork — fix complet en 1 passe, ID interne ag-spec-finance préservé pour pas migrer OrgFact/StoreFact/UserFact.agentId.
§5.4 sales_velocity_estimator v2 avec calibration activePhase 1.5 plumbe la calibration end-to-end mais sans flipper les multipliers (MIN_SAMPLES_FOR_ADJUST=30)Calibration scaffold était déjà créé ; manquait juste la connection au Shopify Admin via ShopifyClient.fromProject.

Livraison item-par-item

#Item brief / backlogLivraison Phase 1.5
1Faye = Intelligence (relabel complet)refactor(agents) — specialists.ts, atlas-router.ts, team-context/roster, identity-registry, mascots, CLAUDE.md, 6 locales i18n
2Wire calibration fetchShopifyMonthlyRevenue/Unitsfeat(intelligence) — ShopifyClient.fromProject + shopifyqlQuery sales report, 200-entry LRU cache partagée
3auditStore → naming clarifierdifféré (autre agent travaille dessus)
4schema_version sur RecordMetafeat(intelligence) — CANONICAL_SCHEMA_VERSION = "1.0.0" stamped + préservé par le reconciler
5embedding vector(1536) + HNSW indexCorrigé : Upstash Vector namespace intelligence:stores au lieu de pgvector. embeddings.ts projette le record en narrative, fire-and-forget sur le persist path.
6searchSimilarStores tool agent + MCPTool agent + MCP route — gate PUBLIC visibility, fallback re-embed quand reference n'a pas de record
7getCompetitorCatalog(url) + getCompetitorAds(url)Tools agents Marco/Maya — déclenchent un scan focused (catalog-only / ads-only) si record stale > 24h
8Refresh tiers HOT / WARM / COLD3 cron routes + refresh-tiers.ts + refresh-runner.ts partagé + IntelligenceLookupTally Prisma
9Anomaly detectorinference/anomaly-detector.ts (8 types — theme_change, stack_change, pixel_added/removed, ad_burst/freeze, restock_massive, pricing_shift, review_burst, social_traction_spike) + StoreAnomaly Prisma
10Domaine G — 3 probes pricingprice-tracker, discount-pattern-analyzer, shipping-policy-extractor + extension CommerceSection
11catalog_delta_watcher + variant_inventory_sniffer2 nouveaux probes + catalog.recent_diffs / catalog.inventory_velocity + CatalogDelta Prisma
12Merchant private dashboard /[orgSlug]/intelligenceList + [domain] profile avec 6 tabs (Overview / Catalog / Stack / Ads / Reviews / Anomalies) + IntelligenceWatchlist Prisma (retiré depuis, voir §27)
13NL search sur /intelligence/api/intelligence/nl-search — Haiku parse → ParsedFilters → query Postgres + canonical record match
14Creative vision + clusteringcreative-vision-analyzer.ts (Sonnet 4.6 vision + Zod schema, budget cap env var) + creative-clusterer.ts (cosine single-link agglomerative, no external ML lib) + AdCreativeAnalysis Prisma + ads.creative_clusters (L4)
15MCP Phase 3 OAuthIntelligenceApiKey Prisma + /api/intelligence/api-keys CRUD + /[orgSlug]/~/settings/api-keys UI + MCP route auth (Phase 3 + legacy Phase 2 fallback)
16Beta consent program scaffoldingIntelligenceBetaConsent Prisma + /[orgSlug]/intelligence/beta UI + consent endpoint
17Traffic provider — wholesale adapters (priorité user)traffic-providers/{similarweb,datos,semrush}.ts adapters + traffic-provider.ts probe v0.2.0 route wholesale-first avec fallback scrape
18Klaviyo benchmark consumerproviders/klaviyo-benchmarks.ts + bundled snapshot 13 niches
19Bright Data tier-3 anti-botlib/browser/bright-data-adapter.ts — Playwright CDP, budget cap mensuel via Redis bucket
20ClickHouse abstractiontime-series/index.ts — interface TimeSeriesStore + driver Postgres, ClickHouse stub gated par CLICKHOUSE_URL
21Bulk indexer pour millions de storesbulk-indexer/index.ts — enqueueSeedList + enqueueOperatorBatch, freshness gate 30 jours, seed list top-shopify-2026 (30 domains)
22Panel propriétaire + BoostEcom Spy bridge/api/intelligence/panel/ingest (HMAC-SHA256 signature) + IntelligencePanelEvent Prisma (partitionKey YYYY-MM pour partitions natives futures)

Bilan de couverture

  • Probes : le compte qui fait autorité est Object.keys(PROBES).length (probes/index.ts) et la taille de PROBE_SETS.full (shopify-deep-scan.ts) ; ads est un alias enregistré vers meta_ad_library_fetcher et reste hors full. Les « 31 enregistrés / 30 exécutés » écrits ici à la main étaient faux au moment de l'audit du 2026-09-03.
  • Sections canoniques : 10 inchangées, 4 étendues (catalog +2, ads +1, commerce +4, inferred +1)
  • Modèles Prisma : +9 (StoreAnomaly, CatalogDelta, IntelligenceWatchlist, IntelligenceLookupTally, IntelligenceApiKey, IntelligenceBetaConsent, AdCreativeAnalysis, IntelligencePriceObservation, IntelligencePanelEvent)
  • Crons : 1 → 4 (intelligence-tick + 3 tier crons)
  • Tools agents : 3 → 6 (searchStoreIntelligence, listKnownStores, searchSimilarStores, getCompetitorCatalog, getCompetitorAds)
  • MCP tools exposés : 4 → 10 au 20 août 2026 (searchSimilarStores, puis getStoreGraph, predictStoreTrajectory, findEmergingNiches, getWinningAngles, getSupplierIntel), source : TOOLS dans src/app/api/mcp/intelligence/route.ts
  • Surfaces produit : 1 → 5 (/intelligence publique + /[orgSlug]/intelligence privée + [domain] profile + /intelligence/beta consent + /~/settings/api-keys)

Ce qui reste user / procurement (pas code)

  • Beta merchant recruitment (#16) : UI prête, contrat & outreach côté ops
  • SimilarWeb / Datos / Semrush contracts (#17) : adapters prêts, env vars déclarées, signature contrat côté ops
  • Klaviyo partnership (#18) — consumer + fallback bundle prêt, deal Klaviyo côté ops (non bloquant grâce au snapshot)
  • Bright Data subscription (#19) : adapter prêt, account création côté ops
  • Bulk crawl execution (#21) : infra prête, lancement crawl effectif (cost & time) côté ops
  • ClickHouse provision (#20) — abstraction prête, instance ClickHouse côté ops

21. Phase 2 — Fermeture des gaps marché (audit de l'outil de référence)

Cible : passer de "parité de l'outil de référence + précision Shopify" à la couverture des manques nommés du marché (audit utilisateur). Quatre features livrées end-to-end, additives, sans migration destructive. Le reconciler backfille les sections/champs ajoutés sur les records existants (forward-compat, zéro migration de données).

21.1 SEO Intelligence (gap G)

Nouvelle section canonique seo. Probe seo (full + competitor) :

  • On-page (toujours, gratuit, depuis le HTML déjà fetché) : title, meta description, h1, canonical, hreflang count, JSON-LD @types, word count, internal links, image-alt ratio, indexabilité, keywords minés (titres + collections), top pages (sitemap catégorisé).
  • Off-page (quand une clé provider est posée) : organic keywords/traffic, backlinks, referring domains, domain rating, top ranking keywords, via seo-providers/{ahrefs,semrush,dataforseo}.ts (résolution par priorité Ahrefs > Semrush > DataForSEO, fail-soft, pattern identique à traffic-providers).

21.2 Store Graph + Clone Intelligence (gaps H + K : "le plus gros manque du marché")

Nouvelle section canonique graph + modèle Prisma StoreSignalIndex (index inversé (domain, kind, value)). Le graph-builder tourne post-inférence dans l'orchestrateur (accès DB, ni probe ni inférence pure) :

  1. indexe les signaux du store (tracking ids GA4/Meta/Klaviyo/GTM/…, theme_store, apps, social handles, top product handles),
  2. interroge l'index inversé pour les domaines partageant ces signaux,
  3. classifie via le scorer pur (graph/scorer.ts, testé) : same_owner (ids compte partagés = même opérateur), same_network (theme + stack apps = agence/template), clone (overlap produits/créatives), lookalike.

Émet same_owner_domains, network_domains, clone_candidates, shared_identifiers, cluster_id. Filtré PUBLIC (privacy). Surfaces : GET /api/intelligence/graph/[domain], tool agent getStoreGraph, tool MCP getStoreGraph.

21.3 Ad Libraries multi-network (TikTok + Google + Pinterest)

Le probe ad-library passe en multi-réseau. Adapters Apify par réseau (ad-library/providers/{tiktok,google,pinterest}.ts + helper apify-network.ts) activés quand APIFY_<NET>_ADS_ACTOR_ID est posé. searchAllNetworks fan-out + merge. Nouveau champ canonique ads.networks_detail (par réseau : active_creatives, spend, format, landing pages). Meta reste toujours actif ; les autres dégradent gracieusement.

21.4 IA stratégique (gap I) + sentiment avis (gap D)

  • Teardown : service teardown.ts (generateStoreTeardown) — synthèse Sonnet ancrée sur le record canonical → pourquoi ça scale, faiblesses, opportunités, nouveaux angles/marchés/offres. Tool agent analyzeStoreStrategy.
  • Sentiment : inference/review-sentiment.ts (Haiku) — mine un échantillon de corps d'avis (capturé par reviews_vendor, jamais persisté — PII) → reviews.sentiment_score, top_objections, top_motivations, hidden_usps (L4). Tourne sur full/competitor uniquement (le tick quotidien quick n'appelle pas reviews_vendor → zéro coût LLM en routine).

21.5 Bilan

  • Sections canoniques : 10 → 12 (seo, graph) + 2 étendues (reviews +sentiment, ads +networks_detail)
  • Probes : +1 (seo), meta-ad-library → multi-network
  • Modèles Prisma : +1 (StoreSignalIndex)
  • Tools agents : 6 → 8 (getStoreGraph, analyzeStoreStrategy)
  • Tools MCP : 5 → 6 (getStoreGraph)
  • Routes API : +1 (/api/intelligence/graph/[domain])
  • Env : +5 (AHREFS_API_KEY, DATAFORSEO_API_KEY, APIFY_{TIKTOK,GOOGLE,PINTEREST}_ADS_ACTOR_ID)

Reste user / procurement (pas code)

  • Ahrefs / DataForSEO contracts (#21.1) : adapters prêts, env vars déclarées
  • Apify actors TikTok / Google / Pinterest (#21.3) : adapters prêts, actor ids à poser côté ops

Suite — Prediction Engine

Le moteur d'observation est complet. La suite (moteur de prédiction : breakout/saturation, forecast calibré, opportunity scorer, market clusters, supplier intel, backtesting) est spécifiée dans intelligence-prediction-roadmap.md, c'est le "+8 ans d'avance".

22. Ce que la collecte atteint réellement (couverture dérivée)

Une section canonique remplie n'est pas une section lue. Le §21.1 décrit dix-neuf feuilles seo collectées à chaque scan ; jusqu'en août 2026, quinze d'entre elles n'atteignaient aucun humain : ni écran, ni rapport, ni API. Personne ne l'avait vu parce que rien ne le mesurait.

22.1 Le rapport pnpm intel:coverage

scripts/intelligence-coverage.mjs répond, pour chacun des champs de canonical/sections.ts (leur nombre est une sortie du script, jamais un chiffre écrit ici), à deux questions dérivées du code source :

  1. Quelque chose le PRODUIT ? Un champ qu'aucune sonde n'écrit est une promesse du schéma que le pipeline ne peut pas tenir : il rend un blanc permanent qui se lit comme une lacune sur le marchand.
  2. Quelque chose l'AFFICHE ? Un champ collecté à chaque scan et montré nulle part, c'est de la bande passante, du quota provider et du stockage payés pour rien.

L'analyse est statique et refaite à chaque exécution, jamais tenue à la main : un inventaire écrit champ par champ est faux la semaine suivante, et faux en silence. Trois règles portent la mesure, et chacune existe parce que la version sans elle mentait :

RègleCe qu'elle corrige
Deux sauts (la projection lit la feuille → un composant nomme la clé projetée)Une recherche par mot de passe crédite domain comme absent et n'importe quel homonyme comme présent
Alias de section (const p = record.prediction … p?.stage)Six champs prediction classés « n'atteint personne » alors que /api/intelligence/predict/[domain] les sert tous
Modules de vue (*/project.ts, *-projection.ts) rattachés à la surface qui les importeLes mêmes champs reclassés en « plomberie », ce qui est faux dans l'autre sens

hub-projection.ts est délibérément exclu de la dernière règle : le hub est déjà modélisé par les deux sauts, et l'y ajouter recréditerait chaque feuille que la projection touche (57 → 81 champs « visibles » en une ligne, sans qu'un pixel change).

Deux cliquets (--check, en CI et au pre-push) : le nombre de champs sans producteur et le nombre de champs orphelins ne peuvent que descendre.

22.2 L'onglet SEO

GET /api/intelligence/seo/[domain] + l'onglet SEO du panneau de détail du hub rendent les dix-neuf feuilles : autorité hors-page, on-page, profondeur de contenu, mots-clés, top pages. Servi à la demande plutôt que plié dans /hub/stores : la section pèse ~2 Ko par boutique et seul le détail la lit, donc la mettre sur la liste coûterait 100 Ko au premier rendu du hub pour des données qu'aucun écran de cette page n'affiche. Visibilité PUBLIC uniquement, comme predict / graph / inspect : le on-page EST la marque (son title, son h1, ses URL), il n'y a rien à servir d'une boutique anonymisée une fois retiré ce qui l'identifie.

22.3 Le blanc du hors-page dit à qui il appartient

Sans clé Ahrefs / Semrush / DataForSEO, la sonde n'émettait rien pour backlinks, domaines référents, trafic organique, domain rating et mots-clés de classement : le record gardait le not_observed du template, et toute surface le lisait comme « on a regardé cette boutique et elle n'a pas de backlinks ». Notre credential manquante, énoncée comme un fait sur le marchand.

Depuis probe:seo@0.2.0 :

ÉtatRaison émiseCe que l'écran dit
Aucune clé poséeno_oauth« Aucun fournisseur SEO connecté » — réglage plateforme, pas un fait sur la boutique
Clé posée, provider muetnot_observed« Le fournisseur n'a rien renvoyé » — les boutiques petites ou récentes manquent des index de backlinks
Clé posée, métrique absente de la réponsenot_observedBlanc sur cette métrique seule, le bloc reste répondu

Même distinction côté on-page, mais sans nouveau vocabulaire : word_count_home / internal_link_count / indexable sont émis dès que la page d'accueil a été parsée, quoi que la boutique déclare. Le champ scanned de la projection en dérive, et sépare « la boutique ne déclare pas de <title> » (un constat) de « on n'a jamais lu cette vitrine » (le nôtre).

22.4 Vélocité d'avis et source de trafic

Six feuilles reviews et une feuille traffic étaient dans le même cas que le bloc SEO : écrites à chaque scan, lues par personne.

last_review_at + recent_count_30d sont la moitié manquante de l'histoire des avis. 3 000 avis dont le dernier date de dix-huit mois et 3 000 avis qui progressent de 40 par mois sont deux commerces opposés, et la carte n'affichait que le total. C'est aussi la colonne que l'outil de référence met en tête de sa vue avis, parce qu'elle bouge à la semaine là où le total ne bouge presque pas.

photo_ratio, response_rate, verified_buyer_ratio s'affichent champ par champ, jamais à zéro par défaut : chacun vient du flux public du vendeur actif et chacun peut manquer seul (Loox publie les photos et pas les réponses, Trustpilot l'inverse). Un ?? 0 écrirait « 0 % avec réponse » sur un marchand qui répond à tout via un vendeur qui ne le publie pas.

sentiment_score et sentiment_sample_size voyagent ensemble ou pas du tout (reviewSentiment, testé). Le score est une lecture LLM d'un ÉCHANTILLON : +0,7 sur quatre avis et +0,7 sur huit cents ne sont pas la même affirmation, et le pipeline produit volontiers le premier sur une boutique dont le flux vendeur n'a presque rien donné. Le score seul laisserait prendre l'un pour l'autre.

traffic.top_source était projeté dans HubStore depuis l'adaptateur provider sans qu'aucun composant le nomme. Un nombre de visites sans canal ne sépare pas une marque qui loue son trafic d'une marque qui le possède, et c'est la première chose qu'on regarde en ouvrant un concurrent. La part payante s'affiche à côté du canal, jamais seule : une part sans le canal qui la porte ne veut rien dire.

22.5 Registrar, thème, CDN — et le champ qu'on n'affiche pas

identity.domain_registrar était projeté dans HubStore depuis la sonde d'enregistrement et nommé par aucun composant : le bandeau portait la date de création et laissait tomber la moitié qui dit quel genre de boutique a acheté le domaine (registrar Shopify = clé en main, registrar spécialisé = marque migrée).

Le chip thème porte désormais theme_schema_version (la semver de la base, donc le signal « thème périmé ») et theme_role seulement quand il ne vaut pas main : une vitrine servie par un thème unpublished est soit une migration en cours soit une erreur, alors qu'écrire « main » sur chaque boutique n'est que du bruit. stack.cdn descend dans le panneau stack, sous ce qui tourne sur la boutique.

stack.theme_version reste volontairement dans la liste des orphelins. Ce n'est pas une version : c'est le compteur de republication de Shopify (/t/<N>/assets/). Sa valeur brute ne dit rien à un lecteur et serait lue comme un numéro de version ; c'est son mouvement entre deux scans qui est le signal (le marchand a édité son thème), et cette comparaison n'existe pas encore. L'afficher pour vider la liste reviendrait à poser à l'écran un nombre qui ne veut rien dire, ce que ce document passe son temps à interdire ailleurs.

22.6 Le détecteur avait un troisième angle mort

catalog.product_snapshot était rangé en « collecté, montré nulle part, utilisé par rien ». Faux : catalog-delta-watcher le lit, mais il va chercher le record lui-même via un select: { record: true } Prisma, au lieu de le recevoir en paramètre. La règle readsPriorPass n'était ancrée que sur existingRecord.

C'est la même faute que pour prediction : le rapport aurait envoyé quelqu'un supprimer le blob, et ressuscité le bug que son propre commentaire consigne (sans cette base, chaque produit hors de la fenêtre de diff est re-signalé product_added à chaque scan).

La règle accepte maintenant les deux formes. Sûre parce qu'une sonde n'écrit jamais à travers une variable nommée record : ses émissions passent par canonical.<section> ou un littéral. Vérifié sur tous les fichiers de probes/ avant d'écrire la règle.

22.7 Deux champs dont le NOM ment, surfacés sous leur vrai nom

Deux feuilles canoniques portent un nom qui promet autre chose que ce qu'elles contiennent. Les câbler sous le nom du champ aurait produit un mensonge à l'écran ; elles sont donc projetées sous le nom de ce qu'elles comptent, avec la raison écrite au point de projection.

FeuilleCe que le nom prometCe qu'elle contientSurfacée comme
email.sequencesdes flows détectés (Welcome, Panier abandonné…)la liste des vendeurs lifecycle (klaviyo, omnisend) — l'en-tête de la sonde le dit« Plateforme e-mail »
ads.hook_clustersdes clusters de hooks créatifsresult.pages.length, soit le nombre de Pages annonceur distinctes — l'en-tête de la sonde parle d'un « distinct page-name proxy »« Pages annonceur », et seulement au-dessus de 1

Le vrai clustering créatif existe ailleurs : ads.creative_clusters.

ads.hook_clusters n'est affiché qu'à partir de 2 : tout annonceur a au moins une Page, donc « 1 page » est un fait sur la publicité en général, pas sur cette marque. Deux Pages ou plus, c'est une opération multi-Pages, et ça se dit.

22.8 L'onglet Emails n'est plus réservé à la démo

Il rendait « indexation en cours » sur toute boutique réelle, alors que la sonde e-mail lit à chaque scan le vendeur lifecycle, l'offre du popup et le formulaire SMS depuis la vitrine. Ces trois-là s'affichent désormais pour une boutique réelle ; seule la liste des e-mails capturés garde son état vide, parce qu'elle n'a effectivement aucun producteur.

Le SMS ne s'affiche que dans le cas positif : la sonde le pose quand elle trouve un formulaire d'opt-in, et ne pas en trouver n'est pas une preuve d'absence (les apps SMS dédiées s'injectent au clic). Quant à email.emails_per_week, il reste dans la liste « déclaré, jamais produit » : aucune carte de cadence ne peut être honnête sur une boutique réelle tant que rien ne l'écrit.

22.9 Les trois derniers, et pourquoi ils restent

Le rapport descend de 44 à 3. Ces trois-là ne sont pas des écrans manquants : ce sont trois décisions, et les afficher pour amener le compteur à zéro reviendrait à poser à l'écran quelque chose qu'un lecteur ne peut pas utiliser.

FeuillePourquoi elle n'est pas affichée
stack.theme_versionCompteur de republication Shopify (/t/<N>/assets/), pas une version. Seul son mouvement entre deux scans dit quelque chose (le marchand a édité son thème) et cette comparaison n'existe pas encore. Sa valeur brute serait lue comme un numéro de version.
email.niche_benchmarksUn fait sur une niche, pas sur la boutique. Sa propre doc dit qu'il existe pour permettre aux agents de situer un marchand face au secteur. L'afficher sur la fiche d'un concurrent mettrait un chiffre d'industrie sous le nom d'une marque.
commerce.price_historyL'en-tête de la sonde le décrit comme une queue glissante conservée « so agents can narrate without a separate query ». Le graphique de prix, lui, lit directement la table IntelligencePriceObservation.

Les deux derniers ont un consommateur naturel qui n'est pas le Spy : les agents. C'est un chantier d'outillage agent, pas un panneau de plus.

22.10 Le reste du lot

FeuilleSurface
commerce.shipping_zones + delivery_days_p50Carte « Livraison ». Où un concurrent expédie décide s'il vous concurrence tout court ; une promesse à 3 jours et une à 21 jours ne sont pas la même offre. La carte n'apparaît que si la page de politique a rendu au moins l'un des deux : une politique illisible n'est pas « n'expédie nulle part ».
catalog.catalog_value_eur + last_change_atÀ côté du nombre de produits qu'ils qualifient. 96 produits à 18 € et 96 à 180 € ne sont pas la même boutique, et un catalogue immobile depuis six mois non plus.
identity.screenshot_atDate la capture, et seulement quand la tuile montre notre capture : le repli est un og:image puis un favicon, que ce champ ne date pas. Une vitrine change chaque semaine ; une image de quatre mois affichée sans date affirme « voilà à quoi ressemble la boutique ».
identity.funding_campaignsChip cliquable à côté de la date de création qu'elle explique. Détection seule : on lie la campagne, on ne cite pas un montant collecté qu'on n'a jamais lu. Vidé pour les boutiques ANONYMISÉES, une URL de campagne nomme la marque aussi sûrement qu'un domaine.

Une note sur projectFundingCampaigns : la feuille est un tableau d'objets, pas de chaînes. leafArr filtre sur les chaînes et aurait donc renvoyé un tableau vide en silence — c'est exactement comme ça qu'un champ peut avoir l'air « câblé » et ne jamais rien afficher.

22.11 A/B est deux mots du marchand, pas une expression

valisedemo nomme ses types de produits chèche/besace, pad/banane, écharpe/sacoche, veste/sac. Chacun est deux produits, et chaque moitié est un mot de bagagerie ou de vêtement que la taxonomie connaît. En une seule chaîne, aucun ne matchait quoi que ce soit : 42 des 51 produits ne disaient rien de ce que vend la boutique.

expandSlashPhrases (sonde catalogue 0.15.0) ajoute les moitiés à côté du tout, jamais à la place : une expression slashée qui EST un vrai nom garde sa chance de matcher entière, et l'opérateur relit les mots du marchand tels quels dans le rapport de scan. Chaque moitié hérite du compte du tout, ce qui est la lecture honnête : quinze produits portent chèche/besace, donc quinze portent chèche et quinze portent besace. mergePhrases replie ensuite une moitié qui existe aussi seule.

Les garde-fous laissent entier tout slash qui ne sépare rien : une partie de moins de trois caractères (s/o, w/), purement numérique (1/2, 24/7, 50/50), une URL, ou plus de trois parties.

22.12 Une racine non corroborée ne peut plus opposer son veto

Le split seul ne suffisait pas, et la mesure le dit mieux qu'un raisonnement. Sur le catalogue réel :

Avant splitAprès split
aa Vêtements et accessoires6 · 2 mots20 · 3 mots (vestes, sacoches, écharpes)
lb Bagagerie4 · 1 mot15 · 1 mot (besaces)
fb Alimentation4 · 1 mot14 · 1 mot (bananes, le fruit)
Verdictlow_sharetie

aa domine et est corroborée trois fois ; lb la bloquait sur un seul type de produit. Or une racine portée par un seul mot est exactement celle que le classifieur refuse de publier seule (MIN_DISTINCT_PHRASES). Appliquer cette conviction dans un sens et pas dans l'autre laissait une racine invendable en bloquer une défendable.

La marge se mesure désormais contre le premier concurrent corroboré. C'est étroit par construction : la racine de tête n'est pas touchée et doit toujours passer corroboration, part et confiance ; et une racine non corroborée qui domine au score reste en tête, où la porte la refuse.

Résultat sur valisedemo : Vêtements et accessoires > Sacs à main, portefeuilles et étuis > Sacs à main > **Sacoches** — un mot que le marchand a écrit lui-même. Et banane ne peut pas faire gagner le fruit.

22.13 Le client Prisma ne peut plus faire échouer un typecheck

pnpm typecheck lance prisma generate avant tsc. Un client généré périmé signalait comme erreurs de type des valeurs d'enum ajoutées au schéma par un autre travail (constaté sur SubscriptionStatus, dans webhooks.ts, un fichier sans rapport avec le changement en cours). Le coût est de ~2 s sur un typecheck qui en prend 60 à 90 ; le bénéfice est qu'un git pull ne peut plus produire une erreur qui n'a rien à voir avec le code qu'on est en train d'écrire.

23. agentic — la dimension que les concurrents ne peuvent pas avoir

Quatorzième section canonique, six feuilles, toutes L1 observées, et la sonde agentic_readiness qui les produit ne coûte rien : trois GET non authentifiés sur des documents que la boutique publie elle-même.

23.1 Le fait

Chaque storefront Shopify sert {origin}/.well-known/ucp. La documentation de Shopify est explicite sur le point qui compte : « You can use this document to confirm the merchant supports carts before calling create_cart ». Cette phrase n'a de sens que si l'ensemble de capacités varie d'un marchand à l'autre. Ce n'est donc pas une constante de plateforme, c'est un signal discriminant — et il décrit une dimension qui n'existait pas avant 2026, qu'aucun outil du marché ne mesurent.

Deux surfaces adjacentes viennent de la même fonctionnalité de thème et coûtent un GET chacune : /agents.md et /llms.txt.

FeuilleSource
agentic.ucp_discoverablele GET a-t-il rendu un document UCP valide
agentic.ucp_versionsucp.supported_versions, plus récente d'abord
agentic.capabilitiesclés de ucp.capabilities
agentic.mcp_endpointservices["dev.ucp.shopping"][].endpoint
agentic.agents_md/agents.md présent
agentic.llms_txt/llms.txt présent

23.2 Ce que la sonde n'a pas le droit de dire

« Pas prêt » est une affirmation sur le marchand. Un timeout, un 403, un échec de proxy et une page d'erreur HTML sont des affirmations sur nous. Ils restent not_observed et ne deviennent jamais une lecture négative — la même règle qui fait rendre null et pas [] à readPolicyHits, appliquée à une autre porte.

Trois distinctions portent tout le reste :

  1. Un 404 EST une observation. La boutique a répondu, et elle ne sert pas ce document. Seule une requête qui jette laisse un blanc.
  2. Un bloc capabilities vide n'est pas un bloc absent. Le premier est la réponse du marchand (« aucune »), le second n'a jamais existé. readAgenticDocument les sépare par hasCapabilityBlock, et la sonde n'émet le champ que dans le premier cas.
  3. Un 200 ne prouve rien sur /agents.md. Une boutique Shopify répond à un chemin inconnu par une 404 thématisée en statut 200 plus souvent qu'autrement. looksLikeMarkdownDocument refuse tout ce qui ouvre sur du HTML, sans quoi toute boutique ayant un template 404 serait créditée de publier les deux documents.

23.3 Le classement

agentic_score (0-4, un point par surface publiée) est dérivé à la projection, pas stocké : c'est un rang de présentation sur quatre booléens observés, et le stocker en ferait un fait autonome qui peut diverger des quatre qu'il résume.

Il vaut null — jamais 0 — quand rien n'a été observé. La distinction est tout l'intérêt : une boutique qu'on n'a pas pu lire doit se classer après toute boutique lue, pas à côté de celles qui ne publient réellement rien. C'est la règle que byMeasuredDesc applique déjà sur les pubs et la croissance ; c'est le troisième champ auquel elle s'appliquait.

Le tri s'appelle « Most agent-ready » dans le Store Spy. intelligence/0136 demandait « un classement public par catégorie » : les URLs /intelligence/<category> redirigent depuis qu'elles dupliquaient le browse/filter/sort du Hub, donc le classement vit là où le classement vit maintenant. Le diagnostic côté marchand (« vos concurrents annoncent cart + checkout, vous annoncez catalog seul ») appartient au synthétiseur d'action-plan, un autre pilier : suivi séparément.

23.4 Robots

.well-known est réservé aux métadonnées lisibles par machine (RFC 8615) et se lit directement. /agents.md et /llms.txt sont des chemins de contenu ordinaires qu'un marchand peut interdire : ils passent par getRobotsRules. Un chemin interdit n'est pas lu, et son absence de réponse est alors notre choix — donc not_observed, jamais « il n'en publie pas ».

23.5 La moitié marchande (commerce-systems/0147)

La mesure ci-dessus décrit une boutique espionnée. Le marchand connecté, lui, voulait la phrase inverse :

vos concurrents annoncent cart + checkout, vous annoncez catalog seul

Elle est livrée comme une constatation du Strategic Action Plan (features/action-plan/agentic-gap.ts, pilier commerce-systems), sous le pilier aeo — /llms.txt est déjà un AeoAuditKind, et le CTA tombe sur le même système. La comparaison est pure et testée ; le synthétiseur ne fait que l'alimenter.

Le jeu de comparaison est fait de ce qui est déjà indexé : aucune sonde, aucun crawl, aucun appel d'embedding sur un rendu de page. Deux lectures : le Store Graph (network + clone_candidates) et le cluster de niche du marchand (MarketCluster.top_domains). same_owner_domains en est exclu — ce sont les AUTRES boutiques du marchand, et se comparer à soi-même se lirait comme une constatation concurrentielle en ne mesurant rien. Les concurrents sont comptés, jamais nommés : le nombre informe la décision, le nom republierait un choix de visibilité qui n'appartient pas à cette surface.

Quatre silences, et chacun est un cas où parler reviendrait à décrire NOTRE couverture comme un fait sur le marchand :

LectureSortie
ucp_discoverable jamais observésilence — l'absence est la nôtre
aucun concurrent observésilence — une comparaison sans terme
document lu, aucun bloc capabilitiessilence — « déclare aucune » et « déclare ailleurs » sont indiscernables
le marchand est déjà en avancesilence — rien à dire

Et le cas inverse, qui compte autant : ucp_discoverable === false — la boutique a répondu et ne sert aucun document — a un ensemble déclaré vide et connu. Il se compare, et c'est la lecture la plus utile de la fonctionnalité. Un silence trop zélé l'aurait tuée.

23.6 Le défaut que cette moitié a révélé

agentic ne figurait pas dans StoreCanonicalRecordSchema. z.object retire ce qu'il ne déclare pas : la section était écrite (l'upsert persiste input.record, pas parsed.data) et effacée à chaque lecture par getCanonicalRecord. Comme le deep scan réconcilie contre cette lecture, une passe où la sonde skippait pour fraîcheur (cost.skipIfFresh) réécrivait la ligne sans la section : une vraie perte de données, silencieuse, et invisible sur le Spy — qui projette depuis la colonne JSONB brute, sans Zod.

Aucun des tests de schema-roundtrip.test.ts ne pouvait le voir : ils vérifiaient tous success === true, et un strip réussit. Le garde ajouté parcourt SECTION_KEYS et exige que chaque section ressorte du validateur — dérivé du template, donc une section ajoutée sans sa ligne dans le schéma échoue sur le commit qui l'ajoute.


24. Les dépenses, les secrets et les affirmations (audit 2026-09-03, lot P2/P3)

intelligence/0317. Sept constats de l'audit du 2026-09-03 dont le point commun n'est pas la sévérité mais la forme : dans chacun, un commentaire ou une réponse d'API décrivait une propriété que le code n'implémentait pas, et personne ne pouvait s'en apercevoir depuis l'extérieur.

24.1 Le second client Firecrawl passe par le disjoncteur

src/lib/scraper/ construit son propre FirecrawlApp pour les outils @Atlas et le bot WhatsApp. Il ignorait entièrement lib/provider-budget, que lib/fetch-providers/firecrawl.ts consulte à chaque appel. Compte capé (402) : la chaîne de scan s'arrêtait, ce client-ci continuait, un tour de chat après l'autre, et rachetait le même refus. Aucun appel n'avait de borne non plus.

Désormais : isBudgetTripped("firecrawl") avant chaque appel facturé, tripBudget sur un 402 lu depuis err.status (le SDK lève un FirecrawlSdkError qui porte le statut amont), et un timeout par appel sur le scrape. Le refus lève au lieu de rendre un document vide : chaque appelant enveloppe déjà l'appel dans un try/catch qui le traduit en { success: false }, alors qu'un document vide arrive au modèle comme une page sans contenu, ce qui le fait réessayer.

scrapeStoreMeta ne passe plus par Firecrawl du tout. Il lisait la homepage d'un store à chaque création de store et chaque détection d'organisation pour en extraire un <title> par regex, en payant un rendu, et en le payant pour le mauvais HTML : le format html de Firecrawl est son extraction nettoyée, qui retire <meta> et <link> (cf. le commentaire de fetch-providers/firecrawl.ts), donc les regex og:* ne matchaient probablement rien. Il lit maintenant par fetchWithFallback(base, { skipFallbacks: true }) : le barreau direct, gratuit, jamais d'escalade vers un rendu payant, et un validateScanUrl d'abord, parce que déplacer le fetch de l'infrastructure de Firecrawl vers la nôtre transforme un hostname fourni par l'appelant en surface SSRF.

24.2 /api/intelligence/og-image n'est plus un cache d'images ouvert

L'en-tête promettait « proxies ONLY the store's own og:image ». Le handler lisait ?url= et servait n'importe quelle URL https qui passait le garde SSRF et répondait en image/*. Le garde SSRF traite l'interne ; il ne dit rien du coût. À 120 req/min/IP et 8 Mo par image, avec un cache CDN de 24 h, c'était un hébergement d'images gratuit sur notre bande passante.

L'URL doit maintenant être signée par nous. ?url= seul est refusé ; ?sig= doit porter un HMAC émis par signOgImageUrl (lib/security/og-image-token.ts), et hub-projection ne signe qu'un actif dont l'hôte est le domaine du store lui-même ou un CDN de storefront Shopify. La vérification a lieu avant le fetch, donc une URL non signée ne coûte même pas une requête sortante.

Une liste d'hôtes autorisés avait été écrite d'abord, puis abandonnée à la fusion : elle laissait passer tout cdn.shopify.com, c'est-à-dire n'importe quelle image téléversée sur n'importe quelle boutique d'essai. Une capacité signée n'a pas ce bord. Il échoue fermé : sans clé de signature, le proxy ne sert rien plutôt que tout.

24.3 Les tokens provider voyagent en en-tête

provider-health.ts:17 énonçait déjà l'invariant pour lui-même : « Tokens travel in Authorization headers, never in a URL, so a thrown fetch error can never echo one back. » Quatre modules faisaient l'inverse avec APIFY_TOKEN et META_AD_LIBRARY_TOKEN. Une URL est la chaîne la plus recopiée d'une requête : log du proxy sortant, attribut OpenTelemetry http.url, texte de l'erreur fetch. Les deux tokens commandent du travail payant.

src/services/discovery/apify-client.ts est désormais le seul endroit où une requête Apify se construit, et il refuse un token dans le chemin plutôt que de le retirer en silence. meta-graph.ts passe son access_token en en-tête. Une exception reste, déclarée : meta-token-health.ts interroge debug_token, dont le paramètre input_token est le sujet de la requête et n'existe nulle part ailleurs ; ici sujet et credential sont la même valeur.

Le garde est comportemental (src/test/provider-secrets-never-in-url.test.ts) : chaque client est réellement exécuté contre un fetch bouchonné avec des valeurs sentinelles, et l'assertion lit ce qui a été demandé. Un regex sur les sources ne verrait pas un token ajouté par un helper, et se déclencherait sur un lien de désabonnement que personne ne fetch.

24.4 Le 404 de inspect envoie vers le bon pipeline

/api/intelligence/inspect/[tool] répondait à un domaine inconnu avec scan_url: "/api/tracking/scan". C'est un autre pipeline : l'audit tracking/pixels, gated Turnstile et authentifié, qui n'écrit jamais StoreIntelligence. Un agent qui suivait l'indication lançait un audit, réinterrogeait inspect, et retrouvait le même 404. Une boucle sans sortie, publiée comme contrat d'API.

La réponse nomme maintenant POST /api/intelligence/hub/scan, avec la méthode, le corps et le plafond (8 req/h/IP), et src/test/intelligence-scan-entrypoint.test.ts résout le littéral vers un route.ts réel plutôt que de le comparer à une chaîne.

Les liens des pages publiques vers /scan/tracking restent : ils demandent la machinerie de deep-link du Hub, qui appartient à un autre pilier (intelligence/0386).

24.5 Les clés bei_ sont journalisées, plafonnées, limitées

Émission et révocation passaient du contrôle d'appartenance à l'écriture Prisma sans AuditLog, sans rateLimit, sans compter les clés actives. Une clé fuitée puis révoquée ne laissait aucune trace de qui l'avait émise. Et comme /api/mcp/intelligence bucketise sur caller.keyId, frapper des clés était le moyen le moins cher de multiplier son quota.

Ajoutés : deux lignes AuditLog (intelligence.api_key.issued / .revoked, la seconde après le garde count === 0), un plafond de 20 clés actives par organisation, et deux buckets de rate-limit distincts. La révocation a le plus large des deux, délibérément : elle est idempotente et réduit un privilège, elle ne doit jamais être ce qui manque de budget pendant un incident.

La correction de fond est livrée (security-identity/0387) : les deux surfaces Intelligence — /api/mcp/intelligence et guardIntelligenceRoute — bucketisent sur caller.orgId. Émettre une deuxième clé n'achète plus un deuxième quota, donc le plafond de 20 clés ci-dessus n'est plus qu'une mesure d'hygiène : il borne la taille d'une fuite, pas un débit. IntelligenceApiKey.orgId est NOT NULL, donc aucune clé déjà émise n'a eu à être réémise.

24.6 Deux surfaces qui présentaient une fenêtre comme un graphe

/api/intelligence/nl-search déclarait un filtre niche_keyword, le faisait extraire de chaque requête par un modèle, puis ne le lisait nulle part ; min_layer était compilé en égalité, donc « au moins L2 » excluait les lignes L1, les plus sûres ; et l'ensemble candidat était les limit * 5 fiches les plus récentes, 50 par défaut.

Le mot-clé est maintenant lu : cherché dans la niche du catalogue, le nom du store, les catégories et les titres des meilleures ventes, avec une seconde aiguille résolue par canonical/niche-vocabulary.ts (le vocabulaire vertical, sorti de la sonde catalog) pour le cas que la première ne peut pas couvrir — la fiche stocke Jewelry, l'utilisateur tape bijoux. Un mot que le vocabulaire ignore n'ajoute pas d'aiguille et n'élargit donc jamais le filtre. min_layer est un plancher de qualité. La fenêtre est une constante nommée (2000), et chaque réponse porte scanned et window_truncated.

/api/intelligence/opportunities et /intelligence/radar classent la même façon (fenêtre de fraîcheur, tri en JS) parce que opportunity_score vit dans le JSONB et qu'il n'y a rien à indexer. L'en-tête de la route promettait « the highest-opportunity PUBLIC stores BoostEcom tracks » : il décrit maintenant la fenêtre, et la réponse porte scanned et window_truncated — les mêmes deux champs que nl-search, sur la même fenêtre de 2000, pour que la page et l'API ne puissent pas diverger sur le sens de « top ». La correction qui supprime la fenêtre (trois colonnes indexées écrites au scan) est intelligence/0384 : elle touche prisma/schema.prisma, donc elle a son propre item.

24.7 /api/intelligence/top est cacheable

Le catalogue de découverte est dérivé d'une constante de code, et répondait sans Cache-Control, donc en invoquant une fonction à chaque poll d'agent. Il porte maintenant public, s-maxage=3600, stale-while-revalidate=86400, la forme de /api/pricing/models : pas de max-age, parce que le commit 9127f3d69 a dû corriger le lien web de chaque catégorie et qu'une heure de cache navigateur aurait continué à servir le lien mort après le déploiement qui le réparait.

24.8 Ce que traffic-provider dit de lui-même

Le bloc « Legal posture » affirmait lire « the same public HTML a logged-out browser sees ». 900 lignes plus bas, la sonde forge un Referer, force requireRenderedHtml et pose stealth: true pour franchir le mur anti-bot de SimilarWeb, en le documentant. L'en-tête dit maintenant ce que le code fait. La question de fond, continuer ou non à franchir le mur d'une source commerciale, est une décision opérateur avec une dimension contractuelle : intelligence/0388.


25. Le plancher de dépense, et la ligne que personne ne rescannait (2026-09-06)

Deux constats du lot P2 des audits des 3 et 5 septembre, tranchés ici parce que les deux décidaient d'une dépense sans qu'aucun chiffre ne soit écrit nulle part.

25.1 Un scan on-demand anonyme coûtait un rendu, et rien ne le comptait

Le fait, mesuré sur le code du 6 septembre :

CheminAppels rendusCrédits Firecrawl
Page d'accueil partagée (app_detector, pixel_detector, analytics, reviews_aggregation, social_handle_extractor, popup_detector, emails)11
traffic_provider (SimilarWeb, stealth)11, 5 sur mur anti-bot, et SimilarWeb nous mure
reviews_vendor (Trustpilot, stealth)11 à 5
storefront_screenshot (appel direct api.firecrawl.dev)11 (estimatedUsd: 0.01)
social_metrics_fetcher (4 surfaces stealth, IG/FB refusés par politique avant l'appel)≤ 41 à 5 chacun
meta_ad_library_fetcher0source gratuite d'abord, acteur Apify estimatedUsd: 0.1 si vide

Soit ~8 à 15 crédits pour un scan complet, et exactement 1 pour un scan dégradé : les sept sondes de la première ligne demandent requireRenderedHtml, fetchWithFallback jette une réponse direct valide dès qu'un provider de rendu est configuré, et cacheKeyFor(url, rendered, stealth) fusionne leurs sept requêtes en un seul appel.

Ce plancher de 1 crédit était borné par une seule chose : la limite de la route, 8 scans/heure/IP. Soit 192 rendus par jour et par adresse, sans plafond global, sur un endpoint qui ne demande aucun compte. À ~3 300 crédits/jour (ce qu'autorise un mois à 100k crédits), dix-huit adresses en régime continu épuisent la journée. Et la réservation quotidienne existante (INTELLIGENCE_ON_DEMAND_PAID_SCANS_PER_DAY, 100 par défaut) ne bornait que le surplus : une fois atteinte, chaque recherche continuait de coûter son crédit.

Amplificateur que l'item n'avait pas vu : le fast path de la route exige monthly_visits != null || first_archive_at != null. En régime dégradé traffic_provider est exclu, donc monthly_visits ne se remplit jamais, et la complétude ne tient plus qu'à first_archive_at (Wayback). Une boutique jeune, précisément ce qu'un utilisateur du Spy cherche, n'a pas d'archive : sa fiche ne devient jamais « complète », le fast path ne s'enclenche jamais, et chaque recherche du même domaine repaie un rendu, indéfiniment.

Décision : option 1 de l'item. Une seconde réservation quotidienne, reserveOnDemandRenderScan, sur son propre compteur (intelligence:on-demand:render-scans). Refusée, le scan tourne dans withoutRenderSpend : mêmes sondes, probeFetch épinglé au provider direct, requireRenderedHtml et stealth retirés. Le visiteur garde une réponse (plus maigre là où une vitrine a besoin de JS), la dépense s'arrête.

Trois choix à l'intérieur de cette décision, et pourquoi :

  • Pas de seconde variable d'environnement. Le plafond de rendus est INTELLIGENCE_ON_DEMAND_PAID_SCANS_PER_DAY × 10. Deux leviers indépendants peuvent se contredire, et la contradiction qui compte est =0, documenté comme « garde la recherche disponible, ne dépense rien » : avec un plafond de rendus autonome, ce réglage aurait continué d'acheter un rendu par recherche. Dérivé, « ne dépense rien » est exact. Au défaut, cela fait ≤ 100 scans complets + ≤ 900 scans à un rendu, soit ~1,7 à 2,4k crédits/jour.
  • L'enforcement est dans probeFetch, pas dans chaque sonde. requireRenderedHtml: true est écrit dans sept modules ; un drapeau que sept sondes doivent penser à honorer sera oublié par l'une d'elles, et la fuite serait exactement le rendu qu'on borne. probeFetch est la seule porte vers la chaîne de providers, et un test le dérive de l'arbre plutôt que de l'affirmer.
  • AsyncLocalStorage et pas un booléen de module. Deux recherches peuvent être en vol dans le même lambda ; un drapeau partagé laisserait le budget de l'une décider de l'autre.

Ce qui n'a PAS été fait, et pourquoi. L'option 2 (baisser RATE_LIMIT pour les domaines non indexés) touche le calcul d'identité IP de la route, réécrit par ailleurs sur security-identity/0366 : deux branches sur les mêmes lignes est le conflit qu'on évite. Elle reste ouverte si le plafond dérivé se révèle trop haut.

25.2 Le tier RETRY : la ligne dont le premier scan n'a rien donné

listColdTier exclut maxLayer: { not: null }, HOT exige un storeId, WARM et spy-full exigent 3 lookups par semaine. Une ligne externe, vivante, sans aucune couche de données n'était donc sélectionnée par aucun tier — et elle ne pouvait pas non plus gagner ses lookups, puisque ne portant aucune donnée elle n'apparaît dans aucun classement d'où un visiteur pourrait la chercher. Discovery payait pour la trouver, un scan échouait une fois (timeout, mur anti-bot, 429), et plus rien ne réessayait jamais.

Décision : option A de l'item (un vrai passage de ré-évaluation), malgré l'absence du chiffre de production que l'item demandait (SELECT count(*) … WHERE storeId IS NULL AND isAlive AND maxLayer IS NULL n'est pas mesurable depuis un clone). La raison est que A est la seule des deux options dont le coût ne dépend pas de ce chiffre : le lot quotidien est de 20 lignes quelle que soit la taille du bassin, donc 20 scans quick par jour, point. B (supprimer les lignes) est destructif et irréversible, et le décider sans compter ce qu'on supprime n'est pas une décision, c'est un pari.

listRetryTier sélectionne le complément exact de COLD (externe, PUBLIC, vivant, maxLayer: null), retire les domaines que WARM prend déjà, et exige que lastScanFail soit nul ou plus vieux que 7 jours. Il est branché en TÊTE du cron refresh-cold existant (pas de nouveau créneau : la limite Vercel est à 100 et 59 sont pris), avec 60s de budget contre 170s pour COLD — en second, un bassin COLD volumineux l'affamerait tous les jours, ce qui est la forme du défaut qu'il répare.

Deux détails qui décident si le passage tourne vraiment :

  • Le tri est lastScanFail asc NULLS FIRST, explicitement. Postgres trie les nuls en DERNIER sur un ordre ascendant, donc sans la mention les lignes jamais réessayées — celles pour qui ce passage existe — seraient au fond de leur propre file.
  • La rotation tient à une colonne que le sweep de liveness ne remet pas à zéro. Ce bassin est plein de domaines vivants et inscannables : le sweep réécrit lastScanOk et consecutiveFailures à chaque passage, donc trier sur l'un des deux aurait rendu les vingt mêmes lignes tous les jours. applyLivenessOutcome n'écrit lastScanFail que sur l'échec, et ne l'efface jamais.

Le compteur à surveiller est retry.scansOk dans cron.intelligence.refresh_cold.done : rapporté à part et non additionné aux totaux COLD, parce que c'est le seul nombre qui dit si le bassin se vide.

25.3 Turnstile devant le budget, pas derrière

Le plafond 8/h/IP et les deux réservations quotidiennes bornent un appelant qui a déjà le droit de scanner. Ils ne distinguent pas un visiteur d'un script qui tourne les adresses. Depuis intelligence/2761, quand NEXT_PUBLIC_TURNSTILE_SITE_KEY est posée, POST /api/intelligence/hub/scan exige un jeton Turnstile d'action hub-scan avant le seau par IP et avant resolveBareLabel (le fan-out qui teste une dizaine de TLD). Jeton absent ou rejeté : 403, aucune sonde, aucun crédit. Sans site key (dev), la route reste ouverte, même porte que /api/auth/send-otp. Le bouton du hub envoie le jeton et le réémet après chaque scan : un jeton Turnstile est à usage unique.

26. Une clé, un lecteur, un verdict (audit 2026-09-03, lot P2/P3 restant)

intelligence/0320 et intelligence/0363. Ce que ce lot corrige n'a rien de spectaculaire pris ligne à ligne, et c'est le point : chaque défaut produisait une réponse plausible et bien formée, donc aucun ne pouvait apparaître comme un bug.

26.1 Une seule normalisation décide de primaryDomain

Quatre implémentations privées de normalizeDomain coexistaient dans le pilier, avec des règles différentes : liveness.ts gardait www., store-meta.ts renvoyait url.hostname (donc www. aussi), hub/scan/route.ts enchaînait un nettoyage inline puis rappelait normalizeDomain quatre-vingts lignes plus bas, et discovery/sources/apify.ts — que l'audit n'avait pas vue — normalisait sans retirer www. du tout.

Conséquence : checkLiveness("www.acme.com") sondait https://www.acme.com pendant que la ligne qu'il met à jour est clé acme.com, et une ligne de dataset Apify lisant www.acme.com créait une seconde StoreIntelligence au lieu d'un second regard sur la même boutique.

canonical/identity.ts est désormais la seule implémentation. C'est un module pur (zéro import), donc « il vit dans services/ » n'a jamais été une raison d'en écrire une cinquième dans lib/. src/test/intelligence-pillar-hygiene.test.ts refuse toute nouvelle déclaration et vérifie la parité www. / port / chemin / query / casse.

Ce qui reste chez apify.ts est ce qui n'est PAS de la normalisation : rejet des valeurs IP-like et mail-style, re-suffixage d'un handle myshopify nu. La fonction s'appelle toCandidateDomain, ce qu'elle fait.

26.2 Un inspecteur, deux rendus

/api/intelligence/inspect/[tool] et /intelligence/inspect/[tool] répondaient à la même question et portaient chacun sa copie du garde de visibilité, du getCanonicalRecord, de la table surface → section et de la boucle isPresent. Les deux copies avaient déjà divergé, et dans le mauvais sens : la page rendait error.message — une chaîne Prisma, nom de table et de colonne compris — sur une page publique, pendant que la route disait en commentaire « Don't leak internals to anonymous callers ».

src/services/discovery/inspect.ts (inspectDomain) porte maintenant la décision ; les deux appelants portent la présentation. Un échec y est journalisé et ressort en status: "error" sans message : aucun appelant ne peut plus en afficher un.

Effet de bord voulu : le not_found de la route ne dit plus POURQUOI. Un domaine non-PUBLIC répondait « The owner has opted out », un domaine inconnu « No canonical record yet » — même code, même statut, deux messages. C'était un oracle : un appelant anonyme distinguait « nous avons une fiche et son propriétaire l'a délistée » de « nous n'avons jamais vu cette boutique », ce que le garde de visibilité existe précisément pour ne pas dire. Les deux cas renvoient la même réponse.

Le comptage de demande (recordIntelligenceLookup) reste une option explicite : la route JSON compte, la page publique n'a jamais compté, et en faire un effet de bord silencieux de la lecture aurait changé ce que les crons de refresh dépensent, piloté par une surface qui n'a jamais demandé à facturer.

26.3 Le hash IP de l'opt-out : option A, et ce qui manquait vraiment

IntelligenceOptOut.ipHash est écrit à chaque opt-out et lu par aucun code. intelligence/0363 proposait A (chaîner INTELLIGENCE_ANON_SECRET || NEXTAUTH_SECRET avant le repli aléatoire), B (arrêter d'écrire la colonne) ou C (faire porter le plafond quotidien par la colonne).

A retenu, et la chaîne était déjà là. src/env/server.ts résout INTELLIGENCE_ANON_SECRET sur les trois noms en || (jamais ?? : un env non posé sur Vercel arrive en ""). L'unique lecture de antiSpamSalt() les parcourt donc déjà toutes les trois, et réécrire la chaîne dans anon-secret.ts aurait été du code mort. Ce qui manquait n'était pas la ligne, c'était la preuve : anon-secret.test.ts épingle les deux moitiés — la chaîne || dans le schéma env, et la propriété qui rend la colonne joignable : le même secret redonne le même sel après un rechargement de module, c'est-à-dire après un cold start.

B a été écarté parce que la colonne a bien un lecteur, humain : n'importe qui peut délister n'importe quel domaine ici, sans compte ni preuve, et grouper les lignes par ipHash sur une fenêtre est la façon dont une campagne devient visible sans ajouter de table (intelligence/0241). Le warn seul sort d'un drain de logs au bout de sa rétention, pas les lignes.

C a été écarté : il ferait dépendre un chemin de retrait RGPD d'une lecture DB par requête, et le cron prune-history supprime les opt-outs non vérifiés au bout de 30 jours — ce qui remettrait le plafond à zéro pour l'adresse concernée.

Le commentaire du schéma ne dit plus « salted hash for anti-spam rate limiting » : ce n'est pas ce que la colonne fait, les deux plafonds tournent sur rateLimit clé sur l'adresse brute.

26.4 Une variable d'environnement qui ne fait rien le dit

  • CLICKHOUSE_URL : les deux branches de getTimeSeriesStore() renvoyaient postgresStore et la fonction ne contenait aucun appel au logger, alors que son commentaire promettait « a one-line marker so monitoring can flag the discrepancy ». Elle passait donc le test « consommée » et restait dans l'env-matrix comme un levier. Le marqueur est émis, une fois par processus — le resolver est appelé à chaque point enregistré par refresh-runner.
  • OPENCORPORATES_API_TOKEN : l'en-tête annonçait un « free tier (no API key required) ». OpenCorporates exige api_token sur chaque requête. Sans la variable, le filet global de la cascade lookup.ts dépensait 5s de budget pour un 401 que if (!res.ok) return [] transformait en « aucune entreprise trouvée ». Il court-circuite maintenant et le dit une fois par processus.

26.5 Un audit de posture qui écrivait ses propres verdicts

security.sweep alimente /admin/settings/security et un score sur 100. Six de ses contrôles passaient le littéral "ok" et ne mesuraient rien. Le pire, forms.csrf, déclarait sain :

NextAuth issues SameSite=Lax cookies. State-changing routes still require a custom x-boostecom-csrf header for cross-site fetch hardening.

x-boostecom-csrf figurait dans cette phrase et dans aucun autre fichier du dépôt. Aucun middleware ne l'exigeait, aucun helper ne l'attachait. Le tableau de bord marquait 100 pour un contrôle qui n'avait jamais existé, et demandait à l'opérateur de « confirmer » un en-tête introuvable.

Chaque contrôle observe désormais quelque chose (une variable posée, un export de module, la table SECURITY_HEADERS) ou dit warn en nommant le manque. forms.zod-validation a été retiré plutôt que maquillé : « tout handler POST devrait valider son corps » n'est pas observable depuis un processus, et le README prescrit lui-même de retirer un contrôle périmé plutôt que de le porter.

src/test/security-sweep-no-constant-status.test.ts refuse un nouveau littéral "ok" ou "skipped" — les deux statuts qui valent 100. Un warn littéral reste légal : c'est un aveu qui coûte du score tant que le trou n'est pas bouché, il ne peut flatter personne.

Enfin, l'en-tête et le README promettaient un cron quotidien qui poste les régressions critiques. Il n'existe pas, ni dans vercel.json ni sous src/app/api/cron. Les deux documents le disent, et le test échoue si quelqu'un ajoute le cron sans les corriger.

Le même défaut est revenu le 2026-09-17 sous une autre forme : deux contrôles qui mesuraient bien quelque chose, mais pas la posture écrite. headers.hsts exigeait Cross-Origin-Embedder-Policy, que csp.ts omet à dessein (COEP casse le Live View Browserbase) — la remédiation du sweep aurait re-cassé l'inspecteur. Et storage.token-encryption disait « tokens en clair », critique, dès que TOKEN_ENCRYPTION_KEY manquait, alors que crypto.ts retombe sur NEXTAUTH_SECRET, confirmé présent par le même rapport. Le sweep lit désormais CORP (qui est dans la baseline) et la chaîne de repli complète (ok / warn / critical selon le maillon présent). src/test/security-sweep-matches-the-posture.test.ts dérive les deux vérités de csp.ts et crypto.ts (intelligence/2712).

26.6 Frontières : deux fichiers changent de pilier

  • src/lib/browser/web-speech-shim.ts (types Web Speech API navigateur) vivait dans le répertoire des providers headless serveur, propriété du pilier intelligence, pour n'être importé que par le chat vocal. Il part sous src/features/ai/chat/lib/integrations/voice/.
  • src/lib/anon-key.ts (cookie de vote anonyme /roadmap) était attribué à intelligence par ownership.json alors que ses deux seuls consommateurs sont growth-web. La ligne change de pilier, et le fichier lit serverEnv.NODE_ENV au lieu de process.env.NODE_ENV — le schéma typé normalise un NODE_ENV absent en "production", donc les deux lectures divergeaient exactement là où ça compte : le flag Secure du cookie.

27. IntelligenceWatchlist retiré : StoreTracker devient la seule primitive de suivi (2026-09-11)

Le modèle IntelligenceWatchlist (ligne 12 du tableau ci-dessus) n'a jamais eu d'écrivain : grep -rn "intelligenceWatchlist" src/ hors schéma ne rendait que la lecture de /[orgSlug]/intelligence/page.tsx. Aucun create, upsert ni createMany n'a jamais existé — l'audit du 2026-09-03 l'avait déjà constaté (intelligence-surfaces-11, docs/audits/2026-09-03-full-codebase-audit.md) et prescrit exactement la correction appliquée ici : la page lit maintenant les StoreTracker des boutiques de l'org (déjà écrits par /api/intelligence/hub/tracker, le bouton "Track in Brand Tracker" du Store Spy), et le modèle mort a été supprimé du schéma.

Deux primitives portaient la même idée (suivre un domaine concurrent) : StoreTracker (par boutique, réellement câblé) et IntelligenceWatchlist (par org, jamais câblé). Il n'en reste qu'une.

Ce qui change concrètement :

  • src/services/algorithms/intelligence/org-tracker-rollup.ts — fonction pure rollupOrgTrackers() qui fusionne les StoreTracker de toutes les boutiques d'une org en une liste dédupliquée par domaine (deux boutiques qui suivent le même concurrent ne produisent qu'une ligne, avec l'attribution des deux boutiques et la date d'ajout la plus ancienne).
  • /[orgSlug]/intelligence lit désormais Store.competitorTrackers (join StoreTracker) au lieu de IntelligenceWatchlist, et affiche quelle(s) boutique(s) suivent chaque domaine — un effet de réseau réel et minimal : plus une org connecte de boutiques, plus son équipe profite du travail de veille de chacune, sans qu'aucune boutique n'ait à re-suivre un concurrent qu'une autre suit déjà.
  • L'état vide ne promet plus un outil @Atlas "watch" qui n'a jamais existé : il pointe vers le Store Spy réel (/#boostecom-os), où vit le bouton "Track in Brand Tracker".
  • model IntelligenceWatchlist et la relation Organization.intelligenceWatchlists sont retirés de prisma/schema.prisma ; le guard régénéré ne provisionne plus la table. La table physique (toujours vide, par construction) reste en base jusqu'à un prisma db push opérateur — aucune perte de données possible puisqu'elle n'a jamais eu de ligne.