Aller au contenu
Chandler Nguyen
IA16 min de lecture

J'ai donné ma plateforme marketing à 9 agents en open source

STRAŦUM v1 est désormais public, sous licence MIT. Neuf agents, deux schémas Postgres, dix langues — et une table livrée avec la Row Level Security désactivée, accessible à quiconque détient la clé qui se charge dans le navigateur. Voici ce que j'ai donné, ce que je ne construirais pas de la même façon, et pourquoi tu es libre de le forker et de construire le tien.

J'ai publié en open source la première version de ma plateforme marketing aujourd'hui.

Elle s'appelle STRAŦUM. Neuf agents IA, un espace de travail pour agences avec séparation des données par client, dix langues. Je l'ai construite en 2025, pendant que j'apprenais à écrire du logiciel. Ensuite, j'ai commencé une deuxième version, et plutôt que de garder la première sous clé, je l'ai donnée.

Si tu veux y jeter un œil avant de lire la suite : github.com/chandlernguyen/stratum-oss

git clone https://github.com/chandlernguyen/stratum-oss

Si tu diriges des équipes marketing et que tu n'ouvriras jamais ce dépôt, va directement à « Si tu ne liras jamais le code » vers la fin. Cette section est pour toi, et elle est courte.

Je veux être précis sur ce qu'est ce dépôt, parce que « open source » peut vouloir dire beaucoup de choses, et la plupart surestiment ce dont il s'agit ici.

Ce que j'ai réellement publié

STRAŦUM v1 est publiée et n'est plus activement développée. Le code est public sous licence MIT. Il n'y a pas de feuille de route, pas de calendrier de publication, et aucun engagement de support — le README le dit, et le guide de contribution le répète.

Il est publié comme implémentation de référence. C'est quelque chose à lire, à forker, ou dont on peut reprendre des morceaux. Ce n'est pas quelque chose sur lequel s'appuyer. Personne ne le maintient face aux futures évolutions des dépendances, et le dépôt liste les avis de sécurité ouverts qu'il connaît plutôt que de faire comme s'ils n'existaient pas.

Ce que je ne dis pas, c'est que ça ne changera jamais. Je ne le développe pas davantage, donc le mieux est de prévoir qu'il reste tel quel — mais je lis ce qu'on m'envoie, et si quelqu'un signale un bug ou propose quelque chose qui rend le code plus clair, je ne vais pas prétendre que je ne l'ai pas vu.

Le publier, c'était aussi prouver qu'aucune information confidentielle ne partait avec. Le dépôt contient un script qui scanne chaque fichier suivi à la recherche d'identifiants d'accès, de clés privées, de jetons de fournisseurs et d'identifiants de production, et il tourne en première étape de la CI — avant les tests, parce qu'une clé divulguée entre dans l'historique dès le push, et la supprimer après coup n'annule pas ça.

Voici ce qu'il contient :

  • Neuf agents — stratégie, persona, contenu, intelligence de performance, intelligence concurrentielle, planification de campagne, succès client, et deux autres. Chacun est une sous-classe d'un agent de base, avec une structure de prompt, un registre d'outils et un contexte progressif partagés.
  • Deux schémas Postgres. Les données des entreprises individuelles vivent dans l'un, celles des agences dans l'autre, avec la Row Level Security comme frontière d'isolation.
  • Dix locales, la locale transmise à l'API dans un en-tête pour que les métadonnées générées correspondent à la langue de l'interface.
  • Un mode démo qui ne nécessite aucune clé API. Les agents renvoient des réponses préenregistrées clairement étiquetées au lieu d'appeler un modèle, pour que tu puisses parcourir toute l'application sans compte chez un fournisseur.

Si tu veux construire ta propre version de tout ça

C'est l'usage que j'aimerais le plus voir, alors autant le dire clairement plutôt que de le laisser sous-entendu.

Si ce que tu veux vraiment, c'est ton propre système d'agents marketing — tes propres agents, tes propres prompts, ton propre schéma, ton propre produit par-dessus — alors forke le dépôt et construis-le. C'est à ça que sert la licence MIT, et c'est l'usage prévu, pas un détournement. Il n'y a aucune obligation d'attribution au-delà de garder le fichier de licence, et pas besoin de me demander. Prends les parties utiles, jette celles qui ne le sont pas, et change tout ce avec quoi tu n'es pas d'accord.

Je préfère voir une douzaine de versions différentes de tout ça plutôt qu'une seule version que moi seul aurais touchée.

Pourquoi publier la v1 au lieu de la garder pour moi

