Aller au contenu

Ré-dérivabilité du mart et de l'index : propagation d'une opposition RGPD

En une phrase. Quand une personne s’oppose à ce que ses données soient traitées, comment faire pour qu’elle disparaisse vraiment de tous les jeux de données que nous produisons à partir d’elle — alors même que ces jeux de données sont, par conception, impossibles à modifier après coup ?

Glose du titre. « Ré-dérivabilité du mart et de l’index » se lit : la capacité à re-fabriquer (ré-dériver) deux jeux de données — le mart et l’index — à partir de la donnée brute, plutôt que de les modifier sur place. Ces deux mots sont définis ci-dessous ; retenez pour l’instant qu’ils désignent les données « finales » exploitées par l’application, et qu’on s’interdit de les retoucher directement.

  • Mart : une table de données « prête à l’emploi », pré-calculée à partir de données plus brutes pour répondre vite à un usage précis (ici : décrire des collaborations entre chercheurs). Terme de l’entrepôt de données (data warehouse) : un data mart est une vue métier dérivée, pas la source de vérité.
  • Index : ici, une base de données spécialisée dans la recherche (recherche par mots-clés et recherche « sémantique » par proximité de sens), construite à partir du mart. C’est ce qui permet à l’application de retrouver rapidement des chercheurs.

Le problème que cette page résout. Le mart et l’index sont stockés en partitions immuables : une fois écrites, elles ne sont jamais modifiées ni effacées (c’est un choix d’architecture qui garantit la traçabilité — voir ADR 0029). Or le RGPD donne à toute personne un droit d’opposition (article 21 : demander l’arrêt du traitement de ses données). Ces deux exigences semblent contradictoires : comment retirer quelqu’un de données qu’on s’interdit de modifier ? La réponse est la ré-dérivabilité : on ne corrige pas l’ancien, on re-fabrique du neuf sans la personne, et on masque l’ancien à la lecture. Cette page spécifie précisément ce mécanisme.

Repères de vocabulaire utilisés partout dans la page :

  • RGPD — Règlement général sur la protection des données (règlement européen 2016/679) ; art. 21 = droit d’opposition.
  • Partition — un « bloc » de données daté et figé (p. ex. les collaborations d’un mois). Immuable = jamais réécrit après coup.
  • Registre d’opposition — la liste, faisant autorité, des personnes ayant demandé à ne plus être traitées.
  • SLA (Service Level Agreement) — engagement de délai : ici, le temps maximal entre une opposition et sa prise d’effet réelle.

Ce que vous trouverez ici. La page suit le chemin d’une opposition à travers le système, de son enregistrement jusqu’à son effet sur chaque jeu de données :

  1. Vocabulaire et invariant central (§0) — les règles du jeu et le principe « on régénère, on ne mute jamais ».
  2. Le registre d’opposition (§1) — où l’on note qu’une personne s’est opposée, et la dette technique qui empêche aujourd’hui d’en tirer parti.
  3. Régénération du mart (§2) — re-fabriquer la donnée courante sans la personne opposée.
  4. Masquage à la lecture (§3) — cacher les personnes opposées dans les données historiques qu’on ne peut pas réécrire.
  5. Purge de l’index (§4) — supprimer les personnes opposées du moteur de recherche.
  6. SLA de propagation (§5) — dans quel délai tout cela prend effet.
  7. Conséquences, contrôles et gate (§6) — les garde-fous et ce qu’il reste à construire avant tout usage sur données réelles.

Place dans le projet. Étape 0.2 du plan pipeline-collaborations. Cette page spécifie comment une opposition RGPD (art. 21) se propage au mart Parquet (format de fichier en colonnes, voir §0) et à l’index pgvector (extension de recherche vectorielle de PostgreSQL), sans jamais réécrire une partition de production en place. Elle opérationnalise deux décisions d’architecture :


Avant tout détail, cette section pose les deux fondations sur lesquelles repose le reste de la page : (1) quel droit est exactement en jeu — un droit d’opposition, qui n’est pas la même chose qu’un retrait de consentement, et la conséquence majeure que cette distinction entraîne ; (2) l’invariant — la règle absolue « on régénère, on ne modifie jamais une partition existante » — qui dicte toute la mécanique des sections suivantes. Si vous ne deviez retenir qu’une chose de la page, c’est l’invariant ci-dessous.

0.1 Quel droit est en jeu : opposition, pas consentement

Section intitulée « 0.1 Quel droit est en jeu : opposition, pas consentement »

Il s’agit d’un droit d’opposition au titre de l’article 21 du RGPD, pas d’un retrait de consentement. La distinction est lourde de conséquences. La base légale (le fondement juridique qui autorise un traitement de données) est ici l’intérêt public (art. 6.1.e) et/ou l’intérêt légitime (art. 6.1.f) (ADR 0030) — jamais le consentement (art. 6.1.a).

