Audits · septembre 2026Audit du modèle métier, 2026-09-11

Audit du modèle métier, 2026-09-11

Lecture du schéma et des chemins qui l'écrivent. Ce document, le garde src/test/store-delete-reaches-every-orphan.test.ts et les items de backlog qu'il ouvre sont la sortie complète. Périmètre : prisma/schema.prisma…

Lecture du schéma et des chemins qui l'écrivent. Ce document, le garde src/test/store-delete-reaches-every-orphan.test.ts et les items de backlog qu'il ouvre sont la sortie complète.

Périmètre : prisma/schema.prisma (155 modèles, 54 enums, 7 279 lignes) et les chemins applicatifs qui décident de l'appartenance d'une ligne à un locataire. Chaque chiffre de cette page a été dérivé du schéma, jamais compté à la main.

Ce que cet audit cherchait

La question posée était : concepts dupliqués, ownership flous, relations contradictoires, modèles inutiles, sources de vérité multiples. La réponse courte, et elle surprend : le schéma est sain. Les cascades sont cohérentes, les @@unique sont là où il faut, et la densité de documentation par colonne est plus élevée que dans la plupart des dépôts de cette taille.

Ce qui manque n'est pas de la rigueur, c'est de la symétrie. Les défauts trouvés sont presque tous de la même forme : une règle appliquée sur une des trois racines de locataire et pas sur les deux autres.

1. La colonne vertébrale

Trois racines, et rien d'autre ne définit l'appartenance :

User ──owns──> Organization ──owns──> Store
 │                   │                  │
 │             OrganizationMember        └── 45 enfants directs (FK)
 │             (RBAC : owner/admin/member/viewer)
 └── 27 enfants directs (FK)      18 enfants directs (FK)
RacineEnfants FK directsModèles portant sa clé sans FK
Store458
User279
Organization189

Store est le vrai centre de gravité : deux fois et demie plus d'enfants que l'organisation. C'est cohérent avec le produit — l'organisation facture, le store est ce que l'IA opère.

Répartition des 155 modèles par la clé de locataire qu'ils portent :

ClasseModèles
Scopés store (storeId seul)39
Scopés org (orgId seul)17
Portant les deux9
Scopés user15
Scopés par un parent (indirect)12
Sans aucun chemin FK vers une racine55

Les 55 derniers ne sont pas une anomalie en soi : Plan, AIModel, PlatformConfig, CronExecution, MarketCluster, StatusIncident sont des données de plateforme, elles n'appartiennent à personne par construction. Mais la liste contient aussi Conversation, OAuthAccessToken, BrowserSession, AlgoTrace, MemoryEvent et ApiToken — qui portent, eux, des données de locataire. C'est le constat 2.

2. L'ownership flou : 26 colonnes, 19 modèles

Dix-neuf modèles déclarent une colonne orgId / storeId / userId que aucune @relation ne lie. Postgres n'en sait donc rien : ni contrainte d'intégrité, ni cascade.

Une partie est délibérée et écrite comme telle dans le schéma — OrganizationArchive (« no FK on purpose : the org row is gone by design »), AffiliateCommission (« un ledger survit à l'entité qu'il désigne »), AuditLog.userId, Credit.userId. Un registre comptable ou un journal de sécurité doit survivre à son sujet : c'est un choix, pas un oubli.

Le reste ne l'était pas, et la conséquence n'est pas théorique.

2.1 Le côté organisation était gardé, le côté store ne l'était pas

src/test/org-delete-reaches-every-orphan.test.ts existe depuis app-shell/0281. Il dérive du schéma la liste des modèles portant un orgId nu et échoue tant que le handler DELETE /api/organizations/[orgId] ne les purge pas ou ne les excuse pas par écrit. C'est exactement la bonne mécanique.

Elle n'avait jamais été posée sur Store. Huit modèles portent un storeId nu ; deux étaient traités, six ne l'étaient pas :

ModèleÉtat avantConséquence
BrowserSessiontraité (app-shell/0281)—
AlgoTracetraité (app-shell/0281)—
OAuthAccessTokennon traitéjeton MCP valide jusqu'à son expiresAt, sur un store supprimé
OAuthAuthorizationCodenon traitémême famille
Conversationnon traitétranscription marchand + acheteurs conservée hors rétention
DevStorePoolretiré par un helperlégitime
ShopifyCustomerRedactionconservélégitime : c'est la preuve de l'effacement
ShopifyWebhookEventconservélégitime : purgé par son propre cron

OAuthAccessToken.storeId est, dans le schéma, « the resource (audience) the token will be bound to ». Toutes les autres portes de révocation du dépôt tapent sur l'utilisateur : auth/delete-account supprime par userId, lib/security/ban-enforcement.ts estampille revokedAt par utilisateur. Supprimer la ressource elle-même ne révoquait rien.

Conversation était couverte côté org — src/app/api/organizations/[orgId]/route.ts fait tx.conversation.deleteMany({ where: { orgId } }) — mais un store supprimé dans une organisation survivante passait à travers.

Corrigé dans cette PR, plus le garde dérivé qui empêche le neuvième modèle orphelin d'arriver en silence. Le reste du plan de data-platform/0521 — rattrapage des orphelins existants, puis pose des FK — reste une action opérateur, dans cet ordre : une ADD CONSTRAINT posée avant le nettoyage échoue en 23503 sur les lignes mêmes que le constat décrit.

2.2 Ce que la liste de 0521 contenait de trop