Je l'ai gardé pour moi pendant dix mois, en partant du principe que personne ne voudrait d'une v1 sur laquelle j'avais déjà arrêté de travailler. Cette hypothèse était fausse, et la raison pour laquelle elle était fausse vaut plus que le dépôt lui-même.

La deuxième version prend une autre direction. Ce que je construis aujourd'hui ne ressemble pas beaucoup à cette base de code, et ce ne sera pas un diff par rapport à elle. Garder la v1 privée aurait voulu dire qu'elle deviendrait lentement une pièce de musée que personne ne pourrait visiter.

La meilleure raison, c'est qu'une application agentique multi-tenant qui fonctionne est un artefact plus utile qu'un schéma d'architecture. Quand j'apprenais, ce qui m'aidait, ce n'était pas l'article explicatif conceptuel — c'était de trouver un vrai projet et de lire comment quelqu'un d'autre l'avait réellement agencé. Le raisonnement qui se perd dans l'explication, c'est justement celui qui compte le plus.

J'ai écrit quatre choses dans les notes d'architecture du dépôt — des choses que j'ai mal faites et que je ne referais pas — et je les ai toutes laissées. La première est un bug de sécurité.

L'erreur qui est passée en production

Une table est partie sans que la Row Level Security soit activée.

public.notification_push_deliveries a été créée sans la ligne qui active RLS, et accordée aux rôles anonyme et authentifié avec tous les privilèges — y compris TRUNCATE — sans aucune politique définie. Elle se trouve dans le schéma exposé par la Data API, et la clé anonyme est embarquée dans le bundle du navigateur. Donc pendant toute cette période, n'importe qui détenant cette clé pouvait lire la table ou la modifier. Elle contient des identifiants d'appareils pour les notifications push, ce qui en faisait un problème d'exposition de données, pas un problème théorique.

Je l'ai découvert lors d'une revue de sécurité. Pas dans un rapport de bug, et pas pendant les tests, parce que l'application fonctionnait parfaitement tout du long. Rien ne lève d'erreur quand une table n'a pas de RLS. Les requêtes passent, la fonctionnalité marche, et la permission qui ne devrait pas exister est simplement là, sans rien faire de visible jusqu'à ce que quelqu'un regarde.

C'est fermé maintenant. RLS est activée sur la table, sans aucune politique, et les rôles navigateur ont vu leurs droits révoqués : la requête qui aurait autrefois renvoyé des lignes revient désormais en erreur de permission. Si je le décris au passé, c'est exprès : c'est à quoi le code ressemblait, pas à quoi il ressemble aujourd'hui.

C'est le point sur lequel je veux être précis : ce n'était pas une décision de conception que j'ai bien prise. C'était une régression que j'ai mal gérée et que j'ai découverte ensuite.

Alors j'ai arrêté de faire confiance à ma mémoire

Ce que j'ai fait ensuite est la seule partie de tout ça que je transmettrais sans hésiter à quelqu'un d'autre.

J'ai écrit un test qui ne nomme aucune table en particulier. Il parcourt le schéma et vérifie la règle : chaque table d'un schéma exposé a la Row Level Security activée, et aucune vue matérialisée liée à un tenant n'est lisible par un rôle navigateur. Si j'ajoute une table le mois prochain et que j'oublie, le test échoue — et il échoue sur ce que j'ai oublié, pas sur une liste que j'avais écrite au moment où je m'en souvenais.

Deux réserves honnêtes, et je préfère qu'un lecteur me les reproche plutôt qu'il les découvre plus tard :

  • La partie du contrôle consacrée aux vues matérialisées couvre le schéma public et pas le schéma agency. Le côté agence a sa propre exposition que je n'ai pas fermée.
  • Le test se saute lui-même quand il ne peut pas atteindre une base de données. Un test sauté est vert, ce qui veut dire qu'un environnement cassé peut masquer un invariant qui échoue.

Si je retenais une habitude de tout ça, ce serait celle-ci : quand je trouve un bug de cette forme, j'écris le test qui l'aurait attrapé — et je l'écris contre la règle plutôt que contre l'objet. Un test qui nomme les trois objets dont je me souvenais ne protège que ces trois-là. Un test qui vérifie la règle protège celui que j'ajouterai la semaine prochaine et que j'oublierai.

Le deuxième piège

Il y a un deuxième piège que j'ai évité par chance plutôt que par conception, et c'est celui que j'entends répéter dans les conversations avec les fournisseurs.