Conséquence directe : le modèle est opt-out et non opt-in.

  • Opt-in : on ne traite une personne que si elle a dit oui (consentement préalable). C’était le fonctionnement de l’application web d’origine.
  • Opt-out : on traite toute personne du périmètre par défaut, sauf celles qui ont explicitement dit non (exercé leur opposition).

Cette inversion de sémantique (passer de « traiter si oui » à « traiter sauf si non ») est le seul renversement à opérer côté consommateur des données (voir §1.3).

0.2 L’invariant : on régénère, on ne mute jamais

Section intitulée « 0.2 L’invariant : on régénère, on ne mute jamais »

C’est la règle qui structure toute la suite. Muter signifie ici modifier des données déjà écrites (en corriger ou en supprimer une ligne sur place). Cette opération est interdite sur les partitions de production. Toute opposition est donc absorbée par re-fabrication, pas par retouche.

Invariant préservé sur tout ce document (ADR 0029, Invariants) :

Les partitions de production sont strictement immuables. Un rejeu (une ré-exécution du calcul) écrit une nouvelle partition dt=YYYY-MM/run=<id>/ — où dt est le mois et run l’identifiant de l’exécution — jamais de réécriture en place. Une opposition est honorée par RÉGÉNÉRATION (nouvelle partition courante filtrée) + MASQUAGE (à la lecture, pour l’historique figé) + PURGE/RECHARGE de l’index dérivé — jamais par mutation d’une partition existante.

Ces trois mécanismes (régénération, masquage, purge/recharge) sont précisément l’objet des sections §2, §3 et §4. L’immuabilité reste un invariant de traçabilité (pouvoir prouver ce qui a été produit et quand), pas un droit de conservation indéfinie (ADR 0030) : on garde l’historique pour l’auditer, on ne s’en sert jamais pour continuer à servir une personne opposée.

0.3 Quatre objets, quatre rôles à ne jamais confondre

Section intitulée « 0.3 Quatre objets, quatre rôles à ne jamais confondre »

