ArchitectureBoostEcom — Architecture Agentic Stack

BoostEcom — Architecture Agentic Stack

Ce que ce document est. La carte des trois projets de l'écosystème et du partage des responsabilités entre eux : qui héberge quoi, et par où passent les beacons, les webhooks et le contexte DOM. À lire avant tout…

Ce que ce document est. La carte des trois projets de l'écosystème et du partage des responsabilités entre eux : qui héberge quoi, et par où passent les beacons, les webhooks et le contexte DOM. À lire avant tout travail sur les pipelines RUM ou commerce.

Ce qu'il n'est pas. L'inventaire du pilier ai-platform. L'arborescence vit dans CLAUDE.md ; les surfaces réelles sont nommées et pointées en § Les surfaces du pilier, plus bas.

Deux sections sont datées de mai 2026 et signalées comme telles : « Le plan de mai » et « Roadmap ». Elles décrivent ce qui était visé à ce moment-là, pas l'état d'aujourd'hui. Le mélange des deux est le défaut que l'item ai-platform/0058 a corrigé ici : un lecteur ouvrait ce fichier pour connaître l'architecture et y trouvait un plan, sans que rien ne le dise.

Les 3 projets de l'écosystème (et où ils vivent)

┌──────────────────────────────────────────────────────────────────────────┐
│  1. BoostEcom Platform (ce repo : boostecom.app)                          │
│     Backend SaaS Next.js + Postgres Neon + Upstash Redis                  │
│     - Dashboards merchant (insights, vitals, scanner, marketplace)        │
│     - Tables normalisées (ShopifyOrder, WebVitalSample, ShopifyPixelEvent…)│
│     - Endpoints publics d'ingestion (beacons)                             │
│     - AI agents (Atlas + 5 specialists)                                   │
│     - Crons + QStash jobs                                                 │
│     - MCP server interne exposé à /api/mcp/[storeId]                      │
└──────────────────────────────────────────────────────────────────────────┘
              ▲                            ▲                     ▲
              │ beacons                    │ webhooks            │ DOM ctx
              │ + dashboards               │ + UI extensions     │ + panel
              │                            │                     │
┌─────────────────────────────┐  ┌──────────────────────────┐ ┌──────────────┐
│ Shopify storefront          │  │ 2. Theme Copilot AI      │ │ 3. BoostEcom Spy │
│ (du merchant)               │  │   (repo séparé)          │ │  for         │
│  - Theme App Extension      │  │   App Shopify publiée :  │ │  BoostEcom   │
│    "Web Vitals Collector"   │  │   apps.shopify.com/      │ │  (repo séparé)│
│    (App Embed Block)        │  │     theme-copilot-ai     │ │              │
│  - Web Pixel Extension      │  │                          │ │ Chrome ext   │
│    "Web Pixel Collector"    │  │  Contient :              │ │ publiée :    │
│                             │  │   - OAuth Shopify        │ │ chromewebstore│
│                             │  │   - Scopes catalog /     │ │   .google.com │
│                             │  │     orders / themes      │ │              │
│ → Beacons vers              │  │   - Theme monitoring,    │ │ → DOM picker │
│   /api/vitals/ingest +      │  │     backup, marketplace, │ │   → /api/    │
│   /api/pixels/ingest        │  │     updater (existant)   │ │   intelligence│
│                             │  │   - Extensions Shopify   │ │   /panel/    │
│                             │  │     livrees ici le       │ │   ingest     │
│                             │  │     2026-09-07 :         │ │              │
│                             │  │      vitals + pixel      │ │              │
│                             │  │                          │ │              │
└─────────────────────────────┘  └──────────────────────────┘ └──────────────┘

Qui contient quoi

1. Repo boostecom.app (ce repo)

Ne contient AUCUNE extension Shopify ni Chrome. Tout le code Shopify-side vit dans Theme Copilot AI, tout le code Chrome-side vit dans BoostEcom Spy.

Contient :

  • Backend Next.js (App Router)
  • Tables Prisma + migrations
  • Modules src/features/ (vitals, commerce, shopify/ingester, pixels, ai, …)
  • Endpoints d'ingestion + dashboards
  • Crons Vercel + QStash handlers

La phrase en gras ci-dessus est vraie depuis le 7 septembre 2026 seulement. Jusque-là ce repo hébergeait extensions/_drafts-for-theme-copilot-ai/ (un Theme App Extension et un Web Pixel Extension livrables), ce qui la contredisait à trois lignes d'intervalle. Les deux ont été portés dans le repo Theme Copilot AI et le répertoire extensions/ n'existe plus ici.

2. Theme Copilot AI (apps.shopify.com/theme-copilot-ai)

App Shopify publiée. Repo séparé (pas dans ce monorepo).

Capacités existantes (selon src/config/ecosystem.ts + brief utilisateur) :

  • Theme monitoring du store
  • Backup automatique
  • Marketplace de thèmes
  • Theme updater
  • OAuth Shopify + scopes catalog/orders/themes

Héberge nos extensions Shopify depuis le 7 septembre 2026 :

  • extensions/theme-extension/blocks/vitals-collector_boostecom.liquid — App Embed Block du collecteur Web Vitals. Pas un répertoire d'extension à lui : Shopify plafonne un app à UN seul Theme App Extension, et cette app en a déjà un (extensions/theme-extension/, uid déjà enregistré). Un second répertoire type = "theme" est refusé par la CLI. Le collecteur est donc un block de plus dans l'extension existante, et son handle vient du nom de son fichier Liquid.
  • extensions/pixel-extension/ — Web Pixel Extension (type distinct, pas soumis à ce plafond, donc bien son propre répertoire). Nommé d'après son type, comme theme-extension/ : le handle d'une extension est gelé au premier deploy et n'accepte pas d'underscore, donc ni le nom du draft (web-pixel-collector) ni le suffixe _boostecom des blocks Liquid ne pouvaient s'appliquer ici. Communication avec boostecom.app :
  • Webhooks Shopify → /api/webhooks/shopify/events
  • Beacons (storefront) → /api/vitals/ingest, /api/pixels/ingest
  • OAuth callback → /api/auth/callback/shopify