Un filtre côté client n'est pas une frontière. Si ton application filtre les lignes par organisation dans le navigateur — .eq('org_id', ...) et compagnie — alors l'isolation s'exécute sur la machine de l'utilisateur, ce qui veut dire qu'on peut la retirer avec les outils de développement. Tout ce qui est retirable est une préférence d'affichage, pas un contrôle de sécurité. La visibilité des lignes doit être appliquée soit dans la base de données, soit derrière un endpoint serveur que l'appelant ne peut pas contourner.

Les vues matérialisées sont le parent pauvre de ce problème. Elles ne peuvent pas avoir de Row Level Security du tout. Un SELECT accordé sur une vue matérialisée renvoie les lignes de tous les tenants, et dans une migration, ça ressemble exactement au même droit accordé sur une table, où les politiques le contraindraient encore. C'est précisément pour cette raison que le dépôt révoque les droits des rôles navigateur sur tout ce qui est lié à un tenant et se retrouve matérialisé.

Celui que je n'ai pas terminé

La Row Level Security ne peut pas restreindre TRUNCATE. C'est un privilège au niveau de la table, pas au niveau de la ligne, donc les politiques ne s'y appliquent pas. Les droits de base du dépôt accordent ALL aux rôles navigateur sur un grand nombre de tables — ce qui inclut TRUNCATE — et rien ne vient réduire ça ensuite. Les droits sur les vues matérialisées sont révoqués. Ceux-là non. Il y a donc des tables où un rôle navigateur détient un privilège qui se situe en dehors de la frontière que je viens de passer cette section à décrire.

En pratique, c'est latent plutôt qu'ouvert : la Data API n'a pas de verbe TRUNCATE, les rôles navigateur ne peuvent pas se connecter directement à la base de données, et un déploiement normal n'expose pas le port de la base. C'est un problème d'hygiène. Mais c'est la même forme que le bug en haut de cet article — un privilège qui survit discrètement à sa justification — et je préfère le montrer du doigt plutôt que de laisser un lecteur le trouver et se demander si je le savais. Ça demande une révocation sur les deux schémas, et un test qui vérifie qu'aucun rôle navigateur ne détient un privilège qui contourne RLS.

Ce que je ne referais pas

Deux schémas parallèles ont dupliqué beaucoup de DDL. Les données des agences et celles des entreprises individuelles ont des structures parallèles au lieu d'une seule table avec une colonne discriminante de tenant. La séparation est réellement plus propre. La duplication a coûté plus cher en maintenance que ne l'aurait fait la colonne discriminante, et je trancherais dans l'autre sens la prochaine fois.

L'appel manuel de fonctions, c'est beaucoup de code. La boucle de l'agent gère les appels d'outils à la main plutôt que d'utiliser l'appel de fonctions automatique du fournisseur, ce qui garde le streaming et l'exécution des outils sous contrôle de l'application. Le coût, c'est que l'application doit assembler elle-même les parties de résultat de fonction, et sur les modèles actuels, ces parties doivent porter l'id de l'appel ainsi que le nom de la fonction. Oublie l'id et tu n'obtiens pas d'erreur de schéma. Tu obtiens un truc qui donne l'impression que le modèle est instable — et ça, c'est un après-midi bien pire. Si le streaming avec outils est disponible via une API de plus haut niveau aujourd'hui, ce compromis mérite d'être réexaminé.

La chaîne de migrations a atteint 321 fichiers. Une grande partie s'appelait fix_, _v2 et remove_, parce que j'ajoutais des corrections au lieu de modifier ce que j'avais déjà écrit. Le véritable état du schéma ne se laissait connaître qu'en rejouant l'historique depuis le début. Les posts que j'écrivais à l'époque étaient plus confiants sur cette période que le code ne le méritait — en novembre 2025, j'ai dit que trente-trois migrations avaient « enfin réglé » le multi-tenant, et j'ai continué à écrire des migrations correctives pendant des mois ensuite.

Le frontend fait confiance aux formes de l'API sans les valider. Il y a une validation soignée à l'entrée — le backend parse chaque requête avec Pydantic — et aucune à la sortie. Le navigateur prend la réponse et la croit. Un schéma partagé attraperait la dérive quand un champ change de forme, au lieu de la laisser se manifester sous la forme d'un écran blanc. Avec les trois précédents, ça fait les quatre choses que les notes d'architecture disent que je ferais autrement.

