Une liste d'exclusion est facile à promettre. « Dites-nous d'arrêter et nous ne vous contacterons plus jamais » tient en une phrase sur une page de politique et en une semaine d'ingénierie environ, dont l'essentiel se loge à des endroits que vous ne devineriez pas. Le schéma prend un après-midi. La difficulté est qu'une liste d'exclusion doit valoir pour tous les comptes, doit atteindre en arrière le travail déjà engagé, et doit pouvoir prouver après coup ce qu'elle a arrêté.
01Le schéma, et l'index qui fait le travail
L'exclusion est indexée sur le domaine enregistrable. Pas le nom d'hôte, pas l'adresse email individuelle. Quelqu'un qui nous demande d'arrêter sur acme.com n'a pas accepté d'avoir de nos nouvelles sur www.acme.com ni sur careers.acme.com. Nous normalisons via la liste des suffixes publics, mettons en minuscules, retirons un www en tête et convertissons les noms internationalisés en punycode avant de stocker, parce que deux orthographes d'une même entreprise dans la table équivalent en pratique à aucune entrée du tout.
- domain : le domaine enregistrable normalisé, et la seule colonne jamais comparée.
- tenant_id : le compte client auquel l'exclusion appartient, avec une valeur sentinelle pour l'échelle de la plateforme.
- reason : texte libre, obligatoire, sans valeur par défaut. Si personne ne sait dire pourquoi, cela n'entre pas.
- source : comment elle est arrivée. Une réponse, une demande via un formulaire, une action d'opérateur, un import en masse, une demande juridique. Ces cas se comportent différemment ensuite, et un simple booléen jette tout cela.
- evidence : un lien vers le message ou le ticket qui l'a provoquée.
- created_by et created_at : qui, et quand.
L'index unique sur le compte client et le domaine est ce qui rend le réajout sans danger. Les écritures passent par une insertion qui met à jour le motif, la source et l'horodatage en cas de clé dupliquée plutôt que d'échouer, si bien qu'un opérateur peut coller deux fois la même liste de quarante domaines et obtenir quarante lignes et aucune erreur. Le réajout idempotent compte plus qu'il n'y paraît. L'alternative est que les gens lisent avant d'écrire, et lire puis écrire est exactement la course que l'index existe pour supprimer.
02Contrôler à chaque étape, pas à l'entrée
Le pipeline est une séquence avec du temps réel entre les étapes. Une entreprise lue arrive d'une source, franchit les étapes et devient qualifiée, fait l'objet de recherches, reçoit un brouillon, attend un humain, et seulement alors un envoi est effectué. Des jours passent entre la première étape et la dernière. Une entreprise peut entrer en exclusion à n'importe quel moment de cette fenêtre.
Ce qui rend un contrôle d'exclusion à la découverte presque sans valeur pris isolément. Si l'entrée est le seul contrôle, une entreprise qui s'oppose le mardi a toujours un brouillon du lundi dans la file de validation, et ce brouillon partira. La liste était correcte, l'entrée était là, et le message est parti quand même. L'échec n'est pas dans la donnée.
- À l'entrée, pour qu'un domaine exclu ne devienne jamais une entreprise lue.
- Avant la recherche, parce que la recherche dépense un budget et qu'il est absurde de le dépenser sur une entreprise que l'on ne peut pas contacter.
- Avant la rédaction, pour la même raison et avec un coût plus élevé attaché.
- Au moment où la file de validation s'affiche, montré comme bloqué plutôt que caché, pour que la personne voie que quelque chose a été arrêté au lieu de se demander où c'est passé.
- Immédiatement avant la délivrance, comme dernière instruction exécutée avant que la requête ne parte.
Seul le dernier de ces contrôles porte la correction. Les quatre autres épargnent du travail et empêchent une personne de dépenser son attention à valider quelque chose qui ne pourra jamais partir. Le contrôle final doit lire la base plutôt qu'un cache de passage, parce qu'un cache est périmé exactement de l'intervalle dans lequel le bug vit. C'est une recherche indexée sur une table de quelques milliers de lignes, et cela ne vaut pas la peine d'être optimisé.
03Deux exécutants, une entreprise
Le cas de concurrence survit à la relecture parce qu'il est difficile à se représenter. Deux exécutants prennent la même entreprise : un bail expiré sans que le premier s'en aperçoive, ou une même activité arrivant de deux sources de découverte sous des identifiants différents qui se normalisent vers le même domaine. Les deux contrôlent l'exclusion. Les deux passent. Les deux écrivent un brouillon. La file de validation contient maintenant deux messages pour une entreprise, et en refuser un ne fait rien à l'autre.
Une transaction plus longue ne corrige pas cela, et contrôler deux fois non plus. Une contrainte d'unicité, si. La ligne de brouillon porte un index unique sur le compte client, le domaine et la campagne, si bien que la seconde insertion perd dans la base plutôt que dans la logique applicative, et l'exécutant perdant traite une clé dupliquée comme une issue ordinaire au lieu d'une erreur. Le bail porte un claimed_at et un identifiant d'exécutant, si bien qu'une tâche coincée est visible plutôt que simplement en retard.
La délivrance utilise la même idée par l'autre bout. L'étape d'envoi fait passer le brouillon de validé à en cours d'envoi avec l'état courant nommé dans la clause WHERE, et ne poursuit que si cette mise à jour a touché exactement une ligne. Si un événement d'exclusion a fait basculer la ligne en arrêtée une seconde plus tôt, la mise à jour ne correspond à rien, l'envoi n'a pas lieu, et aucun code applicatif n'a eu à raisonner sur l'ordre. Ce compare-and-set est ce qui rend l'exclusion insensible aux courses plutôt que correcte la plupart du temps.
04L'exclusion doit atteindre en arrière
Marquer un domaine comme exclu est un événement, pas un filtre. Quand elle arrive, l'exclusion annule les recherches en file pour ce domaine, fait passer chaque brouillon de ce domaine en arrêté avec l'identifiant d'exclusion consigné comme motif, et retire ces lignes de la file de validation. Le travail déjà fait est tout le problème. Un filtre appliqué aux seules lectures futures laisse les lectures terminées intactes, et les terminées sont les seules assez proches pour partir.
Les lignes arrêtées restent ensuite. Nous ne les supprimons pas, et c'est la partie qui se discute, parce que supprimer paraît être l'option respectueuse. Cela détruit la seule preuve que quelque chose a été arrêté. Quand une entreprise écrit pour demander si nous l'avons contactée, une réponse honnête est précise : un brouillon a été écrit le neuf, il a été arrêté le onze quand le domaine est entré en exclusion, il n'a jamais été délivré, et en voici le texte. Cette réponse a besoin que la ligne existe.
La règle tient donc dans les deux sens : nous ne supprimons jamais un enregistrement de refus. Chaque lecture refusée, chaque brouillon arrêté et chaque entrée d'exclusion est conservée, et le champ de motif est obligatoire sur les trois. Un filtre que vous ne pouvez pas auditer n'est qu'une liste, et une liste est ce que tout le monde prétend déjà avoir.
05Une liste de clients n'est pas une liste d'exclusion
La dernière erreur ressemble à du rangement. Un compte client détient déjà une liste de ses clients existants et ne veut pas qu'ils soient prospectés, alors quelqu'un charge cette liste dans la table d'exclusion. Cela fonctionne une semaine, puis l'information a disparu.
Ce sont des faits différents à propos d'une entreprise. Exclue signifie qu'une personne nous a demandé d'arrêter. Cela prime sur tout, cela n'expire pas, et cela s'applique quel que soit le message. Déjà cliente signifie ne pas traiter cette entreprise comme un nouveau prospect, ce qui n'est pas la même instruction : l'équipe de compte peut très bien vouloir une conversation d'extension, et si c'est le cas, la table d'exclusion refuse désormais au nom de quelqu'un qui n'a jamais demandé cela. Une exclusion de concurrent est une troisième chose. Une mise en attente demandée par les ventes pendant qu'une affaire est en cours est une quatrième, et contrairement aux autres elle a une date de fin.
Tenues à part, la question « pourquoi cette entreprise n'a-t-elle jamais été contactée » a une réponse en un mot qu'une requête sait produire. Fondues dans une seule table derrière un motif en texte libre, la réponse exige que quelqu'un lise de la prose et déduise, et en un mois la colonne de motif collectionne des valeurs comme « client, ne pas retirer ». Des tables séparées, des sémantiques séparées, et un contrôle partagé au point de délivrance qui les consulte toutes.
La délivrance, pour l'instant, signifie le formulaire de contact public de la cible. L'envoi d'emails est prévu pour le quatrième trimestre 2026 et n'est pas construit, ce qui simplifie discrètement tout ce qui précède : il y a un seul canal à exclure. Quand il y en aura deux, chaque contrôle décrit ici tournera sur les deux, et l'atteinte en arrière est la partie qui demandera le plus de soin.