Le tableau ci-dessous distingue les quatre objets manipulés dans la page. Le piège à éviter : croire que le mart ou l’index « font foi ». La seule autorité sur qui est exclu est le registre d’opposition ; la seule autorité sur ce qui a été transféré est le manifest.json (le « bon de livraison » qui certifie le contenu d’une partition, voir §2). Le mart et l’index ne sont, eux, que des dérivés régénérables. (s3://… désigne un emplacement de stockage objet de type S3 ; sha256 une empreinte cryptographique servant à vérifier l’intégrité d’un fichier ; row_count le nombre de lignes attendu.)

ObjetRôleAutorité ?Mutabilité
Registre d’oppositionSource de vérité du périmètre servi (qui est exclu)Autorité du périmètre, pas du contratAppend-only + projection d’état
Mart Parquet (s3://citation/marts/collab/dt=.../run=.../)Table de fait dérivée, régénérableNon autorité du contratPartitions immuables ; on régénère un nouveau run
manifest.jsonContrat de transfert (validé par sha256 + row_count + schema_version)Seule autorité du contratÉcrit en dernier, atomique, par run
Index pgvectorExploration / recherche, dérivé du martJamais source de véritéPurgeable / rechargeable

Le registre d’opposition est la liste, faisant autorité, des personnes qui se sont opposées. C’est le point de départ de toute la chaîne : tant qu’on ne sait pas de façon fiable « qui est opposé », on ne peut filtrer ni le mart, ni l’index, ni les lectures. Cette section explique (1) qu’on réutilise un dispositif existant — le journal de consentement de l’application web — en le réinterprétant en registre d’opposition ; (2) comment on en tire la liste d’exclusion consommée par le reste du pipeline ; puis (3) elle expose une dette technique bloquante : aujourd’hui ce registre identifie les personnes par leur compte applicatif, alors que le mart les identifie par leur identité de chercheur — et aucune correspondance entre les deux n’existe (§1.4). C’est le nœud du chantier.

Section intitulée « 1.1 Réutilisation du dispositif consent-events réinterprété »

On part de l’existant. L’application web (une PWA, Progressive Web App : application web installable comme une app native) gère déjà un journal de consentement. On montre ici qu’il fournit exactement le bon patron — un journal immuable doublé d’une projection de l’état courant — pour servir de registre d’opposition auditable, à condition de le réinterpréter (§1.3). (Append-only = on ne fait qu’ajouter des entrées, jamais en modifier ou en supprimer ; upsert = « insérer ou mettre à jour » selon que la ligne existe déjà.)

Le dispositif d’événements horodatés de la PWA (find-an-expert) sert de registre d’opposition une fois réinterprété en opt-out. Le patron technique est directement transposable :

  • consent-events (env APPWRITE_CONSENT_EVENTS_COLLECTION_ID) — journal d’audit immuable, append-only (uniquement create + list). Attributs réels : userId (string), consentType (string), action ('grant'|'revoke'), horodatage = champ système Appwrite $createdAt.
  • current-consents (env APPWRITE_CURRENT_CONSENTS_COLLECTION_ID) — projection d’état courant, 1 ligne par couple (userId, consentType), attribut granted (boolean), dernière modif = $updatedAt. L’unicité du couple est garantie uniquement par la logique applicative read-then-write de upsert (repository.ts:131), pas par un index/contrainte Appwrite confirmable depuis le repo.

Ce double dispositif log immuable + projection est exactement le bon patron pour un registre d’opposition auditable : trace horodatée de chaque action + état courant requêtable par personne.

La liste d’exclusion est l’ensemble des personnes à retirer du périmètre servi à un instant donné. C’est l’objet concret que consomment le pipeline (pour régénérer le mart) et le service de lecture (pour masquer). Cette sous-section définit comment on la calcule : on la dérive de l’état courant du registre (la projection current-consents, qui dit « voici l’état présent de chacun »), pas du journal historique :

est_opposé(personne) ⇔ il existe, dans current-consents, une ligne pour cette personne dont l'état d'opposition est actif.

En réutilisant le dispositif existant tel quel, cela correspond à un enregistrement courant avec l’action d’opposition active (voir §1.3 pour la sémantique recommandée). L’absence d’enregistrement = personne traitée par défaut (opt-out). La liste d’exclusion à l’instant T est donc :

exclusion_set(T) = { clé(personne) | est_opposé(personne) à l'instant T }

Le log consent-events fournit l’historique horodaté de chaque inscription / retrait d’opposition (preuve d’audit : qui, quand, quel sens). Il est écrit à chaque action avant la mise à jour de l’état courant.

Réutiliser le dispositif existant suppose d’inverser son interprétation (§0.1). Cette sous-section précise comment, et pourquoi le code le permet sans réécriture lourde : il stocke un simple oui/non, c’est l’usage qu’on en fait qui change.

Le code stocke seulement un booléen ; il n’impose pas l’interprétation. Le renversement se fait côté consommateur : « traiter SI pas d’opposition » au lieu de « traiter SI granted=true ». Recommandation (la plus propre, à implémenter — voir §1.5) : renommer le ConsentType / les valeurs en sémantique d’opposition neutre (l’actuel 'openalex_email' porte un nom de marque tierce et une sémantique opt-in) et définir explicitement « est opposé(e) » = présence d’un enregistrement courant d’opposition active. Ne pas réutiliser granted=false/absence comme « opposé » (ambigu et confus).

1.4 Clé d’identification réelle — point bloquant

Section intitulée « 1.4 Clé d’identification réelle — point bloquant »

Voici le point dur annoncé en introduction de la section. Une opposition n’a de valeur que si elle peut désigner sans ambiguïté la personne à retirer du mart et de l’index. Or les deux mondes n’utilisent pas la même clé d’identification : le registre identifie un titulaire de compte applicatif, le mart identifie un chercheur du référentiel bibliométrique (la base documentaire des publications et de leurs auteurs). La clé du mart, elle, n’est plus une inconnue : ADR 0059 l’a tranchée — c’est l’author_id (l’identifiant d’auteur du référentiel, instanciation concrète du researcherId de la présente spécification), sur lequel le mart researchers et l’index sont ancrés (cf. aussi ADR 0058 qui pose le schéma de l’index). Ce qui reste à construire n’est donc pas la clé du mart, mais (1) une table de correspondance entre l’identité de compte et cette clé chercheur, et (2) un attribut de clé chercheur sur l’enregistrement d’opposition (l’enregistrement Appwrite ne porte aujourd’hui que userId). Tant que ces deux pièces manquent, l’opposition exprimée dans l’application ne peut pas être projetée sur le mart : c’est, dit le texte, « la dette la plus structurante de l’étape 0 ».

État réel du code : la seule clé d’identification est userId = $id du compte Appwrite (Appwrite Account). Il n’existe aucune autre clé matérialisée dans le chemin consentement : ni email, ni ORCID, ni identifiant d’auteur du référentiel bibliométrique (vérifié : aucune occurrence d’ORCID dans apps/find-an-expert/src, aucun identifiant d’auteur externe dans le chemin consentement ; le profil utilisateur ne contient que {id, email, labels}).

Problème : le mart et l’index sont clés sur l’entité chercheur — concrètement l’author_id du référentiel (ADR 0059 ; les embeddings sont produits par chercheur, mean-pooling L2 des œuvres — pas par publication). Un registre d’opposition doit pouvoir viser une personne réelle même sans compte PWA (un chercheur du référentiel qui n’a jamais créé de compte). Or aucune table de correspondance compte Appwrite ↔ author_id n’existe dans le code, et l’enregistrement d’opposition ne porte aucune clé chercheur. Le côté mart est résolu (la clé existe) ; le côté opposition ne l’est pas (la clé n’y est pas projetable).

À ajouter (n’existe PAS aujourd’hui) — la liste d’exclusion ne peut filtrer le mart/index que si elle est exprimée dans la clé du mart (author_id) :

  1. Une table de correspondance compte ↔ chercheur (compte Appwrite → author_id, le cas échéant via ORCID — Open Researcher and Contributor ID, identifiant pérenne international d’un chercheur — ou email normalisé). À matérialiser ; inexistante.
  2. Un attribut de clé chercheur (author_id) sur l’enregistrement d’opposition (le schéma actuel ne porte que userId). À ajouter aux collections Appwrite (configurées à la main, pas de schema-as-code dans le repo).

La liste d’exclusion utile au pipeline est { author_id } (la clé du mart, tranchée par ADR 0059), pas { userId }. La clé existe côté mart ; ce qui manque est de rattacher une opposition exprimée via la PWA à cet author_id. Tant que la correspondance compte ↔ chercheur et l’attribut sur l’enregistrement d’opposition ne sont pas matérialisés, l’opposition ne peut pas être projetée sur le mart. C’est la dette la plus structurante de l’étape 0 (voir Gate, §6). Cohérent avec l’invariant capacité ≠ décision : le dépôt fournit le producteur clé sur author_id ; brancher l’opposition sur cette clé relève du déployeur.

1.5 Manques à combler avant usage en registre d’opposition

Section intitulée « 1.5 Manques à combler avant usage en registre d’opposition »

Cette sous-section récapitule en une liste tout ce qui doit être construit avant que le dispositif existant puisse servir de registre d’opposition réglementaire. Aucun de ces éléments n’existe aujourd’hui : ce sont tous des « à ajouter ». (Un DPO, Data Protection Officer, est le délégué à la protection des données ; un endpoint est un point d’accès d’une API, c.-à-d. une URL appelable par un programme.)

  • Correspondance compte ↔ chercheur + attribut author_id sur l’opposition (§1.4) — bloquant (la clé du mart, author_id, existe déjà — ADR 0059 ; c’est le rattachement de l’opposition à cette clé qui manque).
  • Renversement opt-out explicite (sémantique du défaut) — §1.3.
  • Endpoint d’administration / DPO : tout est scopé locals.userId (utilisateur courant). Aucun moyen pour un DPO d’inscrire une opposition au nom d’un tiers (chercheur sans compte), de lister les opposés, ou de requêter par ORCID/email/researcherId. Routes admin et requêtes non-userId à ajouter.
  • Lecture de l’historique : getByUserId existe dans le repository mais n’est exposé ni par le service ni par l’API — l’historique est écrit, jamais relu par l’app. Endpoint de consultation à ajouter pour l’auditabilité.
  • Traçabilité réglementaire : aucun champ reason, source, expiresAt, preuve. À ajouter si le DPO l’exige.
  • Garantie d’unicité : l’unicité (userId, consentType) repose sur le read-then-write applicatif → risque de doublon en concurrence. Un index unique côté Appwrite serait à confirmer/ajouter.
  • Incohérence existante GET /api/v1/consents : getAllConsents renvoie un Map JS sérialisé en {}. À corriger si on s’appuie dessus pour lister les états.

2. Régénération de la partition courante du mart (b)

Section intitulée « 2. Régénération de la partition courante du mart (b) »

Premier des trois mécanismes de l’invariant (§0.2) : la régénération. Puisqu’on ne peut pas retirer une personne d’une partition existante, on re-fabrique la partition « courante » (celle qui est servie aujourd’hui) en laissant la personne opposée de côté dès le calcul. Cette section répond à trois questions : dans la chaîne de traitement on applique le filtre (§2.1) ; comment se déroule la régénération, étape par étape, sans jamais écrire en place (§2.2) ; et comment on signale que l’ancienne partition est désormais périmée (§2.3).

Le pipeline de données passe par des couches successives, du plus brut au plus raffiné : raw (données ingérées telles quelles), curated (nettoyées et normalisées), puis marts (tables métier prêtes à l’emploi — voir « mart » en tête de page). La transformation curated → marts est réalisée avec dbt (data build tool, outil de transformation de données en SQL versionné).

C’est le point d’application unique du filtre d’opposition dans toute la chaîne d’ingestion. Le situer correctement évite deux confusions courantes : filtrer trop tôt (on perdrait la donnée brute, qu’on a le droit de conserver) ou confondre ce filtrage réglementaire avec le simple filtrage d’affichage.

Le filtre d’opposition s’applique entre curated et marts (ADR 0030, ADR 0029, §flux) : la régénération de la partition courante servie se fait DEPUIS curated FILTRÉ sur le registre d’opposition à jour à l’instant T. C’est le seul point de filtrage du périmètre dans la chaîne d’ingestion/profilage.

À distinguer : la déclaration des alliances par l’utilisateur filtre l’affichage (PWA / atlas-api), pas l’ingestion/profilage (on profile plus de personnes qu’on n’en affiche). L’opposition (art. 21), elle, retire la personne du mart courant et de l’index servis — via le filtre appliqué entre curated et marts. La personne reste présente dans raw (qui ingère tout le périmètre) : ce n’est pas l’ingestion brute qui est filtrée, mais la dérivation curated → marts.

2.2 Procédure de régénération (jamais en place)

Section intitulée « 2.2 Procédure de régénération (jamais en place) »

Voici la procédure pas à pas. L’enchaînement importe : on charge la liste d’exclusion, on re-dérive le mart sans les personnes opposées, on écrit un nouveau run (jamais l’ancien), on scelle le tout par un manifest.json atomique, et on désigne ce nouveau run comme courant. Le manifest.json écrit en dernier joue le rôle de « bon de livraison » : tant qu’il n’est pas là, le run n’est pas considéré comme valide.

À l’instant T (déclenchement par une nouvelle opposition ou par le schedule mensuel — l’exécution planifiée récurrente) :

  1. Charger exclusion_set(T) depuis le registre d’opposition (état courant, §1.2), exprimé en clé chercheur (researcherId — voir dépendance §1.4).
  2. Re-dériver les modèles marts (collab) depuis curated, en excluant toute paire de chercheurs dont l’une des deux entités appartient à exclusion_set(T) (le filtre s’applique à l’entité chercheur, donc à toute paire la mettant en jeu, puisque la table de fait est paires de chercheurs + features).
  3. Écrire un NOUVEAU run : s3://citation/marts/collab/dt=YYYY-MM/run=<id'>/ avec un nouvel identifiant de run. Aucune écriture dans le run=<id> précédent.
  4. Écrire le manifest.json en dernier, atomiquement, conforme au contrat ADR 0029 :
    {
    "partition": "dt=YYYY-MM/run=<id'>",
    "schema_version": 1,
    "row_count": N,
    "parts": [{ "key": "...", "sha256": "...", "bytes": M }],
    "produced_at": ""
    }
  5. Désigner la partition courante : le consommateur (atlas-api, index_load) doit lire le run <id'> et considérer run=<id> comme obsolète. La sélection de la partition courante se fait via le manifest — selon la convention ou le champ d’obsolescence restant à acter en §2.3 (le contrat actuel ne porte pas encore de sélecteur de « run courant » ; ne pas le supposer existant).

manifest.json = seule autorité du contrat. Le consommateur valide row_count + sha256 avant de lire et refuse une schema_version inconnue (ADR 0029, Invariant 1). Le mart régénéré n’est pas « cru » sur la base de son existence sur S3 : il l’est sur la base de son manifest validé.

Une fois le nouveau run écrit, encore faut-il que le consommateur sache lequel servir. Comment marquer l’ancien run comme « périmé » sans le modifier (ce qui violerait l’immuabilité) ? Cette sous-section présente les deux options possibles, encore à arbitrer : ne supposez aucune des deux déjà en place.

Le contrat manifest actuel (ADR 0029) ne porte pas de champ d’état d’obsolescence. Deux options, à ajouter / arbitrer (ne pas supposer existantes) :

  • Convention de sélection : « le run courant pour un dt = le manifest valide le plus récent (produced_at) ». Implicite, sans champ supplémentaire.
  • Champ explicite à ajouter : p. ex. supersedes: "run=<id>" et/ou status: "current" | "superseded" dans le manifest, pour rendre l’obsolescence explicite et traçable. Extension non destructive (cohérente avec schema_version comme point d’extension, ADR 0029).

L’ancienne partition run=<id> reste physiquement présente (immuabilité, traçabilité) ; elle n’est simplement plus servie. Elle relève alors du masquage à la lecture (§3) au titre de partition historique figée.


3. Masquage à la lecture des partitions historiques (c)

Section intitulée « 3. Masquage à la lecture des partitions historiques (c) »

Deuxième mécanisme de l’invariant : le masquage à la lecture. La régénération (§2) ne règle que la partition courante. Mais les partitions historiques (les mois passés, déjà figés) peuvent contenir une personne qui s’est opposée après leur production — et on s’interdit de les réécrire. La parade : ne pas toucher au stockage, mais filtrer la sortie au moment où on la lit. Concrètement, le service de lecture retire les personnes opposées de chaque réponse qu’il renvoie, quelle que soit la partition d’origine. Cette section décrit ce filtrage et les garde-fous qui l’entourent (authentification, contrôle de cohérence).

(Un run supersédé est un run remplacé par un plus récent ; atlas-api est le service applicatif qui répond aux requêtes de recherche.)

Les partitions figées historiques (mois antérieurs, ou runs supersédés) ne sont JAMAIS réécrites (immuabilité préservée). Pourtant elles peuvent contenir une personne qui s’est opposée après leur production. L’exclusion passe alors par un masquage à la lecture, appliqué dans le service de lecture atlas-api :

  1. atlas-api charge exclusion_set(T) (clé chercheur) depuis le registre d’opposition, à jour à chaque lecture (ou via un cache à TTL court borné par le SLA, §5).
  2. Toute réponse servie — /search, recherche sémantique pgvector, recommandations nominatives, filtrage structuré — est filtrée pour n’émettre aucune ligne dont l’entité chercheur ∈ exclusion_set(T), quelle que soit la partition d’origine (courante régénérée ou historique figée).
  3. Le masquage s’applique aussi bien aux résultats issus du mart qu’à ceux issus de l’index pgvector (l’index étant lui-même purgé, §4 — le masquage est la défense en profondeur complémentaire couvrant la fenêtre de propagation).

Le masquage ne mute pas la partition historique : il filtre la sortie. L’immuabilité est intacte ; la personne opposée n’est jamais servie.

Garde-fous (ADR 0030) :

  • Authentification obligatoire sur toute route exposant des personnes ou des recommandations nominatives — y compris /search et le filtrage structuré. Aucun endpoint anonyme listant des chercheurs.
  • Cohérence bloquante : une divergence entre le périmètre servi par atlas-api et le registre d’opposition est un défaut bloquant (« droit d’opposition opérationnel, pas théorique »). À couvrir par un test/contrôle de cohérence.

Troisième mécanisme : la purge/recharge de l’index. L’index est le moteur de recherche dérivé du mart (voir « index » en tête de page). Comme il n’est jamais source de vérité et qu’il est entièrement régénérable, on n’a pas besoin d’y ruser : on supprime directement les lignes des personnes opposées (purge), puis on recharge depuis le mart déjà filtré (§2). Cette section décrit ces deux temps et la propriété qui les rend sûrs : l’idempotence — recharger plusieurs fois depuis un mart déjà filtré donne toujours le même résultat et ne réintroduit jamais une personne exclue.

L’index pgvector (sur CloudNativePG, une distribution de PostgreSQL pour Kubernetes ; alimenté par l’asset — unité de données produite — du framework d’orchestration Dagster index_load, depuis le mart) n’est pas source de vérité : il est dérivé et régénérable. Il porte deux faces, toutes deux clés sur l’entité chercheur : une FTS lexicale (full-text search, recherche plein texte par mots-clés, stockée en tsvector) et une recherche sémantique (par proximité de sens, via des vecteurs vector(384)). Ces vecteurs sont des embeddings : des représentations numériques du « sens » d’un texte, ici produits par chercheur par le modèle all-MiniLM-L6-v2. Ils sont calculés côté dataops/ en Python (via onnxruntime, le moteur d’exécution de modèles au format ONNX) à partir du seul brut S3 — garantie de reproductibilité hermétique (même entrée → même sortie, jusqu’au sha256 du contrat ; ADR 0057, ADR 0059). Le code TypeScript researcher-profiles n’en est plus que la référence de parité (l’implémentation qu’on reproduit à l’identique), pas le producteur.

Schéma concret de l’index (livré, consommé par index_load). La purge n’est plus abstraite : le schéma pgvector existe, fourni au déploiement par une migration versionnée (dataops/citation-dagster/deploy/migrations/0001_researchers_index.sql, ADR 0058). Le schéma réellement chargé par index_load n’est pas deux tables, mais une seule table researchers, clé sur l’entité chercheur et porteuse des coordonnées de partition (dt, run) :

  • researchers (researcher_id text, embedding vector(384), fts tsvector, dt, run) — une ligne par chercheur servi, portant les deux faces : le vecteur sémantique (embedding) et la FTS déjà matérialisée (fts, colonne tsvector peuplée par index_load — pas un chantier futur) ;
  • index : HNSW (Hierarchical Navigable Small World, structure de recherche kNN approximative) sur embedding (vector_cosine_ops), GIN (index inversé PostgreSQL) sur fts, et un index (dt, run) pour les opérations par partition.

(Il n’y a pas de table pairs dans l’index : les paires de collaboration vivent dans le mart collab côté S3 (§2) ; l’index servi est ancré sur le seul grain chercheur.)

Propagation d’une opposition à l’index, exprimée sur ce schéma :

  1. Purge ciblée : DELETE direct des lignes dont l’entité chercheur ∈ exclusion_set(T) — ligne par ligne, sans recalcul d’embedding (aucun nouveau modèle ni GPU, ADR 0029). La table portant les deux faces sur la même clé, une seule suppression retire à la fois le vecteur et la FTS du chercheur opposé :

    DELETE FROM researchers WHERE researcher_id = ANY($exclusion_set);

    La clé chercheur (researcher_id) rend la purge directe ; la FTS (fts, déjà matérialisée dans la même ligne) part avec.

  2. Recharge depuis la partition régénérée : index_load (étape 4.4) recharge l’index depuis la partition courante régénérée (§2) — désignée par ses coordonnées (dt, run') —, qui ne contient déjà plus les personnes opposées. Le chargement se fait par partition : recharger (dt, run') remplace les lignes de cette partition (pas de doublon). Purge + recharge sont idempotents : recharger depuis un mart déjà filtré ne réintroduit jamais une personne exclue.

  3. L’index reste cohérent avec la partition courante servie et avec le registre d’opposition.

L’index étant purgeable / régénérable, l’opposition y est honorée par suppression de lignes, pas par masquage de partition. Le masquage atlas-api (§3) reste la défense en profondeur durant la fenêtre entre l’opposition et la fin de la purge/recharge.

La vraie purge RGPD est en amont, pas un DELETE par chercheur. Le DELETE ci-dessus n’est qu’un nettoyage de l’index dérivé ; il n’est jamais la purge de référence — l’index n’est pas source de vérité (§0.3) et un rechargement le reconstruirait. La purge fait foi au point unique curated → marts (§2.1) : ADR 0059 disqualifie la suppression en bloc par clé chercheur (un mean-pool L2-normalisé n’est pas dé-poolable, un label co-porté serait perdu à tort) au profit d’un ANTI-JOIN dbt au grain (author_id, work_id) : la liste des couples opposés (variable opposition_pairs, vide par défaut, matérialisée par la macro opposition_pairs_cte) est retirée avant agrégation dans marts_researchers / marts_author_profiles, puis le GROUP BY re-dérive les poids sur les couples restants. Chirurgical : un label porté par un couple opposé disparaît, mais le même label porté par un autre couple (autre œuvre, ou co-auteur non opposé) survit. L’index n’est ensuite que rechargé depuis ce mart déjà filtré (étape 2 ci-dessus).

Capacité côté index : prête ; reste à brancher l’entrée. Le mécanisme de purge décrit ci-dessus est réalisable sur le schéma livré (table researchers clé sur la personne, coordonnées (dt, run), FTS + vecteur dans la même ligne) et la chaîne amont (anti-join dbt, asset index_load) est livrée : le dépôt permet la purge. Ce qui manque n’est pas la chaîne mais l’actionnement — la exclusion_set(T) exprimée en clé chercheur (§1.4) : tant que la correspondance compte ↔ chercheur n’est pas matérialisée et que le déployeur n’a pas branché le registre d’opposition, la purge n’a pas de liste à appliquer (opposition_pairs reste vide → anti-join no-op). Le code fournit la mécanique ; l’actionner (brancher le registre, trancher la recevabilité d’une opposition, fixer le SLA) relève du déployeur (responsable de traitement).


Les sections précédentes décrivent comment une opposition est honorée ; celle-ci décrit en combien de temps. Le SLA de propagation est l’engagement de délai entre le moment où une personne s’oppose et le moment où elle a réellement disparu du mart, de l’index et des réponses servies. Cette section en donne la définition précise (§5.1), distingue les deux régimes de vitesse — l’effet quasi-immédiat côté service grâce au masquage, et le rattrapage plus lent des artefacts de fond (§5.2) — et rappelle que la valeur chiffrée du délai relève d’un arbitrage du DPO, non du code (§5.3).

Une opposition retire la personne du mart ET de l’index dans le SLA défini (ADR 0030, garde-fous). Le coût de ré-dérivation est payé à chaque opposition.

SLA de propagation = délai maximal entre l’opposition exprimée (écriture dans le registre, current-consents à jour) et son effet effectif sur : (1) le mart courant servi, (2) l’index pgvector, (3) le service de lecture atlas-api.

Prérequis de mesurabilité. Ce SLA n’est opérationnel que si la sémantique opt-out est effectivement branchée (§1.3) et la clé chercheur + la correspondance compte ↔ chercheur existent (§1.4) : sans elles, « l’opposition exprimée » ne peut pas être projetée sur le mart ni l’index, et le SLA n’a pas de cible mesurable. Tant que ces dépendances (§6.4) ne sont pas livrées, le SLA reste théorique.

La propagation n’est pas uniforme : une couche prend effet tout de suite, les autres rattrapent ensuite. Le tableau ci-dessous compare les trois couches selon leur mécanisme, leur latence et le fait qu’elles garantissent ou non un effet immédiat. (TTL, Time To Live : durée de validité d’une donnée en cache avant rafraîchissement.) La lecture clé : atlas-api est le filet de sécurité instantané, le mart est le plus lent — d’où la stratégie expliquée juste après.

CoucheMécanismeLatenceGarantie immédiate
atlas-api (lecture)Masquage à la lecture sur exclusion_set(T) (§3)Quasi-immédiat (≤ TTL du cache de l’exclusion, à borner court)Oui — c’est le filet de sécurité
Index pgvectorPurge ciblée (DELETE) + recharge (§4)Purge rapide (ligne à ligne) ; recharge selon triggerPurge ciblée déclenchable hors schedule
Mart (partition courante)Régénération nouveau run depuis curated filtré (§2)Coût d’un run dbt completNon — porté par le masquage en attendant

Stratégie recommandée : le masquage atlas-api garantit l’effet quasi-immédiat côté service (le SLA perçu par la personne opposée), pendant que la régénération du mart + la purge/recharge de l’index rattrapent l’état réel des artefacts dans le SLA défini. La régénération complète peut être :

  • déclenchée à l’opposition (régénération du run courant à T), ou
  • agrégée sur un déclencheur borné par le SLA (p. ex. batch infra-mensuel), à condition que le masquage couvre l’intervalle.

La valeur chiffrée du SLA (p. ex. effet service ≤ X, rattrapage mart+index ≤ Y) n’est pas figée dans le code et relève de l’arbitrage DPO (ADR 0030). À définir lors de la gate phase 0. Contrainte d’architecture : l’effet côté service doit être immédiat (masquage), le rattrapage des artefacts dérivés borné et mesurable.


Cette dernière section verrouille tout ce qui précède. Elle rassemble : les invariants qu’aucune évolution future ne doit casser (§6.1) ; le contrôle de cohérence automatique qui vérifie en continu qu’aucune personne opposée n’est servie (§6.2) ; la gate — la barrière de démarrage qui interdit tout traitement de données réelles tant que des conditions ne sont pas remplies (§6.3) ; et le récapitulatif des dépendances à construire avant de pouvoir avancer (§6.4). C’est la check-list de conformité de l’étape 0.

Rappel synthétique des règles non négociables disséminées dans la page : les violer reviendrait à casser soit l’immuabilité, soit la garantie d’opposition.

  • Partitions immuables : rejeu = nouvelle partition ; jamais de réécriture en place. L’opposition est absorbée par régénération + masquage + purge, pas par mutation (ADR 0029, ADR 0030).
  • manifest.json = seule autorité du contrat : validé par sha256 + row_count + schema_version ; schema_version inconnue refusée. Ni le mart, ni l’index ne sont autorité du contrat.
  • Index/mart jamais source de vérité : l’index est dérivé du mart, le mart est régénérable ; la source de vérité du périmètre est le registre d’opposition.
  • Minimisation maintenue : seule la réduction domain/field/subfield/topic/ keyword + historique d’articles est traitée ; aucune donnée sensible, aucun élargissement silencieux du périmètre.
  • Auth obligatoire sur toute route nominative, /search inclus.

Au-delà des invariants, on veut une vérification automatique que le système respecte réellement l’opposition. Le principe : ce que sert atlas-api ne doit jamais contenir une personne du registre d’opposition ; tout écart est un défaut qui bloque.

Un contrôle doit vérifier en continu : périmètre servi par atlas-api ⊇-complémentaire du registre d’opposition — c.-à-d. aucune personne de exclusion_set(T) n’est servie, depuis aucune partition. Toute divergence est un défaut bloquant (ADR 0030).

Une gate est un point de passage obligatoire : tant que ses conditions ne sont pas toutes remplies, l’étape suivante ne démarre pas. Ici, elle interdit de manipuler la moindre donnée réelle avant que le dispositif d’opposition soit réellement opérationnel.

Aucune phase manipulant des données réelles ne démarre tant que :

  1. le registre d’opposition est branché (avec l’attribut author_id sur l’opposition et la correspondance compte ↔ chercheur — §1.4, à construire ; la clé du mart, elle, existe déjà — ADR 0059) ;
  2. la liste d’exclusion est consommable par marts (régénération), index_load (purge) et atlas-api (masquage) ;
  3. l’arbitrage DPO a eu lieu pour l’instance (bases légales art. 6.1.e/f, information des personnes, responsable de traitement, valeur du SLA).

Synthèse, en un seul tableau, de tout ce qui n’existe pas encore et qu’il faut construire — avec, pour chaque manque, son statut et ce qu’il bloque. C’est la liste de travail de l’étape 0. Précision importante : la chaîne de propagation (mart researchers clé author_id, purge dbt par couples (author_id, work_id), asset index_load) est livrée (ADR 0058/0059) ; les verrous restants ne portent donc plus sur la chaîne, mais sur l’actionnement de l’opposition — son rattachement à la clé chercheur et son interprétation opt-out.

ManqueStatutBloque
Attribut de clé chercheur (author_id) sur l’enregistrement d’oppositionÀ ajouterProjection de l’opposition sur le mart/index
Table de correspondance compte Appwrite ↔ author_idN’existe nulle partActionnement de l’opposition (liste à appliquer)
Sémantique opt-out explicite (renommage ConsentType/valeurs)À ajouterInterprétation correcte du défaut
Routes admin/DPO (inscription tiers, liste des opposés, requête par ORCID/email)À ajouterOpposition de personnes sans compte
Endpoint de consultation de l’historique (getByUserId exposé)À ajouterAuditabilité réglementaire
Champ status/supersedes dans le manifest (obsolescence explicite)À ajouter / arbitrerMarquage d’obsolescence (§2.3)
Index unique (userId/clé chercheur, type) côté AppwriteÀ confirmer/ajouterGarantie d’unicité (anti-doublon)
Valeur chiffrée du SLAÀ arbitrer (DPO)Mesurabilité du SLA