Le dépôt publié reconstruit la chaîne de migrations en vingt migrations en couches, regroupées par préoccupation — les tables, puis les fonctions regroupées par domaine, les vues, les index, les triggers, les politiques, les droits, les tâches planifiées, et une passe de durcissement finale. C'est à peu près vrai plutôt qu'exactement vrai, et autant être honnête sur l'endroit où ça dérape : l'une des vingt est un fourre-tout assumé pour les fonctions qui n'ont pas trouvé leur catégorie, et la couche des tables n'est pas découpée comme ses noms de fichiers le suggèrent. Le fichier nommé d'après le schéma partagé crée aussi les huit tables d'agence, et le fichier nommé d'après le schéma agence ne contient aucune définition de table. Le découpage en couches est réel ; les étiquettes, elles, ne sont pas parfaites.

Ce que le découpage réussit, en revanche, c'est la partie qui compte pour le lire : le schéma est une pure réorganisation, vérifiée en le vidant avant et après et en confirmant que les deux sont identiques au bit près, à part le jeton aléatoire de l'outil de dump.

Cette vérification est la seule raison pour laquelle j'ai accepté d'y toucher.

Ce qui a survécu

La Row Level Security comme véritable frontière, plutôt qu'une fonctionnalité à activer. Une fois que la conception est organisée autour d'elle, l'isolation cesse d'être un point sur une checklist pour devenir une propriété du système.

Les écritures passent par des fonctions de base de données routées. Le code applicatif ne choisit pas quel schéma toucher. Il appelle une fonction qui inspecte le type d'organisation et dispatche : le choix vit à un seul endroit, là où un nouveau chemin de code ne peut pas oublier de le faire.

L'inscription ignore ce que le client envoie. Le trigger de provisioning ne respecte pas un id d'organisation ni un rôle fournis par le client, parce que ces données sont contrôlées par l'utilisateur. Il crée une organisation neuve et un rôle propriétaire par défaut. Petit détail, facile à rater, coûteux quand on le rate.

La construction paresseuse des clients. Les services construisent leur client fournisseur au premier usage plutôt qu'à l'import. Ça a l'air d'une préférence de style, et ça n'en est pas une : plusieurs services sont créés à l'import du module, donc une construction immédiate voulait dire que l'import de l'application exigeait une clé API, et la panne se manifestait par une erreur opaque du SDK du fournisseur avant même que quoi que ce soit puisse démarrer. C'est le fait de rendre ça paresseux qui rend le mode démo possible, et c'est ce qui permet à un inconnu d'évaluer le projet sans rien dépenser.

Cette dernière est la décision dont je suis le plus content, et je ne l'ai pas prise pour la raison qui a fini par compter. Je l'ai prise pour arrêter le crash.

Si tu ne liras jamais le code

C'est la section que j'aurais voulu avoir quand j'étais du côté agence, à acheter ce genre de logiciel — donc il n'y a pas de code dedans.

Ce qu'il faut retenir, c'est la différence entre « on filtre par compte » et « la base de données ne peut pas renvoyer les lignes d'un autre compte ».

J'ai acheté des plateformes pendant la majeure partie de ma carrière et j'en ai construit ces dernières années, et cette distinction, je ne l'avais pas comprise avant d'en avoir livré la mauvaise version. L'une est une règle que le navigateur suit. L'autre est une règle que le navigateur ne peut pas casser. La plupart des outils ont la première et la décrivent avec le vocabulaire de la seconde.

Quand tu évalues une plateforme qui va contenir les données de plusieurs clients — intelligence concurrentielle, données de performance, définitions d'audience, peu importe — la question à poser n'est pas « est-ce que c'est sécurisé ». Tout le monde répond oui à ça. La question, c'est : où la séparation est-elle appliquée, et que se passe-t-il si un développeur retire le filtre ?

Il y a deux bonnes réponses. Soit c'est appliqué dans la base de données par des politiques liées à l'identité de l'utilisateur connecté, soit c'est appliqué derrière un endpoint serveur que le navigateur ne peut pas contourner. Toute réponse qui implique le navigateur est un non.

Les bonnes réponses ressemblent à : la politique est sur la table et rattachée à la session de l'utilisateur ; le navigateur n'interroge jamais cette table directement ; voici le test qui le prouve. Les réponses moins utiles ressemblent à : notre application filtre par compte ; les données sont chiffrées ; nous sommes certifiés SOC 2. Tout cela peut être vrai sans qu'aucune de ces réponses ne réponde à la question.

C'est une ligne, et elle a sa place dans une revue de sécurité fournisseur — c'est là que je la mettrais. La réponse te dit si le multi-tenant a été conçu dès le départ ou rajouté après coup.