Deux entrées de ce plan ne devaient pas y figurer, et les laisser aurait coûté :

  • ShopifyCustomerRedaction — l'item le dit lui-même dans ses contraintes, mais la liste du « Résultat attendu » l'omettait. Supprimer un store ne doit pas effacer la preuve qu'un effacement RGPD a été demandé et drainé.
  • ShopifyWebhookEvent — ledger d'idempotence pur (webhookId, topic, storeId, un horodatage). Aucune donnée personnelle, et le cron prune-audit-log fait déjà vieillir chaque ligne. Une purge ici n'achèterait rien à la rétention et coûterait un scan complet : le modèle indexe createdAt, jamais storeId.

Les deux sont désormais des excuses écrites dans le garde, avec leur motif.

3. Les sources de vérité multiples

3.1 Deux tables pour « ce store est connecté à un fournisseur »

Connector (storeId, provider, accessToken, 27 usages) et IntegrationConnection (orgId, storeId, provider, accessToken, 109 usages) stockent la même chose.

Le partage s'est fait par accident d'historique, pas par domaine :

FournisseurTable écrite
google, meta, klaviyoConnector
shopifyIntegrationConnection
figmaIntegrationConnection

Figma est un connecteur OAuth de store comme Google ou Meta, mais il a été persisté dans la table d'appairage Shopify. Trois conséquences mesurables :

  1. GET /api/connectors?storeID=… lit db.getStoreConnectors() → Connector. Une connexion Figma n'y apparaît jamais, et DELETE /api/connectors/[connectorId] ne peut pas la révoquer.
  2. Le cron refresh-oauth-tokens lit Connector. Il ne verra jamais un jeton Figma.
  3. Le dépôt a donc dû écrire un rafraîchisseur parallèle, src/features/connectors/figma-token.ts, dont l'en-tête énonce le problème sans le nommer comme tel : « Nothing in the connector refresh job knows about Figma (it only handles google/meta on the Connector table) ».

Deux tables, deux chiffrements, deux rafraîchisseurs, une seule notion. Non corrigé ici : déplacer les lignes est une migration de données dans un dépôt sans prisma/migrations/, dont le guard généré est additif et ne sait pas déplacer. Item ouvert : integrations/0596.

3.2 Deux tables pour « ce que cet utilisateur a mis de côté »

Watchlist (userId, itemType, itemSlug, table bst_watchlist) et SavedListing (userId, listingId, FK vers MarketplaceListing).

Watchlist.itemType énumère store | agency | freelancer | app | theme | extension | skill | tool | prompt | mcp | cli : l'intersection avec le marketplace est presque totale. Deux surfaces distinctes en découlent, /account/saved et /account/watchlist, et aucune ne montre les lignes de l'autre. Sauvegarder une app depuis sa fiche marketplace et la sauvegarder depuis l'API /api/me/watchlist produisent deux états qui ne se voient pas.

SavedListing est le modèle correct des deux : clé composite, FK en cascade, index couvrant. Watchlist désigne ses cibles par une chaîne libre, donc rien ne garantit que itemSlug résout.

Non corrigé ici : pilier marketplace, et la fusion est un choix produit (laquelle des deux surfaces survit). Item ouvert : marketplace/0597.

4. Modèles inutiles

Un seul, sur 155 : SystemInstall. Zéro lecteur en production, une seule référence, dans le garde src/test/system-install-is-not-wired.test.ts qui existe précisément pour constater qu'il n'est pas branché. CLAUDE.md le dit déjà (« rien ne lit SystemInstall »).

Le laisser est le bon choix tant que le retrait n'est pas décidé : supprimer un modèle du schéma ne supprime pas la table, le guard généré étant additif. Le retrait est une action opérateur, pas un effet de bord de déploiement.

Ce chiffre mérite d'être lu pour ce qu'il est : 154 modèles sur 155 ont un lecteur. Un schéma de cette taille sans code mort est rare.

5. Ce qui n'est PAS un constat

Trois choses ont été vérifiées et tiennent :

  • Cohérence des cascades. Les 18 enfants d'Organization sont tous en Cascade. Les 45 de Store sont en Cascade sauf quatre SetNull délibérés (PlatformActivity, StoreIntelligence, Scan, Prospect) — chacun correspond à une entité qui doit survivre au store. Order.listingId est en Restrict : on ne supprime pas une annonce qui a des commandes. Aucune contradiction.
  • Organization.plan versus Subscription.plan. C'est un cache dénormalisé, il est documenté comme tel sur vingt lignes, écrit par un seul chemin (syncOrgPlanCache()), et aucune porte d'accès ne le lit. Ce n'est pas une double source de vérité, c'est une source de vérité et son index.
  • Les familles « audit / scan / rapport ». Scan, StoreReport, AeoAudit, PsiReport, StoreSnapshot se ressemblent de loin. De près elles ont des cycles de vie, des rétentions et des propriétaires différents (Scan est une surface publique partageable, AeoAudit une série temporelle par kind, StoreReport un job déclenché). Les fusionner coûterait plus que la ressemblance ne rapporte.

6. Ce que cette PR livre

LivrableFichier
Purge des trois orphelins à la suppression d'un storesrc/app/api/stores/[storeId]/route.ts
Garde dérivé, symétrique de celui de l'orgsrc/test/store-delete-reaches-every-orphan.test.ts
Cette cartographiedocs/audits/2026-09-11-modele-metier.md
Deux items pour ce qui n'est pas corrigé icibacklog/integrations/0596, backlog/marketplace/0597

Le garde a été vérifié dans les deux sens : vert avec le correctif, et rouge sans lui en nommant les trois modèles et la conséquence de sécurité.