Audit — pilier ai-platform
Semaine du 8 au 14 septembre 2026, tour ai-platform de la rotation docs/team/roster.md. Le pilier precedemment audite a ce titre l'a ete le 25 aout (2026-08-25-ai-platform.md), soit 17 jours. Lecture seule sur le code…
Semaine du 8 au 14 septembre 2026, tour
ai-platformde la rotationdocs/team/roster.md. Le pilier precedemment audite a ce titre l'a ete le 25 aout (2026-08-25-ai-platform.md), soit 17 jours.Lecture seule sur le code. La sortie complete de ce tour est ce fichier plus huit items de backlog. Aucune ligne de
src/n'a ete modifiee.
Périmètre
Commit audite : 906b716c (origin/main, 2026-09-11 03:5x UTC). Les
huit constats ont ete re-verifies sur ce sha apres que origin/main a
avance pendant la passe.
Perimetre lu (fiche @ai-platform-engineer, roster.md + les 26
chemins de ownership.json) :
| Chemin | Fichiers | Lignes TS/TSX |
|---|---|---|
src/features/ai/** | 521 | 92 594 |
src/app/api/chat|agents|workflow|sidekick|tasks|branches|evi|registry/** | 44 | 6 368 |
src/services/agents|agent-autonomy|agent-ledger|ai-models|skills|knowledge/** | 23 | 3 742 |
src/features/sidekick/**, src/lib/workflows|presence/** | 17 | 1 616 |
evals/** (+ vitest.evals.config.ts) | 5 (+1) | 665 |
docs/architecture/agentic-stack|memory-layer|atlas-channels.md, docs/canvas/** | 13 | — |
Soit 104 985 lignes, mesurees sur 906b716c — un chiffre qui aura bouge
avant que vous ne lisiez ceci, et c'est le sujet : store-history-tool.ts
est arrive dans ce perimetre pendant la passe (cf. P2-5). Une fraction a
ete lue, et la section « Ce qui n'a pas ete couvert » dit laquelle.
Ce qui a ete passe en revue exhaustivement
| Sonde | Resultat |
|---|---|
maxSteps dans le perimetre | 0 |
toDataStreamResponse() | 0 |
generateObject / streamObject (hors commentaires et tests) | 0 |
useChat({ api }) | 0 |
framer-motion | 0 |
Imports directs @ai-sdk/anthropic|openai|google | 3, tous provider.tools.* — voir « Non-constats » |
message.content sur un UIMessage | 0 (5 occurrences, aucune sur un UIMessage — voir « Non-constats ») |
| Route handlers du perimetre sans porte d'authentification | 0 |
Entrees d'outil acceptant un storeId du modele | 1, re-scopee — voir « Non-constats » |
Copies de requireStoreId dans src/features/ai/tools/ | 7 → P2-5 |
pnpm deadcode (knip) | 0 fichier inutilise, 1 125 exports inutilises repo-large (dette connue, deja portee par 0277) |
pnpm fleet:scope sur ce diff | vert |
Ce qui n'a PAS ete couvert
src/features/ai/workflow/**(1,2 Mo) : le canvas est deja porte par0366,0391,0496; non relu.src/features/ai/chat/runtime/**: audite le 2026-09-08 (items0565a0570) ; non relu pour ne pas dupliquer.src/features/ai/sdk/**etsrc/features/ai/skills/**: parcourus, pas audites.- Le cout par tour, que la DoD du pilier exige de ne pas laisser
regresser. Toujours aucune mesure, le constat de l'audit du 25 aout
tient mot pour mot. Je ne rouvre pas d'item :
0279porte deja les tests manquants du pilier. - La memoire au-dela de
docs/architecture/memory-layer.mdet de son README vivant.
P0
Aucun. Rien dans ce perimetre ne casse la production maintenant, et rien n'ouvre un chemin cross-tenant. Les deux constats P1 ci-dessous degradent silencieusement, ce qui est different et se traite cette semaine plutot que cette nuit.
P1
P1-1 — Le cron cleanup-drafts n'archive rien, et le dira SUCCESS tous les jours
archiveBranch est une server action gatee sur une session navigateur :
src/features/ai/branches/actions/publish.ts:101—await requireOrgMembership(branch.store.orgId, "store.update")src/features/ai/branches/shared.ts:43—const user = await getSignedInUser();if (!user?.id) throw new Error("Unauthorized")
Son seul appelant est un cron :
src/app/api/cron/cleanup-drafts/route.ts:23—withCronAuth("cleanup-drafts", …), donc unBearer CRON_SECRET, aucun cookie de session:37—await archiveBranch(branch.id):39-41— lecatchpousse{ ok: false, error }dansresultset continue
Consequence : chaque ligne leve Unauthorized, la boucle en avale
jusqu'a 100 par run, et le handler rend { checked: N, archived: 0, results: [...] }.
Le second etage est ce qui rend le premier invisible.
src/services/cron/auth.ts:197 definit
FAILURE_KEYS = ["failed", "failedCount", "errors", "errorCount"] ; ce
handler n'expose aucune de ces clefs, donc verdictFor (:217-218)
retourne SUCCESS sans meme regarder les succes. La ligne
CronExecution est SUCCESS, le log est cron.cleanup-drafts.completed
au niveau info, et /admin/platform/crons affiche un job vert. Le seul
temoin est le contenu de results, dans le payload d'un log info.
Ce que ca coute reellement : le cron existe pour tenir la limite Shopify
de 20 themes par boutique (en-tete du fichier, ligne 5). Les
brouillons de theme crees par le chat-as-branch ne sont jamais supprimes
cote Shopify ; passe le 20e, themeDuplicate echoue et une boutique ne
peut plus ouvrir de conversation de theme. Le cron tourne
15 3 * * * (vercel.json:204-205).
Ce constat avait deja ete vu et n'a jamais ete transforme en item.
backlog/_archive/ai-platform/0593,
lignes 101-107, le decrit exactement, sous « Bogue latent sans rapport
releve au passage … A traiter separement ». backlog/inbox/ est vide et
aucun item ouvert ne cite cleanup-drafts. C'est la regle §6 du
protocole (« Un travail revele une suite → un nouvel item dans
backlog/inbox/ ») non appliquee, et la raison pour laquelle cet audit
existe.
P1-2 — Quatre portes du pilier lisent OrganizationMember a la main, la faute que cinq routes soeurs documentent comme corrigee
Organization.ownerId est une colonne. Une organisation heritee, creee
avant que l'inscription n'insere la ligne de son proprietaire, n'a
aucune ligne OrganizationMember pour son owner.
src/lib/security/tenant-auth.ts:54-58
traite ce cas en premier, et
src/app/api/admin/data-integrity/backfill-members/route.ts:22-35
existe uniquement pour les rattraper (WHERE NOT EXISTS (SELECT 1 FROM "OrganizationMember" …)), sur declenchement manuel d'un operateur. La
classe est donc reelle, et son eradication n'est pas garantie.
Quatre routes du pilier posent la question a la main :
| Fichier:ligne | Effet pour le proprietaire d'une org heritee |
|---|---|
src/app/api/chat/voice/route.ts:32 | orgId silencieusement abandonne |
src/app/api/branches/[id]/diff/route.ts:31 | 403 sur le diff de son propre theme |
src/app/api/branches/[id]/stream/route.ts:44 | 403 sur la timeline de versions |
src/app/api/tasks/[id]/stream/route.ts:45 | 403 sur la console de sa propre tache |
La plus couteuse est la voix, et elle merite d'etre deroulee. Le client
transmet l'orgId que le shell a resolu
(src/features/ai/chat/runtime/use-chat-setup.ts:61-63).
La route ne trouve pas de ligne membre, laisse orgId a undefined,
donc storeId aussi (:41-47), puis signe quand meme un
custom_session_id vide (:49). Cote CLM,
src/app/api/evi/chat/completions/route.ts:396-399
est explicite : « Unauthenticated turns (no orgId) have no balance to
charge: the IP rate limit above is their only bound ». Un proprietaire
qui paie obtient donc des tours vocaux @Atlas gratuits et non
factures, sans contexte boutique, plafonnes par 20 tours/min/IP — et
rien dans l'UI ne le dit.
Le commentaire :28-29 de cette route affirme la premisse inverse :
« the session user must be a member (owner rows are member rows
too) ». Cinq routes soeurs, hors pilier, ont corrige le meme defaut et
documentent pourquoi :
stores/[storeId]/alerts/route.ts:26-35 (supprime le 2026-09-26, app-shell/3033),
alerts/count/route.ts:46-52 (idem),
alerts/[alertId], probe/page-insights, reports/latest,
runtime-snapshot. ai-platform est le pilier qui a garde quatre
copies de la faute que tout le monde a reparee.
P1-3 — Le prompt systeme du pilier tait 9 de ses 26 chemins et envoie la regle « model names » sur le mauvais fichier
.claude/agents/ai-platform-engineer.md
est ce qu'un agent @ai-platform-engineer recoit au boot. Deux derives,
le meme fichier.
a) Le ## Scope (lignes 17-23) declare 15 chemins ; ownership.json
en attribue 26. Neuf manquent, en comptant les deux docs
d'architecture citees en prose dans ## Boot :
src/app/api/branches/** src/app/api/evi/**
src/app/api/registry/** src/app/api/wizard/skill/**
src/app/(minimal)/embed/** docs/architecture/atlas-channels.md
docs/canvas/** evals/**
vitest.evals.config.ts
Un agent qui lit ce fichier croit que les branches, EVI, le registre de
skills, l'embed public, docs/canvas/ et toute la suite d'evals ne
sont pas a lui — et pnpm fleet:scope lui dira le contraire au moment
de la PR. C'est mot pour mot le defaut que roster.md a corrige pour
ce pilier le 2026-09-05 : « cette ligne … taisait neuf chemins que
.claude/fleet/ownership.json attribue au pilier ». Le roster a ete
repare, le prompt systeme ne l'a pas ete, alors que roster.md:9-10
pose la regle : « si tu changes un perimetre ici, change-le aussi dans
ownership.json et dans .claude/agents/ ».
Rien ne l'attrape, et c'est verifiable :
src/test/roster-perimeter.test.ts:25-27
couvre bien .claude/agents/* mais dit explicitement
« Exhaustiveness is NOT checked » ;
src/test/pillar-scope-matches-ownership.test.ts,
qui verifie les deux sens, ne lit que docs/team/roster.md. Le trou est
exactement a l'intersection.
b) Ligne 44 : « Model names and versions come from config
(src/config/atlas-version.ts), never inline ». Ce fichier ne contient
aucun identifiant de modele : il exporte ATLAS_SURFACE_PATHS,
ATLAS_INTELLIGENCE_VERSION, ATLAS_MATURITY. Le catalogue est
src/config/ai-models.ts (MODELS,
modelId(), tierModelId()), tenu par pnpm models:check — ce que
CLAUDE.md et AGENTS.md disent tous les deux. Le chemin existe sur
disque, donc doc-paths-exist.test.ts passe : la derive est semantique,
et sa consequence est celle que ce test decrit lui-meme, « the agent
looks, finds nothing, and either gives up or RECREATES it ».
Les deux tiennent dans un seul item parce qu'ils se corrigent dans le meme fichier : deux items se disputeraient le meme verrou de branche.
P2
P2-4 — Le composeur du prompt @Atlas appartient au pilier intelligence
src/services/algorithms/prompts/layer-system.ts
porte composeAtlasPrompt (:67) et, en dur, les six regles de la
Layer 1 en FR et EN — veracite, citations, propriete de l'action,
locale, concision, confidentialite (:39-61). C'est la constitution que
le modele lit a chaque tour.
Ses deux seuls consommateurs runtime sont dans le pilier :
handler.ts:55,519
et
handler-system-layers.ts:34.
Mais ownership.json attribue src/services/algorithms/** a
intelligence, dont la mission est « Store Intelligence & Prediction ».
Donc : changer une regle de base du prompt @Atlas est une PR
cross-pilier ou une violation de perimetre, et un pilier dont ce n'est
pas le sujet possede 60 lignes de garde-fous LLM. Pire,
docs/architecture/memory-layer.md:19
— une doc d'ai-platform — presente ce fichier comme une surface du
pilier. La carte et le territoire se contredisent sans que rien ne le
signale : roster-perimeter.test.ts verifie qu'un chemin CITE est bien
au pilier, pas qu'un fichier soit au bon endroit.
P2-5 — Sept copies de requireStoreId, sept de notConnected, cinq de errMsg
Dans src/features/ai/tools/, un repertoire qui possede deja
permissions.ts et types.ts partages :
| Helper | Fichiers |
|---|---|
requireStoreId | store-history-tool.ts:25, aov-tools.ts:26, cro-tools.ts:19, action-plan-tools.ts:17, commerce-tools.ts:35, vitals-tools.ts:49, aeo-tools.ts:20 |
notConnected | memes fichiers, :29 :30 :23 :21 :39 :53 :24 |
errMsg | store-history-tool.ts:36, aov-tools.ts:37, cro-tools.ts:30, action-plan-tools.ts:28, aeo-tools.ts:31 |
requireStoreId est return ctx.storeId ?? null dans les sept. Les
notConnected different d'un mot (« AOV tools » / « CRO tools » / …) et
d'un type : vitals-tools.ts:53 rend { ok: false; error: string }
la ou les six autres rendent { ok: false as const, … }, donc le
narrowing du retour n'est pas le meme selon l'outil.
Ce n'est pas de la dette figee : store-history-tool.ts est la septieme
copie et n'existait pas il y a deux heures — elle est arrivee sur
origin/main pendant cette passe. Le motif se reproduit a chaque
nouveau domaine d'outils.
Meme famille, degre moindre, note sans item separe : requireOrgMembership
existe deux fois dans le pilier
(branches/shared.ts:39,
tasks/actions.ts:38). Le
commentaire :45-55 du premier explique justement que quatre homonymes
ont empeche toute sonde d'autorisation de les separer. Deux survivent.
P2-6 — Soixante lignes identiques entre deux wizard-skills
src/features/ai/wizard-skills/structured-data-wiring.ts:60-119
et
src/features/ai/wizard-skills/crawl-surface-builder.ts:73-132
portent chacun readThemeFile + upsertThemeFiles. diff sur les deux
blocs : deux lignes differentes sur soixante, et les deux sont le
nom de l'operation GraphQL (wireJsonLd vs wireRobots), qui n'a aucun
effet. Toute correction faite sur l'un (retry 429, gestion des
userErrors, pagination) manquera l'autre en silence.
P2-7 — Le harnais d'evals cite un ADR qui n'existe pas
evals/support/harness.ts:6 : « The
choice is argued in docs/decisions/2026-09-05-eval-harness.md ». Ce
fichier n'existe pas ; l'ADR est
docs/decisions/0008-le-harnais-d-eval-est-vitest.md,
que CLAUDE.md cite correctement. C'est la seule occurrence du chemin
fantome dans le depot. doc-paths-exist.test.ts ne lit que les
documents de boot en .md, pas les commentaires .ts, donc rien ne
l'attrape.
P2-8 — memory-layer.md appelle « iRen kernel » ce que le code appelle « @Atlas kernel »
docs/architecture/memory-layer.md:19 :
« Layer 1 (base) + Layer 2 (iRen kernel) + Layer 3 ». Le code dit
« Layer 2 : @Atlas kernel »
(layer-system.ts:5).
AGENTS.md a deja retire cette marque de son propre texte en notant
qu'elle « survives nowhere in src/ but two CSS comments ».
La ligne compte parce qu'elle est dans le tableau « What already exists
(DO NOT duplicate) », c'est-a-dire la partie de la page explicitement
tenue a jour — deux autres cellules du meme tableau ont ete corrigees en
place les 5 et 6 septembre. Un lecteur la lit donc comme du present.
0278 (hygiene-naming-organization-36) couvre @iRen dans
hub.css et identity-registry.ts ; sa preuve dit « rg iRen src →
ces 2 lignes seulement », scopee a src. docs/ n'a jamais ete
regarde.
Ce que ce tour renvoie hors pilier
Le trou de garde derriere P1-3 n'est pas propre a ai-platform. En
comparant chaque .claude/agents/<agent>.md a ownership.json :
| Pilier | Possede | Declare | Tait |
|---|---|---|---|
| growth-web | 39 | 17 | 27 |
| app-shell | 31 | 14 | 21 |
| ai-platform | 26 | 15 | 11 |
| platform-ops | 28 | 18 | 11 |
| data-platform | 13 | 7 | 6 |
| commerce-systems | 21 | 18 | 3 |
| billing | 15 | 13 | 2 |
| intelligence / marketplace / design-system | — | — | 1 chacun |
| security-identity / integrations | — | — | 0 |
L'item va dans backlog/inbox/ et pas dans un pilier : les fichiers
concernes (.claude/agents/**) appartiennent a @conductor, et le
garde (src/test/**) est une surface partagee. C'est @conductor qui
tranche.
Non-constats
Ce que j'ai pris pour un defaut et qui n'en est pas. Cette section existe pour qu'un prochain tour ne les rouvre pas.
Les trois imports @ai-sdk/anthropic sont corrects.
orchestrator/runtime/handler-tools-build.ts:27, bot/bot-handlers.ts:42,
bot/whatsapp-per-store.ts:22. Les trois usages sont
anthropic.tools.webSearch_20250305({ maxUses: 5 }) — un
provider-defined tool, pas un modele. Les modeles passent par
modelId("sonnet") et gatewayProvider: "anthropic".
src/test/ai-gateway-routing.test.ts tient la distinction, roster.md et
.claude/agents/ai-platform-engineer.md la formulent correctement depuis
0057. Deja ecrit comme « non-constat » le 25 aout ; c'est le deuxieme
tour ou je dois le re-verifier. Si une sonde automatique le resignale un
jour, c'est la sonde qui a tort.
Les cinq message.content ne sont pas l'anti-pattern. Aucun ne porte
sur un UIMessage du SDK : voice/messages.tsx:41 lit un
msg.message.content de @humeai/voice-react, widget/widget.tsx:197
un message du widget maison, context/level.ts:131 un objet
{ content: string } local, use-chat-persistence.ts:118 et
conversations/[id]/messages/route.ts:308 la colonne Message.content
de Prisma.
maxSteps : 0. toDataStreamResponse : 0. generateObject /
streamObject en usage : 0. Les seules occurrences des deux derniers
sont dans memory/extract.ts:173 et son test, qui les nomment pour dire
qu'ils sont interdits.
/api/branches/[id]/fork et /publish n'ont pas de porte dans le
route handler, et c'est correct. forkBranch / publishBranch
appellent requireOrgMembership(orgId, "store.update") en premiere ligne
(actions/publish.ts:32,81), et restoreVersion de meme. Le defaut
oppose — le void params qui ignorait l'id de branche de l'URL — a ete
corrige et documente dans l'en-tete de restore/route.ts.
publishStudioDrop accepte un storeId du modele, et c'est scope.
studio-write-tools.ts:158 : l'entree est re-resolue par
studioActorFor(caller, storeId, "studio.drop.publish") puis comparee a
ctx.orgId via outsideOrg(...) avant toute ecriture. C'est le seul
outil du registre qui prend un identifiant de tenant en entree.
docs/canvas/** n'est pas de la derive. Les dix pages portent le
meme bandeau : « archive de conception, mai 2026 … Ce qu'elles ne sont
pas : une description du code d'aujourd'hui », plus un tableau
« Ce que le plan de mai laissait ouvert, et ou ca en est » verifie
chemin par chemin le 2026-08-28. Les iRen qu'on y trouve sont dans le
perimetre de cette declaration. Nommer la chose morte et dire qu'elle est
morte est exactement la regle du depot.
docs/architecture/agentic-stack.md a ete reparee. L'item 0058
issu de l'audit du 25 aout est archive, le doc porte desormais un
en-tete « Ce que ce document est / n'est pas », une section
« Les surfaces du pilier » dont j'ai verifie les 15 chemins (tous
presents), et les deux sections de mai sont encadrees et datees.
src/features/ai/rules/ ne contient qu'un README, et c'est assume.
Le README dit « For now: empty placeholder » et nomme ou vit
l'implementation reelle. C'est la distinction que l'item 0056 a posee
entre un placeholder et un vestige.
src/features/ai/connectors/ (deux .gitkeep, zero code) est deja
suivi. 0277 (ai-platform-tools-35) et 0278
(hygiene-naming-organization-28) le portent tous les deux. Non
resignale.
Les 1 125 exports inutilises de knip ne sont pas un constat de ce
tour. 229 sont dans le perimetre, mais knip.json classe exports,
types et duplicates en "warn" : c'est une dette assumee au niveau
du depot, pas une regression du pilier. 0277 en porte deja les
morceaux nommes (sops.ts, MODEL_MAP, clearPresence).
Le rate-limit de /api/chat est maintenant par utilisateur.
0277 (ai-platform-runtime-33) decrit une cle par IP+chemin qui
throttlait une agence derriere un NAT. session-auth.ts:100-104 utilise
desormais l'identite authentifiee (security-identity/0364). Ce
sous-constat de 0277 est perime ; je ne le corrige pas, c'est au
pilier de le rayer en livrant l'item.
Prochain pilier de la rotation : integrations (audit du 26 aout, 16
jours, et quatre items blocked — le plus gros stock de blocage de la
flotte apres ai-platform).