Questions fréquentes

Pourquoi le publier au lieu de le laisser dormir dans un dépôt privé ?

Parce qu'un artefact public peut être vérifié, et un artefact privé non. Les posts que j'ai écrits plus tôt sur la construction de tout ça avançaient des affirmations ; un dépôt avec les migrations, les politiques et les tests est quelque chose qu'un lecteur peut vérifier, y compris les parties que j'ai ratées. Les notes d'architecture ont une section intitulée « Compromis, et ce que je ferais différemment », et c'est la raison pour laquelle le dépôt existe sous cette forme.

La deuxième version sera-t-elle open source ?

Non. Elle est développée en privé, et la base de code ne devrait pas ressembler à celle-ci. Je préfère le dire clairement plutôt que de laisser le flou et que des gens clonent ce dépôt en s'attendant à une feuille de route.

Est-ce que je peux l'utiliser en production ?

Non, je ne le ferais pas. C'est une référence, pas un produit. Les identifiants de démo dans les données de seed sont destinés à un usage local uniquement, il n'y a aucun engagement de support, et personne ne le corrige face aux futures évolutions des dépendances. C'est une bonne chose à lire et dont on peut s'inspirer, et une mauvaise chose sur laquelle faire tourner une entreprise.

Faut-il une clé IA pour l'essayer ?

Non. Il démarre en mode démo, où les agents renvoient des réponses préenregistrées clairement étiquetées au lieu d'appeler un modèle. Tu peux parcourir toute l'application — chaque agent, les parcours clients côté agence, le sélecteur de langue — sans clé et sans dépenser quoi que ce soit. Passer aux vrais appels de modèle, c'est un réglage et une clé. Le premier lancement m'a pris plus de dix minutes : il faut Docker en marche, Node, Python, Poetry et la CLI Supabase, et le passage lent, c'est un gros téléchargement.


J'ai beaucoup écrit sur la construction de tout ça pendant que je la construisais, et les deux posts par lesquels je commencerais sont pourquoi j'ai construit le multi-tenant au jour deux et ce qui s'est passé quand je l'ai reconstruit au jour soixante-sept. Si un seul des deux vaut ton temps, c'est le second — c'est celui où l'architecture s'est révélée mauvaise.

Le code est sur github.com/chandlernguyen/stratum-oss, et le test qui garde l'erreur que j'ai livrée se trouve dans tests/automated/test_rls_coverage.py.

Si tu as livré un système multi-tenant et trouvé un troisième piège silencieux que je n'ai pas mentionné, ça m'intéresserait vraiment d'en entendre parler — c'est ceux-là qui valent la peine d'être collectionnés.

Retours, suggestions, et ce qui arrive à la v1

Je préfère que ça ne soit pas une diffusion à sens unique, alors voici la version honnête de ce à quoi t'attendre.

Ce que j'accueillerais volontiers : les rapports de bug si quelque chose dans le dépôt est tout simplement faux. Les suggestions sur les parties qui pourraient être plus claires, ou plus simples, ou écrites avec moins de code. Les retours de quiconque a essayé de le faire tourner et est tombé sur un point que le README ne couvre pas. Les pull requests, si tu trouves un vrai problème et que tu veux le corriger. Et si tu le forkes et construis quelque chose à toi, j'aimerais savoir ce que tu as changé et pourquoi — c'est le retour le plus intéressant de tous, parce que toi, tu as dû prendre les décisions pour de vrai.

Ce que je peux promettre : pas grand-chose, et je préfère le dire plutôt que de laisser croire autre chose. Ce n'est pas un projet activement développé, je ne tiens pas un service d'assistance, et je ne peux m'engager sur aucun délai de réponse. Certaines suggestions, je les appliquerai. D'autres, je les lirai, je serai d'accord, et je n'aurai jamais le temps. C'est la version réaliste d'un projet parallèle qui a déjà un successeur.

Ce qui arrive à la v1 : elle reste publiée telle quelle. Je ne la développe pas davantage, donc ne compte pas sur de nouvelles versions. Mais je ne vais pas prétendre qu'elle est scellée — le code est public, la licence te permet de l'emmener dans n'importe quelle direction, et si quelque chose est cassé ou vraiment peu clair, je n'ai aucune bonne raison de le laisser ainsi par principe.

Le plus simple pour me joindre, c'est une issue sur le dépôt, ou un email si tu préfères ne pas être public.

Voilà, c'est tout pour moi pour le moment.

À bientôt, Chandler