Deux répertoires d'extension figuraient ici, sidekick-data/ et sidekick-actions/, marqués « à créer », en face d'un /api/sidekick/* marqué « à wirer ». Personne ne les a créés, et le backend qui les attendait a été supprimé (integrations/0598) : voir la note sous le tableau de communication.

3. BoostEcom Spy (Chrome Web Store, déjà publié)

Chrome extension. Repo séparé (worktree Extensions/@BoostEcom selon CLAUDE.md).

Capacités :

  • DOM element picker sur n'importe quelle page
  • Envoi du contexte sélectionné à @Atlas via /api/intelligence/panel/ingest
  • Session BoostEcom de l'utilisateur (plus de secret partagé embarqué)

Pas concerné par les chantiers vitals/commerce.

Comment ça communique

SourceDestinationEndpointAuth
Theme Copilot AI (webhook receiver)boostecom.app/api/webhooks/shopify/eventsHMAC Shopify
Storefront Theme App Extensionboostecom.app/api/vitals/ingestOrigin matche store.domain
Storefront Web Pixelboostecom.app/api/pixels/ingestOrigin matche store.domain
BoostEcom Spyboostecom.app/api/intelligence/panel/ingestSession utilisateur + Origin extension publiée
boostecom.app (agents)Shopify Admin GraphQLdirect via BridgeOAuth Theme Copilot AI
boostecom.app (agents)Storefront MCP officiel (par store)via /api/mcp/[storeId] bridgeOAuth public scope

Ce tableau portait une septième ligne, /api/sidekick/{tool}, et elle méritait sa note : HMAC sans horodatage ni fenêtre de rejeu, sur une racine de signature partagée avec les beacons du storefront, donc un seul corps capturé était rejouable indéfiniment. La note a été écrite, le actionNonce rendu obligatoire, le plafond par IP posé, l'hôte vérifié.

Ce que la note n'a jamais demandé, c'est qui appelait. Personne. Ni la plateforme, ni l'extension publiée : grep sur les deux dépôts rend zéro. Deux routes authentifiées ont donc porté pendant des mois un secret partagé avec l'identité client, pour un client qui n'a jamais existé, et le durcissement les a rendues plus sûres sans les rendre utiles.

Les deux routes et src/features/sidekick/ sont supprimées (integrations/0598). La racine VITALS_BEACON_SECRET n'a plus que deux lecteurs, tenus par src/env/shared-secrets.test.ts. Si la surface revient un jour, elle revient avec un appelant, pas avant : c'est la leçon que cette note remplace.

Les surfaces du pilier

Le document ci-dessus décrit où vit l'agentique. Ce qu'elle contient tient dans quelques chemins, volontairement donnés sans dénombrement : un chiffre écrit à la main dans un document rote en silence, et c'est exactement le défaut que cette page vient de corriger. Un chemin, lui, se vérifie d'un ls.

SurfaceOùCe qu'on y trouve
Orchestrateursrc/features/ai/orchestrator/La boucle @Atlas et son runtime. Un tour de chat est runtime/handler.ts, qui enchaîne quatre modules dans un ordre qui est le comportement : handler-gates.ts (qui peut dépenser, combien à la fois), handler-prompt-assembly.ts (le prompt jusqu'à l'overlay client), handler-tools-assembly.ts (le ToolSet et les fragments qui l'annoncent, ensemble pour qu'un outil absent ne soit jamais annoncé), handler-lifecycle.ts (les trois callbacks du stream et leur unique verrou de règlement). handleChatRequest faisait 1 359 lignes d'une seule fonction jusqu'à ai-platform/0455 ; les gates et le cycle de vie ont depuis un test qui les appelle, pas une regex qui les lit
Agentssrc/features/ai/agents/Identités, registre, tool-permission-matrix.ts, autonomy.ts
Outilssrc/features/ai/tools/Un fichier *-tools.ts par domaine, plus permissions.ts et fence.ts
Skillssrc/features/ai/skills/registry.ts, router.ts, loader.ts, et un répertoire par skill (dont creative-ads)
Wizard skillssrc/features/ai/wizard-skills/Les étapes du wizard turnkey
Mémoiresrc/features/ai/memory/Voir memory-layer.md, la doc dédiée
Promptssrc/features/ai/prompts/kernel.ts, le texte que chaque tour lit sur chaque canal, et layer-system.ts, le composeur 3 couches. Un seul kernel, voir ci-dessous
Personnalitésrc/features/ai/personality/PCM, avec son propre README

L'identité publique décrit le runtime, pas un second jeu de rôles

Un agent a deux fichiers : sa définition runtime (agents/agents/specialists.ts : périmètre, prompt, outils) et son identité publique (agents/identity-registry.ts : prénom, titre, bio, outils affichés, niveau d'autonomie). Le second ne décide rien, mais c'est lui que lisent les profils /agents/<prénom>, llms.txt, le sitemap et le chat. Jusqu'à ai-platform/3016 il portait un autre jeu de rôles que le routeur : « @Otis builds your email and SMS flows » pendant que chaque demande Klaviyo partait chez Marketing.

Le kernel lui-même (prompts/kernel.ts, lu à chaque tour) portait ce second jeu dans sa liste « Quand déléguer » : il disait à @Atlas que Klaviyo et les flows étaient à @Otis, pendant que le roster ajouté au même prompt les donnait à Marketing. L'éval de routage ne pouvait pas le voir : elle construit son prompt à partir du roster seul.

Depuis, quatre règles, chacune tenue par un test :

  • la liste du kernel nomme chaque spécialiste par le domaine du roster (specialistDomainLabel), et Klaviyo n'apparaît que sur la ligne de Marketing (prompts/kernel-team.test.ts) ;

  • les outils d'un profil de spécialiste sont ceux de sa définition runtime, à l'identique (identity-registry-tools.test.ts) ;

  • le slug d'un profil est le prénom en minuscules, jamais le rôle, et l'artwork vit sous public/agents/<slug>/ (même test). Les anciens slugs de rôle répondent 308 (ADR 0040) ;

  • une carte de scénario sur un profil rejoue un cas de evals/routing.eval.ts routé vers CET agent (agent-scenarios.test.ts). Le cas « Draft a Klaviyo abandoned-cart flow » y a été ajouté pour que la promesse de @Maya sur les flows e-mail reste vraie du routeur.

Un changement de rôle se fait donc dans specialists.ts et team-roster.ts d'abord, puis dans le registre et les six catalogues (agents.registry.<prénom>), jamais dans l'autre sens.

Un seul kernel, et il est du texte versionné

Le prompt système d'un tour part d'un seul texte, prompts/kernel.ts (getKernelPrompt) : identité, lois, expertise, hors-scope. Le chat web le compose avec le contexte du tour dans orchestrator/runtime/handler-prompt-kernel.ts (composeKernelPrompt : éléments attachés, fichiers, modules, overlay d'activité récente), puis composeAtlasPrompt (prompts/layer-system.ts) le splice en Layer 2 entre les six règles de base et l'overlay de la boutique. WhatsApp lui ajoute son contexte de canal ; la voix (EVI) a son propre kernel, plus court, dans sa route.

Jusqu'à l'ADR 0022 ce texte s'appelait getFallbackPrompt, « repli » d'un @Atlas configuré en YAML (@Atlas/kernel.yaml, routing.yaml, agents/, skills/) qu'un chargeur de 900 lignes attendait derrière six gardes isAtlasAvailable() sur trois canaux. Le dossier n'a jamais existé dans l'historique du dépôt (ai-platform/0116, vérifié sur quatre fronts) : le « repli » était le seul chemin que chaque déploiement ait jamais exécuté, et trois notions de « kernel » coexistaient dans le code (le type YAML AtlasKernel, composeKernelPrompt, le « Layer 2 : @Atlas kernel » du composeur) pendant que memory-layer.md l'appelait « iRen kernel ». Le chargeur, son composeur, son routeur par mots-clés, sa famille de types et le barrel orchestrator/index.ts qui les ré-exportait sont supprimés ; le composeur 3 couches est rapatrié depuis services/algorithms/prompts/ (ai-platform/0599), où il faisait de chaque édition des règles de base une PR cross-pilier.

Ce que le routeur YAML faisait et que rien ne remplace : rien. Le routage par mots-clés vers un spécialiste n'a jamais tourné ; la délégation réelle est celle de l'équipe (orchestrator/team/delegate-tool.ts), et la poche mémoire d'un tour est forceAgent, puis atlas.agent de la requête, puis atlas. src/test/one-atlas-kernel.test.ts refuse qu'un second kernel, un chargeur ou une garde de disponibilité reviennent.

La matrice de permissions que cherche un lecteur est src/features/ai/agents/tool-permission-matrix.ts ; le garde-fou d'exécution est wrap-tools-with-autonomy.ts, à côté.

Elle ne classe que des outils réellement enregistrés, et tool-risk-coverage.test.ts le vérifie dans les deux sens : tout outil enregistré a un niveau de risque, et toute clé de la matrice correspond à un outil que quelque chose enregistre. Le second sens manquait, et la matrice avait accumulé sept entrées fantômes — read, write, edit, bash, delegateToSpecialist, searchKnowledge, requestUserApproval — dont quatre sous un titre « Filesystem tools » désignant un répertoire qui ne contenait aucun outil (ai-platform-tools-13). Une matrice qui classe des capacités absentes se lit comme un confinement : un relecteur qui voit bash classé en conclut que les appels shell sont encadrés, alors qu'il n'y a pas de shell. Les entrées sont retirées, avec le module de regex de chemins et de commandes qui n'existait que pour elles.

Un skill de boutique, et les trois portes qui le tiennent

modules.skills était écrit fidèlement par deux surfaces et lu par personne : selectSkillForRequest ne consultait que le registre disque. Une ligne portait de surcroît { id, label?, description?, enabled } et aucun corps, donc il n'y avait rien à router même si le routeur avait regardé. C'est le défaut enabledSkills que le commentaire de use-store-skills se félicite d'avoir corrigé, corrigé d'un seul côté.

Le format n'a pas été inventé : les skills du disque sont déjà au format Agent Skills (skill.md avec id, name, description, triggers, tools, tier, plus prompt.md en corps). Il y avait un format à ouvrir, pas à concevoir.

Trois choses décident si l'ouverture est sûre, et aucune n'est un contrôle ajouté après coup :

Un skill ne peut que RÉTRÉCIR les outils. applySkillFilter intersecte la liste du skill avec le ToolSet que le runtime a déjà construit pour ce tour, cet appelant, cette couche et ce plan. Nommer deleteStore n'accorde rien : le skill choisit dans ce que l'appelant possède. C'est ce qui rend défendable de laisser un marchand écrire ce champ, et store-skill-tools-cannot-widen.test.ts épingle le SENS de l'opération, pas sa sortie du jour : types.ts énonçait le contrat en prose, et la prose est ce qu'un futur remaniement lit juste avant de changer un filtre en fusion. Ce jour-là, l'échec est silencieux — le skill d'un marchand se met à mieux marcher, et personne n'ouvre de ticket sur un outil apparu.

Un id natif est refusé trois fois. Le routeur résout par id, donc une ligne qui s'appelle seo-audit ferait tourner les instructions du marchand sous le nom d'un skill que la plateforme livre : détournement de routage, pas collision de nommage. Refusé à l'écriture par store-skill-schema, refusé à la lecture par loadStoreSkills, refusé par l'outil — trois portes parce qu'aucune n'atteint les lignes que les autres couvrent (une ligne antérieure au garde, un futur écrivain qui contourne le schéma).

Une ligne d'avant reste inerte. Corps ET description sont exigés avant qu'une ligne devienne routable. Sans corps, c'est le vieux basculeur, toujours inerte. Sans description, elle serait injoignable par construction : la passe regex lit les triggers, le classifieur lit les descriptions. Rien de dormant ne devient vivant en silence.

Le marchand écrit un skill en le demandant (createStoreSkill), pas par un formulaire. Ce n'est pas de l'économie d'UI : un skill est un prompt, et ce que les gens ratent n'est pas le formulaire mais la rédaction — une description qui dit QUAND l'utiliser, des triggers qu'un marchand taperait vraiment, des instructions qui sont une méthode et pas un souhait. Un formulaire collecte ces champs et n'en juge aucun. Les règles de rédaction vivent donc dans la description de l'outil, là où le modèle qui rédige les lit. Les surfaces d'édition existantes (plus-menu, page settings) continuent de lister et de basculer.

Une conséquence non évidente, trouvée par le test et pas par la relecture : l'id d'un skill de boutique est préfixé store:, et parseClassifierAnswer nettoyait la réponse du classifieur avec [^a-z0-9-]. Le : disparaissait, la réponse ne correspondait plus à rien, et le tour retombait sur general — en silence, alors que le skill était bien dans le menu ET dans l'ensemble éligible.

Le setup clé en main converge sur la lecture, il ne se déclenche pas

Un marchand qui connecte une boutique arrivait devant un chat vide et un plus-menu vide. Le schéma le disait lui-même sur StoreContext.modules : « Empty by default ; never seeded ». Tous les montages concurrents sont un tas de prompts et de bascules que l'utilisateur assemble à la main ; le nôtre est censé être l'inverse, et il partait exactement du même point.

Le pack vit dans features/ai/setup-pack/ et sa forme est décidée par deux contraintes qui tirent en sens opposé : clé en main, puis entièrement au marchand.

Versionné en code, pas semé en base. Même raison que config/native-skills.ts : un pack en code est un DEPLOY et pas une migration, donc améliorer une règle la propage à tous les tenants au tour suivant, sans rien à exécuter et sans lignes par boutique à réconcilier. C'est la moitié « quand nous améliorons, le marchand reçoit », et elle est gratuite.

Additif, jamais destructeur. C'est l'autre moitié, et c'est celle qu'on perd facilement. Un pack qui se réaffirme se bat contre le marchand : supprimez une règle, elle revient au tour suivant. Le manifeste modules.setupPack enregistre donc ce qui a été offert, jamais ce qui est présent — un id déjà offert n'est plus jamais reposé, donc une règle supprimée reste supprimée et une règle réécrite garde les mots du marchand. Même discipline que le schema guard généré : ajouter seulement, ne rien retirer, être idempotent.

Il converge sur la lecture. ensureSetupPack tourne sur le chemin chaud du chat, pas sur un événement de connexion, et c'est le point le plus important à ne pas « simplifier » plus tard. Les boutiques arrivent par le wizard turnkey, par l'OAuth Shopify, par une Custom App et par le pool de dev stores ; semer « à la connexion » veut dire trouver ces quatre chemins et se souvenir du cinquième, plus écrire un backfill pour toutes celles qui existaient avant. Converger sur la lecture couvre toute boutique qui parle un jour à @Atlas, sans backfill et sans oubli — exactement la propriété sur laquelle le schema guard repose.

Le coût sur un tour est une lecture indexée. Le court-circuit ne porte pas sur la version seule mais sur la version plus une empreinte des trois conditions dont dépend la dérivation. La version seule ne suffirait pas : la règle du thème live et la tâche de canal ne s'appliquent qu'une fois Shopify connecté, donc une boutique évaluée le premier jour, jugée non concernée, ne serait jamais réexaminée. Sur l'empreinte, le pack se réévalue exactement quand la boutique change d'une manière qui change la réponse, et jamais autrement.

Une loi du prompt sans outil est pire qu'une loi absente

composeAtlasPrompt epingle des lois dans chaque prompt systeme : la loi marketplace-first (prompts/marketplace-first-law.ts) ordonne a @Atlas d'interroger la marketplace BoostEcom avant toute autre piste des qu'on lui demande de recruter, installer, comparer ou evaluer une ressource.

Elle a vecu des mois sans qu'aucun outil ne sache le faire. Quatre-vingt- quatre outils enregistres, et le seul du domaine lisait le bureau personnel de l'appelant, jamais le catalogue. Le moteur existait pourtant — searchListings(), FTS Postgres, cache Redis — avec une route REST et un provider de recherche globale, et aucune porte cote chat.

Le mode de defaillance vaut d'etre nomme, parce qu'il ne ressemble pas a une panne. La meme loi interdit d'inventer une fiche, donc la reponse conforme a « trouve-moi un theme Shopify » etait le silence : le produit marketplace-first ne disait rien de sa propre marketplace. Rien dans le code ne le montrait — un prompt se lit parfaitement tout seul, et une capacite absente n'a pas de trace.

D'ou la garde, dans src/test/prompt-laws-have-tools.test.ts : aucun code ne peut deriver « cette phrase anglaise implique cet outil », donc le lien est declare dans une table LAWS, et le test prouve les deux moities de chaque ligne — la loi est toujours epissee dans le prompt, et son outil est toujours enregistre, sur la couche connected et pas seulement sur la couche agent. Ajouter une loi qui ordonne une capacite = ajouter une ligne. C'est le seul moment ou quelqu'un se demande si @Atlas sait faire ce qu'on vient de lui ordonner.

Corollaire pour l'outil lui-meme (tools/marketplace-tools.ts) : le catalogue se remplit encore, donc zero resultat est le chemin commun, pas le cas limite. La branche vide rend le protocole que la regle 4 de la loi prescrit — le dire, chercher sur le web avec sources, ne rien inventer — au lieu d'une liste vide sur laquelle le modele improviserait. Et le texte des fiches est redige par qui les soumet : il sort par fenceUntrusted, comme toute chaine tierce de ce repertoire.

Qui a le droit de parler dans une conversation

Une Conversation existe sous deux formes, et la distinction gouverne tout le reste :

FormeConversation.userIdQui y accède
Chat de boutique (le défaut)nulltout membre de l'organisation propriétaire du store, via getStoreAccess
Chat privé (accueil, compte)l'id du propriétairelui seul

Le client choisit la forme par sa clé : store:<storeId> demande la première, chat:<canal>:user:<id>:no-store la seconde (use-chat-persistence.ts). Le serveur ne fait pas confiance à cette clé : il revalide le storeId contre le tenant de l'appelant avant d'accepter d'écrire userId: null.

Trois vérifications distinctes en découlent, et elles ne posent pas la même question :

  • Parler dans le fil — verifyConversationOwnership (orchestrator/runtime/handler-messages-prep.ts). Rend l'id « de confiance » du tour, celui auquel sont accrochés le résumé persistant (ConversationSummary), l'auto-titre, le conversationId de chaque ligne AgentAction, la couche PCM et le self-feeder. Sur un fil partagé : accès au store et égalité avec le store de la requête, un résumé étant de la mémoire longue qui n'a pas à traverser d'une marque à l'autre. Le paramètre de store est obligatoire dans la signature : un paramètre optionnel laissait un appelant l'oublier sans que rien ne le dise.
  • Voir un message — resolveVisibleMessage (api/chat/messages/[id]), même règle que resolveOwnedConversation dans api/chat/conversations/[id]/messages. Rend null aussi bien pour « n'existe pas » que pour « pas à toi » : la route ne dit jamais lequel des deux.
  • Modifier un message — mayMutate, une fois la visibilité acquise. Noter une réponse (pouce) ne pose pas cette question du tout : c'est la porte large, tout membre du store note n'importe quel tour assistant. Supprimer : son propre message, ou un tour assistant (écrit avec authorUserId: null, donc sans auteur à opposer). Éditer : l'auteur seul, parce qu'une édition tronque la suite d'un fil que toute l'organisation lit.

Le refus est toujours un 404, jamais un 403 : sur un fil partagé, un statut distinguable laisserait un membre cartographier qui a écrit quoi en sondant la route.

Ce qu'un serveur MCP tiers a le droit de faire

Les outils qu'un serveur MCP expose arrivent au moment de la connexion : leurs noms sont ceux que le serveur annonce, donc ils ne peuvent pas figurer dans TOOL_RISK_MAP. Ils étaient tous inconnus de resolveToolRisk, ce qui valait safe_write.

safe_write n'est pas le milieu prudent que le nom suggère : il s'exécute sans surveillance dès le niveau 2, et le runtime du chat fixe le niveau de session à 2 ou 3, jamais 1. Le défaut n'était donc pas un milieu, c'était un oui — appliqué à l'écriture d'une page Notion, à une génération vidéo facturée, et à chaque outil d'un serveur ajouté par un membre.

Trois changements, ai-platform/0173 :

OùQuoi
resolveToolRiskinconnu = destructive, plus jamais safe_write
mcp/tool-risk.tschaque outil est classé à la connexion, sous son nom ${serveur}__${outil}, et enregistré là où la porte le lira
registerClientToolsle résultat de l'outil est fencé : c'est du texte du système de quelqu'un d'autre qui entre dans le contexte du modèle

Le même raisonnement vaut pour une source que personne ne classe comme « tierce » : les résultats du Shopify Admin. Une note de commande, un nom de client, un champ de métaobjet sont du texte écrit par les clients du marchand, et fence.ts les nomme dans son propre en-tête. Les deux outils GraphQL (Admin et Storefront) rendaient pourtant data brut à côté d'un marqueur _untrusted fixe, alors que le délimiteur aléatoire de fenceUntrusted existe précisément parce qu'un délimiteur fixe se contrefait : il suffit que le contenu émette le jeton de fermeture. Corrigé par ai-platform/0274 ; la porte d'autonomie reste le filet pour ce qui passerait quand même.

La classification lit d'abord la capacité, ensuite le nom : un list_and_delete_pages est une écriture même s'il commence par un verbe de lecture, et un getApiKey aussi. Ce qui survit à ce scan est lu par son verbe, sur les deux premiers mots du nom — les serveurs préfixent couramment leur propre nom (notion-fetch à côté de notion-update-page), donc ne lire que le premier classerait toutes les lectures Notion en écritures.

Le scan de capacité lui-même était aveugle, et c'est la trouvaille qui rendait le reste sans effet : ses motifs sont ancrés sur \b, et _ est un caractère de mot en JavaScript. \bdelete\b ne matche donc pas dans delete_page, ni dans deletePage. snake_case et camelCase sont les deux conventions de nommage des serveurs MCP : delete_page, drop_table, purge_cache, refund_order, exec_command, get_api_key rendaient tous « pas risqué ». Seul le kebab-case matchait. verify-client.test.ts épingle les trois formes.

Conséquence assumée : un outil MCP en écriture demande désormais une approbation aux niveaux 1 et 2. C'est voulu, et c'est visible — la génération d'images Higgsfield par le chat en fait partie. Le chemin principal du Studio passe par un outil Gateway déclaré, pas par le MCP.

Le niveau stocké est un plafond, jamais une suggestion

Deux choses décident du niveau d'un appel, et une seule a le droit de le faire monter :

SourceOùPeut
AgentAutonomy.level/[orgSlug]/~/agents, écrit par un owner/adminposer le plafond
le sélecteur du composeurdans le chat, par n'importe quel membredescendre en dessous

checkToolPermission prend donc le minimum des deux. Un sélecteur en « autonome » sur un agent stocké au niveau 1 rend le niveau 1.

Quand l'organisation n'a jamais posé de plafond, il faut bien que quelque chose réponde, et il y a deux candidats. Depuis ai-platform/0274 l'ordre est écrit : AgentPersona.defaultAutonomyLevel (l'override admin de /admin/ai/agents) d'abord, la valeur compilée dans identity-registry ensuite. C'était l'inverse, et pire : la registry en code était lue seule. Le champ admin était donc stocké, audité, réaffiché et obéi par rien, ce qui est la forme exacte du défaut décrit au point précédent. La valeur lue de la base passe par isAutonomyLevel plutôt que par un cast : la colonne est un Int?, et un niveau hors 1..3 ne serait pas « une mauvaise donnée », il traverserait toutes les comparaisons de la porte.

Jusqu'à ai-platform/0172 c'était override ?? stocké, et le handler passait un override à chaque requête (autonomous ? 3 : 2). Le niveau stocké n'était donc appliqué nulle part : il n'était lu que par la page du dashboard et par l'outil getAgentAutonomy, dont la description dit « level currently in force ». maya, marco, faye et sam sont livrés au niveau 1 et tournaient au niveau 2 — c'est-à-dire que tout safe_write s'exécutait sans carte d'approbation sur quatre agents qu'un opérateur avait délibérément laissés en « Propose ».

C'est la même règle que le point 3 de la section admin, vue de l'autre côté : un assistant capable de relever son propre niveau peut allonger sa propre laisse. Un sélecteur dans un composeur n'est pas le dashboard, et une session n'est pas une politique.

Conséquence visible, dite franchement : « autonome » veut maintenant dire « aussi autonome que le dashboard l'autorise ». @Atlas est livré au niveau 2, donc son sélecteur plafonne à 2 tant que personne ne le relève sur /[orgSlug]/~/agents. C'est le réglage qui fait ce qu'il annonce.

Comment une action approuvée s'exécute

La matrice rend trois réponses : execute, require_approval, deny. La deuxième n'est pas une fin de course, et c'est le point qui manquait.

tour 1  outil appelé → require_approval → ligne ToolApprovalRequest → carte inline
        l'opérateur décide → POST /api/agents/approvals/[id]/decide → status "approved"
tour 2  l'utilisateur relance, le modèle rappelle l'outil avec les mêmes arguments
        → checkToolPermission trouve la ligne, la dépense, retourne execute

La relance EST le chemin de reprise. Rien ne rejoue l'outil côté serveur depuis la route de décision : c'est délibéré, l'exécution reste dans le tour de chat qui la demande, avec son contexte, sa facturation et son ledger.

Une approbation vaut pour un appel et un seul :

Doit correspondrePourquoi
org, agent, nom de l'outilévident
les arguments, par empreinteapprouver la suppression d'une boutique n'approuve pas celle d'une autre. L'empreinte trie les clés à chaque niveau : le modèle régénère son objet d'arguments à chaque tour, l'ordre des clés n'est la décision de personne. Les tableaux gardent leur ordre
la conversationl'opérateur a répondu à une carte dans un fil qu'il lisait
non expirée24 h

La dépense est un updateMany encore conditionné sur status: "approved", donc de deux tours qui courent sur la même ligne exactement un voit count === 1. Le statut passe à consumed : approuvée ET dépensée. deny prime toujours sur une redemption — retirer un outil de la liste d'un agent doit survivre à une approbation d'hier.

Avant ai-platform/0171, rien ne relisait ToolApprovalRequest : chaque relance créait une nouvelle ligne en attente et l'opérateur approuvait dans le vide. Tout outil critical (deleteStore, forgetMemoryFact, setAgentAutonomy, deleteThemeAsset, publishStudioDrop (retire avec le plan C, ADR 0043), adminTriggerCron — approbation requise aux trois niveaux) était donc inatteignable depuis le chat par construction.

Ce que l'assistant peut faire de la plateforme elle-même

Deux fichiers, et la séparation entre eux est le contrat :

FichierContenu
tools/admin-tools.tsLes lectures : adminPanelMap, adminWorkloadSnapshot. Un test refuse toute mutation dans ce fichier.
tools/admin-write-tools.tsLes trois écritures, chacune passant par la server action que le bouton du panel appelle

Cette dernière règle est la garantie de fond. Un outil qui recopierait la requête Prisma divergerait de la garde et du journal AdminAuditLog le jour où l'une des deux change, et personne ne le verrait. Le test lit le fichier source et échoue sur toute requête directe.

Trois choses ne sont pas négociables ici :

  1. La porte est ctx.isPlatformAdmin, dérivé de la ligne User, jamais ctx.userRole — dont l'« admin » appartient aux clients. Le refus intervient avant l'appel : un résultat d'outil est du contexte modèle, donc lire puis filtrer a déjà fui.
  2. Aucune écriture admin n'est safe_write. Ce niveau signifie « mutation interne sans rayon externe » et s'exécute sans confirmation dès le niveau 2 d'autonomie. Les trois sortent de la plateforme. adminTriggerCron est critical — confirmation à tous les niveaux — parce que marketplace-payouts et reset-credits figurent parmi les crons déclarés.
  3. FORBIDDEN_ACTIONS ne peut que grandir. Bannir, créditer, approuver une annonce, arbitrer un litige, mettre un template en pause, changer l'autonomie d'un agent : aucun outil. La dernière mérite d'être dite à voix haute — un assistant capable de relever l'autonomie d'un agent peut allonger sa propre laisse. Retirer une entrée de cette liste est une décision, et elle se voit dans le diff.

La surface : où l'opérateur regarde

chat/runtime/chat-surface.ts classe le chemin courant en admin, store, org ou public, et le transport le pose dans le corps de chaque envoi. Lu au moment de l'envoi, pas au montage : le chat est un panneau persistant, on l'ouvre dans le panel et on écrit depuis sa boutique.

Ce signal n'ouvre rien. Il vient du client, donc il peut mentir. Les outils restent gatés par isPlatformAdmin, et le fragment admin n'existe que dans ce cas. La surface choisit parmi des cadrages déjà autorisés : dans le panel, le sujet par défaut est la plateforme ; ailleurs, c'est la boutique, et la capacité attend d'être demandée. Un test lit la source du handler et échoue si surface apparaît dans la moindre condition de garde.

Un montage d'intake déclenche, il ne converse pas — et il doit les deux

Il y a un fil par opérateur et il vit dans le panneau droit du cockpit. Tout autre montage de <AiChat> — le hero de la home, les trois closers de FAQ compacts — prend un texte et le passe à ce panneau. La règle porte un nom, intakeOnly, dérivé une seule fois de Boolean(onAuthenticatedSubmit) : cette prop EST le relais.

Un montage d'intake doit alors les DEUX moitiés, et le compilateur les tient ensemble depuis ai-platform/0604 : AIChatHandoffProps est une union où onAuthenticatedSubmit et onAuthRequired arrivent ensemble ou pas du tout. Les trois closers compacts n'avaient que la première, sur la foi d'un commentaire qui affirmait que le menu « + » du composer gérait le cas déconnecté tout seul. Il ne le gère pas : Envoyer et la puce Audit/Scan passent par onAuthRequired, qui valait undefined — le bouton ne faisait rien sur une vingtaine de pages publiques, pendant que le texte partait quand même en sessionStorage. Destination partagée : chat/runtime/auth-wall.ts (/auth?from=…).

Le stash (chat/runtime/pending-chat-intent.ts) a deux consommateurs légitimes, et leur ordre compte :

ConsommateurQuandCe qu'il en fait
shell-client.tsxl'opérateur a une org et une boutiquerouter.replace vers /{org}/{store}?initialPrompt=…, en honorant la boutique choisie avant l'auth
useResumePendingMessagedernier recours, dans le cockpitenvoie le tour directement

Le second est un ENFANT du premier, donc React vide son effet en premier. Il refuse pour cette raison un stash qui nomme une AUTRE boutique que celle du cockpit courant : le laisser le dépenser supprimerait silencieusement le choix de boutique de l'opérateur. Sa garde de sortie est intakeOnly, jamais la présence d'un onAuthRequired — cette déduction-là était exactement inversée, elle tuait le hook sur le dashboard et le laissait facturer un tour invisible sur les pages marketing.

La compaction se dit une fois, à l'endroit de la coupure

applySlidingWindow abandonne réellement les tours anciens et le fil le dit, par un CompactionSeparator. Mais droppedCount n'est pas un ÉVÉNEMENT : c'est un état permanent et croissant. Passé summarizeAbove (15 messages), il vaut longueur - keepLast et ne redescend jamais, donc le handler le ré-estampille à chaque tour et les métadonnées sont persistées par message. Rendu message par message, un fil de trente tours empilait une vingtaine de séparateurs disant « 6 », « 8 », « 10 »… soit le défaut symétrique de celui que l'item 0394 voulait corriger.

La position est donc dérivée du fil entier, côté client (chat/runtime/compaction-boundary.ts, ai-platform/0605) : on lit summarisedTurns sur le dernier message assistant qui en porte un, et on rend un séparateur à cet index, avant le premier message encore dans la fenêtre du modèle. Corriger côté serveur — n'estampiller que sur une augmentation — est juste en vol et faux après un rechargement : le handler n'a aucun état de session entre deux requêtes HTTP. Le client, lui, a le fil sous les yeux.


Ce que coute un fil que personne ne paie

Le bot WhatsApp plateforme (le numero BoostEcom lui-meme, par opposition au bot par boutique) parle a des gens qui n'ont pas de compte. Une Conversation de ce canal ne porte jamais d'orgId : rien dans le depot n'en ecrit un pour channel: "whatsapp", l'upsert du handler cree { externalId, channel } et met a jour {}. Toute la chaine de facturation du handler vit derriere if (billOrgId) et ne s'execute donc jamais la.

Ce n'est pas repare en inventant une organisation. Il n'en existe aucune a qui facturer, et rien dans le depot ne peut lier ce fil a un tenant : le resolveur telephone est un no-op documente, il n'y a pas d'organisation plateforme, et le phone_number_id n'est pas un liant sur ce chemin — c'est un disqualifiant, cette branche n'est atteinte que parce que la resolution par canal a rendu null.

Ce qui est repare, c'est le cout. Un tour anonyme :

AnonymeLie a une org
Outilsaucunweb_search (ou Firecrawl)
Boucle d'etapesune seulejusqu'a 5
Historique rejoue4 tours12
Sortieplafonneenon plafonnee
Memoireaucuneselon la config de l'org

Retirer les outils fait s'effondrer la boucle toute seule : stopWhen est lui-meme conditionne a hasTools. Et la discipline d'outils du prompt est desormais derivee du jeu d'outils reellement construit, pas d'une condition parallele : un prompt qui promet un outil absent du ToolSet est un generateur d'hallucination, le modele affirme avoir verifie.

Deux plafonds, dans features/ai/bot/anon-budget.ts :

  • le plafond — un compteur global, cle par jour UTC, pour toute la plateforme ;
  • l'equite — une part quotidienne par expediteur, qui empeche un seul numero de vider la journee de tout le monde.

Le plafond echoue ferme. Les deux regles du depot pointent ici dans le meme sens : un garde qui autorise une depense ne doit jamais autoriser quand il ne peut pas se lire, et la regle du chemin chaud (ne pas s'interposer entre un client payant et ce qu'il paie) ne s'applique pas — il n'y a pas de client payant derriere ce tour. Un visiteur qui arrive pendant une panne recoit une phrase fixe, qui ne coute rien.

Le plafond est construit sur kv.incr, deliberement pas sur rateLimit. rateLimit ne leve jamais : il attrape tout, y compris sa propre erreur de configuration, et retombe sur un compteur en memoire, par instance serverless. Un plafond « global » bati dessus devient N par lambda tiede, exactement dans la panne pour laquelle il a ete ecrit. kv.incr leve, et c'est la propriete dont ce garde a besoin. Deux budgets existants du depot ont ce defaut : intelligence/0351.

Ce qui reste ouvert : la depense anonyme est bornee mais toujours absente de /api/usage, faute d'org a qui l'attribuer. Elle se reconstitue depuis Message.tokensUsed sur les conversations sans orgId.

Le cycle proactif de @Atlas (ai-platform/3039)

@Atlas travaille entre deux conversations. Le cron atlas-proactive (src/app/api/cron/atlas-proactive/route.ts, tous les jours a 05:45 UTC, apres commerce-daily-aggregate) fait tourner, par boutique connectee (Store.domain non nul), les regles de src/services/agents/proactive-cycle.ts. Il est suivi dans /admin/platform/crons comme les autres.

  • Deterministe, aucun credit. Aucun appel de modele : des regles sur des lignes deja ecrites par d'autres jobs (RevenueDaily, StoreAlert des autres producteurs). Rien a estimer, rien a plafonner, rien a debiter. Un appel LLM ajoute ici devra passer par l'estimation et le plafond du chat.
  • Ce qu'il observe. Revenu net semaine contre semaine (seuil 20 %, plancher 100 $ la semaine precedente), une boutique qui a vendu la semaine d'avant et plus rien depuis sept jours, les alertes ouvertes non acquittees depuis plus de 48 h.
  • Ce qu'il ecrit. Des insights StoreAlert (source = "agent:atlas", kinds revenue/atlas/* et orders/atlas/*, que resolveTarget route vers le panneau Insights ; l'onglet Issues de la preview les liste), en info ou warn seulement : jamais critical, que store-alerts-dispatch enverrait par e-mail. Et des taches Task en PENDING, createdBy = "agent:atlas", assignees a un specialiste (@Maya, @Otis, @Faye). Jamais executees : les niveaux 1 et 2 veulent dire « proposer », et meme au-dela ce cycle ne fait que proposer, l'execution reste un tour de chat avec ses approbations.
  • Borne par le plan. Seules les PLAN_LIMITS[plan].stores premieres boutiques d'une organisation (les plus anciennes) sont servies, le plan lu par resolveEntitlement, jamais par le cache Organization.plan.
  • Idempotent par boutique et par jour. Upsert sur (storeId, kind), pas de nouvelle tache tant qu'une tache ouverte du meme titre existe, et les insights dont la regle ne se declenche plus sont resolus.

Le plan de mai 2026

Section datée, conservée comme archive. Elle raconte ce qui était visé le 22 mai 2026 et ce qui avait été livré à cette date. Elle n'est pas une liste de tâches en cours : les cinq points « à faire dans ce repo » ont tous été livrés depuis, et le tableau qui suit le montre chemin par chemin. Rien ici ne doit être lu comme du travail en attente.

Livré à cette date

Pipeline Web Vitals multi-source :

  • 4 tables (WebVitalSample, WebVitalAggregate, CruxRecord, PsiReport)
  • Sources : CrUX, PSI, Browserbase, RUM (via beacons)
  • 5 AI tools wired
  • Dashboard /[org]/[store]/systems/vitals

Pipeline Commerce normalisé :

  • 11 tables (ShopifyOrder + line items, ShopifyCustomer, ShopifyProductSnapshot, ShopifyInventoryLevel, RevenueDaily, ProductPerformanceDaily, CustomerCohort, AttributionTouch, DailyBriefing, ShopifyPixelEvent)
  • Ingester webhooks → tables typées
  • Aggregations daily + weekly cohorts/RFM
  • 8 AI tools commerce wired
  • Dashboard /[org]/[store]/insights + filtres 7/30/90/365j
  • Anomaly detection → StoreAlert auto
  • Setup page /insights/setup avec deep-links Shopify Admin
  • Briefing AI narratif quotidien (Sonnet 4.6 via AI Gateway)
  • Backfill historique AuditLog → tables normalisées (job pagine)

Endpoints d'ingestion :

  • POST /api/vitals/ingest (Origin-verified, no client secret)
  • POST /api/pixels/ingest (Origin-verified)
  • POST /api/admin/shopify/backfill (admin-gated)

Extensions Shopify : plus aucune dans ce repo. Les deux brouillons qui vivaient sous extensions/_drafts-for-theme-copilot-ai/ ont été portés dans le repo Theme Copilot AI le 7 septembre 2026 (platform-ops/0471, sortie 1). Seuls les endpoints qu'elles appellent restent ici.

Ce que mai listait « à faire dans ce repo », et ce que c'est devenu

Vérifié le 27 août 2026, un chemin par ligne. Les cinq sont livrés. Ils sont restés écrits comme des tâches ouvertes pendant trois mois, ce qui est le vrai coût du mélange plan/état : un lecteur en déduisait cinq chantiers en attente qui n'existaient pas.

Point de maiLivré ici
Storefront MCP consumersrc/features/shopify/storefront-mcp/client.ts
AEO : générateur llms.txt par storesrc/app/api/stores/[storeId]/llms.txt/route.ts
Sidekick app extensions, specs backendLivré, puis supprimé : le backend n'a jamais eu d'appelant (integrations/0598)
Pipeline healthchecksrc/app/api/admin/pipeline/health/route.ts
Roadmap dans CLAUDE.mdLa table « Roadmap » de CLAUDE.md

🟡 Restait à faire dans le repo Theme Copilot AI

Travail a mener dans l'AUTRE depot, pas ici. Les deux premiers points sont faits depuis le 7 septembre 2026 ; que le deploy ait suivi ne se verifie pas depuis ce depot.

  1. Copier web-vitals-collector/ — porte en App Embed Block dans l'extension existante (extensions/theme-extension/blocks/vitals-collector_boostecom.liquid), et pas en repertoire separe : le plafond Shopify est d'UN Theme App Extension par app.
  2. Copier web-pixel-collector/ — porte sous le nom pixel-extension/, avec api_version remonte de 2025-04 (hors fenetre de support) a 2026-07.
  3. Ajouter les Sidekick app data + action targets — abandonné avec le backend qui les attendait (integrations/0598).
  4. shopify app deploy pour publier l'app + extensions.

🟡 Restait à faire côté ops (pas du code, hors de ce dépôt)

  1. Appliquer le schéma en production pour les nouvelles tables. Cette ligne a vieilli sur le fond : le dépôt ne pousse plus le schéma avec --accept-data-loss, un déploiement ne peut plus supprimer de données, et le delta est appliqué par le schema guard au premier cold-start. Voir la section « Schema sync en deploy » de CLAUDE.md.
  2. GOOGLE_PAGESPEED_API_KEY provisionné (GCP Console, gratuit). La clé est déclarée dans src/env/server.ts ; qu'elle soit posée en production ne se vérifie pas depuis le dépôt.
  3. VITALS_BEACON_SECRET, même remarque.
  4. Vérifier les crons Vercel actifs. Ils sont dénombrés et surveillés ailleurs : vercel.json, et /admin/platform/crons.

Roadmap (projection de mai 2026)

Datée elle aussi. Ce tableau exprime des priorités et des ordres de grandeur tels qu'ils étaient posés en mai. Il n'engage aucun planning courant : le travail réellement en attente vit dans backlog/, un fichier par item, et c'est la seule liste qui fasse foi.

PilierEffortPourquoi
AEO complet2-3 semUCP-compliance audit + llms.txt + LLM citation tracking. Différenciation 2026.
AOV engine3-4 semUpsell/cross-sell/bundles. ROI direct merchant.
CRO engine (A/B + funnel)4-6 semFunnel visualization + A/B infrastructure.
True Ads ROAS3-4 semMeta/Google Ads OAuth → matched orders via UTM.
SEO classique2-3 semSearch Console + keyword tracking.
Email perf (Klaviyo deep)1-2 semPer-flow revenue attribution.