Skip to content

Develop → main : fédération #636, API ouverte des plans, SBOM et correctifs - #667

Merged
aschlegle merged 49 commits into
mainfrom
develop
Sep 28, 2026
Merged

aschlegle merged 49 commits into
mainfrom
develop

Conversation

@aschlegle

Copy link
Copy Markdown
Contributor

Intègre dans main les 46 commits de develop depuis la 0.1.48.

Contenu

Exploration fédérée multi-instances (#636)

  • Hub d'exploration : dépôt de l'index par lots avec purge à la bascule, recherche et fiche servies depuis l'index agrégé
  • Côté instance : dépôt de l'index sur le hub, relais de l'exploration, publication nocturne
  • Consentement au partage et réciprocité, registre des instances côté hub
  • Provenance des données affichée (pastille et bandeau)
  • Image et compose de production du hub, documentation de mise en production
  • Banc d'essai, tests de banc et E2E de la fédération

Exploration des données : pourquoi un résultat répond (#650), survol, repli de recherche et champ de filtre (#649, #651, #652)

API ouverte des métadonnées des plans (#645)

Corrections

Divers

  • Numéro de version dans l'administration (Montrer le numéro de version sur le front end dans l'administration  #646)
  • Bloc marque UE + LIFE BIODIV'FRANCE et mention UE obligatoire
  • Brouillon de la notice utilisateur v2.0
  • SBOM CycloneDX envoyés à Dependency-Track : workflow sbom.yml à chaque release publiée (et lancement manuel). Il doit être sur main pour que GitHub le propose au lancement manuel.

Avant de lancer le workflow SBOM

Les secrets DT_URL et DT_API_KEY (clé ci-upload) doivent être accessibles au dépôt : ils ne sont pas au niveau du dépôt, à vérifier au niveau de l'organisation.

🤖 Generated with Claude Code

aschlegle and others added 30 commits August 3, 2026 14:56
)

Premier test local de l'exploration fédérée, sans engager la topologie ni la
gouvernance (les deux restent à trancher dans l'épic).

- `instance_id` sur `ccd_search.t_recherche_contenu` : toutes les clés de
  l'application sont des séquences locales, l'instance d'origine est donc la
  seule chose qui rende `id_objet` non ambigu. Elle entre dans la contrainte
  d'unicité.
- `id_pg` devient nullable + `plan_denorm` : un document reçu d'une autre
  instance n'a pas de plan dans cette base ; son bandeau d'affichage est
  emporté avec lui.
- `federation.py` : format d'échange versionné, jeton de service partagé
  (provisoire, cf. #514), et publication en étoile — un portail ne repropage
  pas ce qu'il a ingéré.
- `pull_federation` : ingestion + purge par instance (l'état fait foi, pas
  l'événement, pour que la dépublication se propage).
- `docker-compose.instance.yml` + `docs/MULTI_INSTANCE_LOCAL.md` : deux
  instances sur une même machine pour rejouer le scénario.

17 tests (`tests/apps/search/test_federation.py`), suite `search` verte (88).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les sites étaient vidés à l'ingestion faute de clé stable entre instances.
`id_inpn` en est une : unique et national, il désigne le même espace protégé
partout. Les documents publiés portent donc désormais `site_inpn_codes`,
re-résolus en identifiants locaux à la réception — même modèle que les zones
ref_geo.

Réserve propre aux sites : `id_inpn` est nullable, un site qui n'en porte pas
n'est pas publiable. La liste transmise est partielle par construction ; elle
dit « ces sites-là », jamais « seulement ceux-là ».

La traduction est groupée par page (`codes_de_la_page`) : deux requêtes pour
toute la page au lieu de deux par document, l'index ayant vocation à porter
~1,3 M de documents.

Seuls les organismes restent vidés, faute d'identité nationale tranchée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nse) (#634)

Une action de connaissance se lisait comme un intitulé et une période : ni
l'espèce ou l'habitat observé, ni le protocole appliqué, ni ce qui mesure
l'effet de l'action n'étaient publiés.

La fiche action de l'exploration expose maintenant :

- le **suivi** — intitulé, statut, objectifs de la collecte, cible, espèce
  (TaxRef) et habitats (HabRef), fréquence, outils de saisie et de
  bancarisation, commentaires ;
- ses **protocoles** — nom CAMPanule ou saisie libre, descriptif, objectif,
  période d'échantillonnage, mois de suivi, documentation, et surtout le
  respect du protocole avec sa justification : un protocole appliqué avec des
  écarts ne se compare pas à un protocole appliqué à la lettre ;
- ses **indicateurs de réponse**, avec métriques et grille de lecture ;
- le détail de mise en œuvre — localisation, fréquence de l'action.

Deux règles reprises de l'export de fiche action plutôt que réinventées, pour
que les deux surfaces ne divergent pas : `_reponse_indicateurs` (#626) décide
quels indicateurs de réponse sont ceux de l'action, et les indicateurs de
réponse sont tenus hors du bloc « Cadre ». Le cadre lit désormais aussi les
indicateurs atteints par les métriques, et non le seul lien direct — il restait
vide sur les actions rattachées par leurs métriques, qui sont le cas courant.

Le périmètre exclu ne bouge pas : ni budget, ni RH (`nb_etp_cycle` compris), ni
mesures, ni réalisations. Les mnémoniques de nomenclature des suivis sont
résolus en une requête pour toute la fiche, et le coût de la fiche reste
indépendant du nombre d'actions (test de garde).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retour de recette : le bouton « retirer d'ici » s'affichait VIDE, sans icône.

`fi-rr-unlink` n'existe pas dans le set Uicons Rounded Regular 2.6.0 chargé par
`index.html` — c'est le piège déjà documenté dans CLAUDE.md (le nom attendu ne
correspond pas toujours à un glyphe réel). Le glyphe correct est
`fi-rr-link-slash`, que le projet utilise d'ailleurs déjà ailleurs
(`admin-users.component.html`).

Trois occurrences corrigées, pas seulement celle vue en recette : le bouton des
deux branches d'indicateur (#585) ET celui du résultat attendu, introduit au même
endroit par c2477c6 et invisible depuis.

Test : la présence du glyphe est désormais épinglée dans le test de rendu du
bouton, pour que la régression ressorte au lieu de passer inaperçue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Même cause que la correction précédente, sur une entrée sans rapport : la
sidebar d'administration demandait `fi-rr-unlink`, qui n'existe pas dans Uicons
Rounded Regular 2.6.0. L'entrée s'affichait donc sans icône.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le jeu d'essai ne contenait un exemple d'élément partagé que pour le facteur
d'influence (#552). Les trois relations arbitrées en recette sur #585 n'en
avaient aucun : la recette devait donc construire son propre cas à la main avant
de pouvoir tester quoi que ce soit.

Deux démos ajoutées, sur le modèle de `_seed_shared_facteur_demo` :

- un résultat attendu partagé entre deux objectifs opérationnels ;
- un indicateur d'état partagé entre deux niveaux d'exigence, et un indicateur
  de pression partagé entre deux résultats attendus.

Chaque branche parcourt les plans en brouillon jusqu'à en trouver un qui a la
structure voulue — tous n'ont pas deux niveaux d'exigence ni deux résultats
attendus dont le premier porte un indicateur. Les partages ne sont posés que sur
des plans en brouillon, seuls plans où ils seraient modifiables (#248).

Vérifié sur la base de développement : les deux passes créent bien un partage
réel de chaque type (indicateur sous deux parents), et un second passage ne crée
rien de plus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renumérote la migration de fédération en `0005_federation_instance_id` :
develop a livré `0004_contenuindexe_idx_recherche_ratt_trgm` entre-temps, et
il est déjà déployé. La migration multi-instances doit donc passer après, pas
se disputer le numéro 0004 (le graphe avait sinon deux feuilles).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Troisième brique du banc d'essai : un back API seul, sans interface, où les
instances CICADA déposeront leur index et où la recherche s'exécutera. Il vit
dans `hub/` tant que le contrat d'échange bouge — un seul commit fait alors
évoluer les deux côtés — et sera extrait quand le format sera figé.

Deux tables, et deux seulement. Le hub ne connaît aucun modèle métier de
CICADA :

- `t_plan_indexe`, une ligne par plan publié. L'index de CICADA transporte le
  bandeau du plan dans une colonne JSON de *chaque* ligne de contenu ; à
  ~1,3 M de documents c'est plusieurs centaines de Mo pour une information qui
  ne varie que par plan, et une mise à jour de libellé proportionnelle au
  nombre d'objets plutôt qu'au nombre de plans. D'où une table, et une clé
  étrangère.
- `t_recherche_contenu`, une ligne par objet explorable, avec les mêmes
  vecteurs générés et la même configuration `french_unaccent` que CICADA : les
  deux index coexistent pendant la transition et doivent répondre pareil au
  même mot.

La fiche complète d'un plan distant est un **instantané JSON publié**, pas un
modèle répliqué : les sérialiseurs de fiche produisent déjà un arbre autonome,
l'instance l'envoie tel quel et le hub le ressert. Sans ça, servir la fiche
imposait de recopier ici la moitié d'`apps.plans` et d'en maintenir deux
versions.

`instance_id` entre dans toutes les clés d'unicité. Les sites sont stockés en
codes INPN plutôt que résolus en identifiants locaux — le hub n'hébergeant
aucun site, il n'a rien à quoi les apparier. Les organismes gardent une colonne
vide : recopier un identifiant local ferait matcher le mauvais organisme, une
corruption silencieuse, là où un tableau vide produit une absence visible.

`ref_geo` et les nomenclatures sont importés depuis les **fichiers source de
CICADA montés en lecture seule** : les documents voyagent en codes et sont
re-résolus ici, deux fichiers divergents produiraient des zones introuvables en
silence. L'import échoue franchement si le montage manque, plutôt que de
laisser démarrer un hub aux facettes vides.

Le compose fige `name: cicada_hub`. Sans ça Compose déduit le nom du dossier,
« cicada », et le service `db` de ce fichier détruit puis recrée le conteneur
de la base de l'instance principale.

10 tests (`hub/tests/test_socle.py`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Une publication se fait en trois temps — ouvrir un lot, déposer N pages,
basculer — parce que « l'état fait foi » et « ne rien détruire en cas de
coupure » sont contradictoires si on purge au fil de l'eau. Purger ce qui n'a
pas été revu suppose d'avoir tout reçu ; une coupure en milieu d'envoi viderait
sinon le hub de ce qui n'était pas encore arrivé. Un lot jamais basculé ne
détruit rien, il expire.

L'unité d'échange est le **plan entier**, pas le document : un plan arrive avec
la liste complète de ses objets et remplace intégralement ce que le hub savait
de lui. L'ingestion est donc idempotente sans comparaison objet par objet — un
enjeu supprimé chez l'émetteur disparaît parce qu'il n'est plus dans la liste,
pas parce qu'un message l'a annoncé.

L'instance émettrice est déduite du **jeton**, jamais du corps de la requête, et
la purge est bornée à l'instance du lot. Sans ces deux bornes, un jeton valide
suffirait à ouvrir un lot au nom d'une autre instance puis à le basculer —
c'est-à-dire à purger l'index de quelqu'un d'autre. Un jeton par instance,
révocable individuellement, plutôt qu'un secret unique partagé.

La fiche n'est pas validée dans le détail : la valider reviendrait à recopier
ici le schéma de la fiche de CICADA et à devoir le suivre à chaque évolution,
exactement ce que l'instantané JSON permet d'éviter. La validation est en
revanche stricte sur ce qui structure l'index — un libellé absent fait une tuile
vide, un `id_pg` absent fait un plan que rien ne retrouvera jamais.

Les tests ont sorti une asymétrie qui n'était pas dans le cadrage : l'unicité du
contenu est **globale à l'instance**, un identifiant d'enjeu étant une séquence
globale chez l'émetteur et non une numérotation par plan. Vider le contenu du
plan courant ne suffit donc pas si un objet est arrivé sous un autre plan —
l'insertion violerait la contrainte et ferait échouer la page entière. Ce n'est
pas censé arriver, raison de plus pour ne pas le laisser bloquer une
publication.

34 tests (`hub/tests/`), et un dépôt de bout en bout vérifié contre le hub réel :
« DEP:13 » et « REG:93 » résolus en identifiants locaux, fiche stockée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C'est ici que le choix d'architecture se concrétise : la recherche s'exécute en
un seul passage sur l'index agrégé. Le tri par pertinence est donc transverse
aux instances et les compteurs d'onglets exacts — deux choses qu'une fédération
qui interrogerait chaque instance puis fusionnerait les réponses ne peut pas
garantir, faute de scores comparables entre index.

La forme des réponses est celle de CICADA, à l'octet près : pendant la
transition une même page d'interface peut être servie par l'index local ou par
le hub, et le frontend ne doit pas avoir à distinguer les deux. Deux ajouts
seulement — chaque résultat porte son instance d'origine, et une facette
`instances` permet de s'y restreindre. CICADA n'en a pas l'usage, un index
local n'ayant qu'une seule provenance.

Les deux modes de recherche partagent désormais leurs facettes. Côté CICADA le
mode « plan » attaque `PlanGestion` par jointures pendant que le mode
« contenu » attaque l'index ; ici les plans sont eux-mêmes une table d'index,
aux mêmes colonnes. Cela supprime la classe de bugs où une facette filtrait
différemment selon le mode.

La fiche d'un plan distant est resservie telle que publiée, avec sa date : un
instantané sans date ne se distingue pas d'une donnée jointe à la volée, or
c'est précisément la différence qu'il faut pouvoir voir.

Un plan se désigne par `instance:slug`. Ni l'identifiant ni le slug ne sont
uniques entre déploiements ; l'identifiant interne du hub le serait mais n'est
pas durable — un plan dépublié puis republié en obtiendrait un nouveau, cassant
les liens.

Les noms de sites sont dénormalisés dans une colonne texte indexée en trigramme
plutôt que cherchés dans le tableau JSON : chercher dans du JSON supposerait de
le convertir à chaque requête, sans pouvoir l'indexer.

Le filtre par organisme est écrit alors qu'il ne rend rien — la colonne reste
vide faute d'identité nationale tranchée. C'est délibéré : le jour où la
décision tombe, seule l'ingestion sera à toucher, et un test fige le
comportement actuel pour qu'il se lise comme un choix et non comme un oubli.

55 tests (`hub/tests/`), et vérifié contre le hub réel : recherche « foret » qui
trouve « Forêt », compteurs, fiche distante, et recherche par nom de zone via le
référentiel du hub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Referme la boucle : CICADA publie vers le hub et peut lui déléguer sa recherche.

Le sens s'inverse — c'est l'instance qui va vers le hub, alors que le hub venait
l'interroger. Ce n'est pas un détail de plomberie : une instance derrière un
pare-feu ou sans adresse publique peut publier, alors qu'elle ne peut pas être
interrogée.

Un plan part avec tout ce qui le concerne, fiche rendue comprise. Le hub la
range sans l'inspecter, ce qui lui évite de connaître les enjeux, les objectifs
et les actions — donc de suivre le modèle métier de CICADA à chaque évolution.
Une fiche mobilisant plusieurs centaines d'objets, les pages font 10 plans et
non 500 comme pour un index nu.

En cas d'échec le lot est abandonné, jamais basculé : un état partiel basculé
dépublierait du contenu valide, là où un lot abandonné laisse en place la
publication précédente, un peu vieille mais complète. Entre « incomplet » et
« périmé », c'est périmé qui est récupérable.

Le relais passe par le backend et non par le navigateur : le jeton reste côté
serveur, il n'y a ni CORS ni second domaine à déclarer, et la bascule est
invisible pour le frontend — même URL, même forme de réponse, donc retour
arrière par simple réglage. Il n'y a délibérément **pas de repli** sur l'index
local : servir les résultats de cette seule instance sous une interface qui
promet une recherche transverse ferait conclure à l'utilisateur que les plans
des autres organismes n'existent pas.

Deux défauts trouvés en publiant pour de vrai, tous deux silencieux :

- `CICADA_INSTANCE_ID` était lu avec un défaut de `get()`, qui ne s'applique pas
  à une variable *définie mais vide* — cas courant d'un .env où la ligne existe
  sans valeur. L'identité vide s'écrivait dans chaque ligne d'index, où plus
  rien ne la retrouvait : la publication déposait 22 plans et 0 document.
- Les facettes étaient lues sur les lignes d'index. Un plan validé mais vide n'en
  a aucune : il serait parti sans zones ni types de site, disparaissant de tous
  les filtres alors qu'il doit rester trouvable en mode « plan de gestion ».
  Elles viennent maintenant de `facettes_du_plan`, la fonction qui alimente déjà
  l'indexation — les deux chemins ne peuvent plus diverger.

La commande refuse désormais de publier quand l'index ne contient aucun document
pour l'instance courante, en nommant l'identité trouvée. Sans ce garde-fou, le
dépôt « réussit » en publiant des plans que l'exploration affiche mais qu'aucune
recherche de contenu ne trouve.

`prefetch_sites` remonte de `views` dans `serializers` : la publication en a le
même besoin, et l'oublier ne casse rien visiblement — la liste des sites ressort
simplement vide.

140 tests (`tests/apps/search/`), et vérifié en réel contre le hub : 22 plans et
96 documents publiés, zones et codes INPN résolus sur 22/22, puis exploration
relayée (recherche, mode plan, fiche par slug nu complété en `rnf:slug`) et 502
franc quand le hub est coupé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le document décrivait encore l'ancienne topologie : un portail qui était une
instance CICADA et qui venait *tirer* l'index des autres. Le hub n'est plus une
instance et le sens s'est inversé.

Le scénario de bout en bout a été déroulé pour de bon, et le document rapporte ce
qui a été observé plutôt que ce qui devrait l'être :

- RNF publie 22 plans / 96 documents, le CEN autant ;
- l'index agrégé porte 44 plans et 192 documents, compteurs d'onglets doublés ;
- un enjeu ajouté côté CEN remonte à la republication, attribué à `cen` ;
- le plan repassé en brouillon disparaît — `1 plan dépublié`, fiche en 404 ;
- **le plan de même slug chez RNF reste intact.** C'est l'invariant à ne jamais
  casser : une instance ne dépublie qu'elle-même.

Le banc démontre au passage ce qu'aucun test unitaire ne rend aussi tangible :
seedées des mêmes fixtures, les deux instances produisent des slugs identiques
pour des plans différents (`bac-a-sable-plan-initial-2010-2020` → cen id_pg=30,
rnf id_pg=1864). Sans `instance_id` dans les clés, ingérer le second écraserait
le premier, en silence.

Deux pièges rencontrés au montage, désormais signalés dans le document :

- `docker-compose.hub.yml` doit figer `name: cicada_hub`. Sans nom de projet,
  Compose le déduit du dossier — « cicada » — et le service `db` du hub détruit
  puis recrée le conteneur de la base de l'instance principale.
- une base où l'ancien `0004_federation_instance_id` a déjà été appliqué demande
  le renommage de l'enregistrement en `0005` (rencontré sur l'instance CEN
  comme sur la principale).

Le port externe du hub passe de 5434 à 5435 : le CEN occupait déjà 5434.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trois briques, trois invocations Docker Compose différentes, dont deux qu'on ne
retape pas juste. `scripts/federation.sh` les rassemble derrière des
sous-commandes, et encode surtout les pièges qui ont coûté cher au montage :

- le hub DOIT être lancé avec son nom de projet, sinon Compose le déduit du
  dossier — « cicada » — et le service `db` du hub détruit puis recrée le
  conteneur de la base de l'instance principale ;
- une base ayant vu la branche avant la renumérotation porte
  `0004_federation_instance_id` et bloque au démarrage sur « column instance_id
  already exists » : `up` réaligne l'enregistrement avant de lancer le backend,
  au lieu de laisser un conteneur en boucle de redémarrage ;
- changer l'identité d'une instance périme tout son index, et la publication
  échoue alors — franchement, mais après coup : `reindex` fait le `--purge`.

`status` répond à la question qu'on se pose vraiment en débogage : qui tourne,
sous quelle identité, en mode local ou relayé, et que contient le hub. `check`
va plus loin en vérifiant que la recherche est effectivement transverse, c'est-à-
dire que plusieurs instances ressortent d'une même requête — un index peuplé par
une seule instance est un banc d'essai qui ne teste rien.

`down all` épargne volontairement RNF : c'est le stack de travail, pas une
brique jetable du banc.

Vérifié : `status`, `check`, `push` (avec et sans cible, options transmises),
`mode local|hub` aller-retour, et `test` — 55 tests hub + 140 CICADA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le script était référencé par la documentation mais tombait sous la règle
« scripts/ », qui exclut les utilitaires locaux. Il n'est pas local : c'est le
point d'entrée du banc d'essai à trois instances, et il encode les pièges de
montage qu'on ne veut pas rediagnostiquer.

La règle passe de `scripts/` à `scripts/*` pour que les exceptions prennent
effet : Git ne sait pas ré-inclure un fichier dont le dossier parent est exclu,
un simple `!scripts/federation.sh` sous `scripts/` serait resté sans effet.
`sync-version.sh`, déjà suivi, est explicité au passage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le chantier vit dans trois documents (docs/MULTI_INSTANCE_LOCAL.md, hub/README.md
et le code) mais rien n'y menait depuis CLAUDE.md, qui est le point d'entrée. Une
session future serait repartie de zéro sur des décisions déjà prises — et sur des
pièges déjà payés.

La section couvre ce qui ne se déduit pas du code :

- le sens des flux et pourquoi il est ce qu'il est (une instance derrière un
  pare-feu peut publier, pas être interrogée ; le navigateur ne parle jamais au
  hub) ;
- le blocage central — rien n'est identifiant entre instances — et les trois
  traitements qui en découlent, dont la colonne organismes délibérément vide ;
- pourquoi la fiche est un instantané publié et non un modèle répliqué, et
  pourquoi le plan est une table et non une colonne JSON ;
- l'état plutôt que l'événement, le lot en trois temps, et les deux bornes de
  sécurité (instance déduite du jeton, purge bornée à l'instance) ;
- l'absence délibérée de repli sur l'index local quand le hub tombe.

Et surtout les trois pièges qui coûtent une demi-journée à rediagnostiquer : le
nom de projet Compose du hub, le renommage de la migration 0004 → 0005, et la
péremption de l'index après changement d'identité.

Le tableau de couverture des tests intègre les 55 tests du hub, dont la suite se
lance à part puisque c'est un projet distinct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…636)

`federation.sh up --open`, ou `open` seul, lance les deux interfaces dans deux
fenêtres **distinctes** plutôt que deux onglets : l'intérêt du banc est de
comparer les deux instances côte à côte — l'une sur son index local, l'autre
relayée vers le hub — et un onglet qu'on quitte ne se compare à rien.

Les deux pages s'ouvrent directement sur `/exploration`, le seul écran où la
fédération se voit.

Le navigateur est détecté parmi google-chrome, chromium et xdg-open ; à défaut
les URL sont affichées. Une interface qui ne répond pas est signalée et non
ouverte, plutôt que de donner un onglet en erreur qu'on prendrait pour un bug
applicatif.

Documenté au passage : les sessions des deux instances ne se marchent pas dessus,
les ports différents faisant des origines différentes donc des `localStorage`
séparés. On peut être connecté aux deux à la fois, sous des comptes différents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#636)

Une seule invocation du navigateur avec les trois URL : Chrome ouvre alors une
fenêtre et y place un onglet par URL. Les lancer une par une, comme le faisait la
version précédente, donnait autant de fenêtres.

Le hub gagne son onglet, sur la sonde de disponibilité — la seule route qu'un
navigateur puisse atteindre, les autres exigeant l'en-tête `X-Hub-Token` qu'une
barre d'adresse ne sait pas poser. Il répond d'un coup d'œil à « le hub est-il
debout ? », qui est la première question quand l'exploration d'une instance
relayée renvoie une erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aucun des 195 tests unitaires n'a vu les bugs de cette fédération. Tous étaient
des bugs de **couture** — entre les deux projets, ou entre le code et le
déploiement :

    identité d'instance vide  → 22 plans publiés, 0 document
    facettes lues sur l'index → un plan sans contenu part sans facettes
    deux migrations 0004      → conteneur en boucle de redémarrage
    nom de projet Compose     → base de l'instance principale détruite

Chaque projet avait ses tests, écrits contre sa propre lecture du contrat.
Personne ne les avait fait se parler. D'où trois familles :

**Contrat** — une charge utile réellement produite par CICADA est soumise au
sérialiseur du hub, sans rien écrire. Un champ renommé d'un côté casse un test
au lieu de casser une publication.

**Scénarios** — aller-retour, isolation entre instances, dépublication,
idempotence, garde-fou d'identité, jeton invalide. C'est ce que j'avais déroulé
à la main ; ce n'est plus à redérouler. L'idempotence est ce qui rendra une
publication périodique sans danger : un second passage qui retirerait ou
dupliquerait quoi que ce soit dégraderait l'index à chaque exécution.

**Parité** — le cas le plus important. `filters.py` existe en deux exemplaires,
un par projet : 14 requêtes couvrant les facettes, les deux modes de recherche
et les tris doivent rendre le même résultat servies localement ou par le hub.
Sans ce test, les deux implémentations divergeraient en silence, et une
divergence de filtrage se constate sans pouvoir se décrire.

Vérifié par mutation plutôt que par confiance : neutraliser le filtre « statuts »
du hub fait échouer la parité en nommant les deux requêtes divergentes
(archive : 0 en local, 96 au hub). Un test qui passe du premier coup mérite
qu'on s'assure qu'il n'est pas creux.

Le banc ne suppose pas les instances configurées pour publier : il lit les
jetons dans `.env.hub` et les passe à chaque appel. Sans ça, les tests
échoueraient sur un `.env` incomplet plutôt que sur un vrai défaut — et la
première question serait « le test est-il cassé ? ». C'est d'ailleurs ce que le
premier passage a révélé : l'instance RNF n'était pas configurée.

Le cas « dépublication » repasse un plan en brouillon puis le restaure, y compris
si l'assertion échoue : le banc doit ressortir comme il est entré.

Hors CI (trois stacks Docker) et sans pytest : l'hôte n'a que `requests`, et
déclencher une publication demande un `docker exec` qu'un conteneur ne peut pas
faire.

11/11 cas passés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…és (#636)

Playwright sur l'instance **relayée** vers le hub — le seul niveau qui couvre ce
que l'utilisateur voit. Les suites unitaires et le banc s'arrêtent à l'API ; ces
deux défauts naissent de la construction du lien et de la forme de la réponse,
côté navigateur, et aucun test d'API ne pouvait les voir.

**Le lien de fiche perdait l'instance d'origine.** Les tuiles liaient par
`plan.slug`. Or les deux instances du banc sont seedées des mêmes fixtures et
ont des slugs identiques pour des plans différents : cliquer un résultat distant
demandait `<instance-locale>:<slug>` et ouvrait donc l'homonyme local — ou rien.
Une réponse fausse et silencieuse, la pire espèce. D'où `referencePlan()`, qui
utilise la `reference` fédérée (« rnf:camargue ») quand elle existe et retombe
sur le slug sinon : hors fédération, rien ne change.

**Le hub enveloppait la fiche** dans `{fiche: …}` là où une instance la sert à
plat. Le frontend recevait une enveloppe qu'il ne sait pas lire et affichait une
page au titre vide. C'est la promesse « même forme de réponse, donc bascule
invisible pour le frontend » qui était fausse. Le hub sert désormais la fiche à
plat, métadonnées de fédération à côté.

Le test de banc **masquait** ce second défaut : il déballait lui-même
`['fiche']` au lieu de comparer les deux formes. Un test qui s'adapte à ce qu'il
mesure ne mesure plus rien ; il compare maintenant à plat des deux côtés.

Les tests E2E de fédération sont opt-in (`E2E_FEDERATION=1`) : ils demandent le
banc à trois briques, que ni la CI ni un lancement ordinaire de la suite n'ont.
Ils visent une autre origine, d'où une session dédiée — un `storageState` est lié
à son origine, celle de l'instance principale n'y vaut rien. Ils s'exécutent dans
le conteneur frontend du CEN, avec le Chromium système : l'image est Alpine, et
celui fourni par Playwright est compilé pour glibc. Le script l'installe s'il
manque, plutôt que d'échouer sur « executable doesn't exist ».

Deux erreurs de ma part, corrigées en route : les recherches d'origine portaient
sur des noms de plans (« camargue », « bac à sable ») là où la recherche par
contenu attend des libellés d'enjeux, et l'attente de la réponse de fiche était
posée après le clic — `waitForResponse` ne voit que ce qui arrive après son
appel.

6/6 E2E, 55 hub, 11/11 banc, 13 unitaires frontend. Mode local vérifié inchangé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le vrai sujet de #636 : qui consent à publier, et à quelle condition on lit.

**Ce que le partage fait sortir n'était pas à construire.** `serializers_fiche`
documentait déjà une liste d'exclusions exhaustive — budget et financement,
ressources humaines, mesures et réalisations, traçabilité interne — verrouillée
par `TestFichePubliqueCloisonnement`. Ce qui manquait, c'était le **choix** et sa
contrepartie.

**Le choix** vit dans `SiteConfiguration.federation_partage`, faux par défaut.
Publier le contenu de ses plans est un engagement de la structure : une montée
de version ne doit jamais le prendre à sa place, et une instance qui met à jour
CICADA continue donc de ne rien publier tant que personne n'a décidé. Il se
change depuis l'interface, sans redéploiement — revenir sur un engagement ne
doit pas dépendre de qui tient les serveurs.

**La réciprocité est appliquée par le hub**, et c'est le point qui compte. Une
instance peut couper son propre relais, mais quiconque l'administre peut aussi
le rallumer : une réciprocité qui ne tient qu'à la bonne volonté du lecteur
n'est pas une règle, c'est un souhait. Le jeton de lecture devient donc propre à
chaque instance — un secret partagé authentifie mais n'identifie pas — et le hub
refuse de servir qui n'a rien déposé. Effet de bord assumé : une instance sans
aucun plan validé ne peut pas lire, faute d'avoir de quoi verser.

**Le retrait a sa propre commande.** `push_federation` refuse de déposer un lot
vide, parce qu'un index momentanément vide — identité mal configurée, base
restaurée — effacerait sinon tout le travail de la structure. Mais retirer ses
données est un droit. La distinction n'est donc pas entre autorisé et interdit,
mais entre **accidentel** et **voulu** : deux commandes.

**Le message**, à trois endroits. La page d'exploration annonce sa portée avant
même la recherche : sans ça, une exploration limitée à son organisme se lit
comme une panne, et l'utilisateur conclut que les autres structures n'ont pas de
plans. Les paramètres montrent deux listes — ce qui sort, ce qui ne sort
jamais — parce que la question que se pose une structure est « qu'est-ce qui
part de chez moi ? », et qu'une liste y répond plus vite qu'un paragraphe.

Vérifié de bout en bout : partage coupé sur le CEN, l'exploration continue de
répondre mais sur ses seuls plans (4 résultats au lieu de 8) ; partage rétabli,
les deux instances reviennent.

59 hub, 231 CICADA, 13/13 banc (dont deux nouveaux cas consentement et
réciprocité), 6/6 E2E, 59 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#651, #652)

Trois retours de recette, dont un dont la cause n'était pas celle qu'on croyait.

**#649 — le survol annonçait un autre type.** La tuile porte un liseré gauche
qui identifie le type du résultat, et le survol posait `border-color`, donc
*toutes* les bordures. La couleur employée, `$secondary-orange-salmon`, est
précisément celle des pressions : survoler un enjeu l'annonçait comme une
pression. Le survol se signale désormais par l'ombre et un fond léger, deux
variations qui ne portent aucune signification ailleurs.

**#651 — ce n'était pas les rattachements, c'était la tolérance aux fautes.**
L'hypothèse évidente — les objets rattachés sont dans le vecteur « titres »
(#634) — était fausse, et la mesure l'a corrigée : `word_similarity('fleur',
titre)` vaut **0,667** pour un seuil à 0,6, parce que « fleur » ressemble assez
au mot « **leur** ». D'où « Préservation des pelouses alpines et de **leur**
faune associée » remonté sur « fleur », en mode titres uniquement.

Relever le seuil ne pouvait pas marcher : une vraie faute score à peine plus
haut (« eutotrophes » / « eutrophes » = 0,69) et serait tombée avec le bruit. Ce
qui sépare les deux cas n'est pas le score mais le **contexte** — on ne cherche
un mot approchant que faute d'avoir trouvé le mot. Le trigramme redevient donc
ce qu'il aurait dû rester : un repli, qui ne s'en mêle pas tant que le plein
texte répond.

Le repli se décide **avant** les facettes : si le mot correspond mais qu'un
filtre écarte tout, la bonne réponse est « aucun résultat », pas une liste de
termes approchants que personne n'a demandés.

**#650, première moitié.** La réponse porte désormais `approximatif`, et
l'interface l'annonce : « aucun résultat ne correspond exactement à “fleur” ».
Un résultat approchant qui ne se présente pas comme tel laisse croire qu'on a
trouvé ce qu'on cherchait — c'est exactement ainsi que #651 a été rapportée.

**#652 — deux causes cumulées.** Le champ de recherche du filtre était le
premier enfant d'un panneau qui défile : il sortait de vue dès qu'on faisait
défiler, c'est-à-dire exactement quand il sert. Il est maintenant collant en
haut de la zone. Et le panneau, quand il bascule au-dessus du déclencheur,
glissait sous le bandeau collant : `viewportMargin` lui réserve la place. Le
correctif touche `filter-option-list`, partagé — les autres filtres en
profitent.

Le correctif de #651 est porté dans les **deux** copies de `filters.py` ; la
parité du banc l'a vérifié (14 requêtes identiques des deux côtés).

156 CICADA, 61 hub, 13/13 banc, 6/6 E2E, 85 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#650)

« Pour "fleur" on ne sait pas si c'est lié au mot, ou bien si une des espèces
est une fleur. » Le problème est propre à cet index : les objets rattachés —
espèces, habitats, protocoles standardisés — sont **interrogés mais jamais
affichés**. Une tuile dont le titre n'a aucun rapport visible avec la requête
paraît donc arbitraire, alors qu'elle est parfaitement pertinente.

Chaque résultat porte désormais les champs qui ont répondu, et l'extrait de
l'objet rattaché quand c'est lui. Le cas exemplaire, relevé sur le jeu de
données :

    Bruit des activités nautiques
      correspondances : ['rattachements']
      extrait : « milieu Phoenicopterus roseus Flamant rose »

Un titre sans rapport apparent, désormais expliqué. La ligne n'apparaît que
lorsque le titre n'a PAS répondu : quand il a répondu, le surlignage le montre
déjà, et répéter l'évidence noierait le seul cas qui demande une explication.

L'extrait vient de `ts_headline` : le champ est un bloc de texte sans
séparateur, et rien d'autre ne sait y isoler le passage utile. Il voyage **sans
balisage** — le surlignage se fait côté interface, par segments de texte, pour
ne pas faire transiter du HTML depuis la base.

Le surlignage ignore les accents et retient les **débuts de mot**. Les deux
suivent la recherche : le plein texte est désaccentué et radicalisé, si bien
qu'exiger une égalité exacte ne surlignerait presque jamais rien — l'utilisateur
verrait les résultats sans savoir quel mot a répondu, c'est-à-dire le problème
qu'on prétend résoudre. À l'inverse, refuser les milieux de mot évite d'allumer
« leur » dans « valeur ».

L'annotation est posée **après** le calcul des compteurs d'onglets : elle
entrerait sinon dans leur `GROUP BY` et fausserait les totaux affichés au-dessus
de la liste.

Deux détails d'implémentation qui n'en sont pas :
- la normalisation des accents se fait par unité UTF-16 et non par point de
  code, sinon les indices se décalent et le découpage tombe au milieu d'une
  paire de substitution ;
- le mot surligné est celui des **critères**, pas du champ de saisie : le champ
  peut avoir été modifié sans que la recherche ait été relancée.

Porté dans les deux copies de `filters.py` ; la parité du banc l'a vérifié.

156 CICADA, 61 hub, 13/13 banc, 6/6 E2E, 139 frontend (dont 8 nouveaux cas de
surlignage).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le retour de recette #652 — « la recherche des organismes gestionnaires n'est
pas visible car trop d'OG » — était **intestable avec le jeu d'essai** : cinq
organismes tiennent à l'écran, le panneau ne défile pas, et le champ de
recherche ne peut donc pas sortir de vue. On corrigeait un défaut qu'on ne
pouvait pas reproduire.

Le seeder pose désormais les vingt CEN du réseau, sans coordonnées véritables.
C'est le volume que rencontrera une instance nationale, et c'est celui qu'il
faut pouvoir mettre sous les yeux d'un testeur : 26 organismes au total, le
panneau défile, le champ collant se vérifie.

Leur type est déduit du préfixe « CEN » plutôt qu'énuméré dans
`TYPE_ORGANISME_MAP` : vingt lignes de mapping pour une règle que le nom porte
déjà seraient vingt occasions d'oublier la vingt-et-unième.

Aucun test n'assortit le nombre d'organismes d'une valeur exacte (les E2E
vérifient `> 0`) : l'extension ne casse rien.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ajoute le hub d'exploration — projet Django distinct, back API seul, dans
`hub/` — qui agrège l'index de plusieurs instances CICADA et sert la recherche
transverse.

- publication en trois temps (lot → pages → bascule), l'état faisant foi, avec
  purge bornée à l'instance émettrice et identité déduite du jeton ;
- consentement au partage par instance, faux par défaut, et réciprocité
  appliquée par le hub : on ne lit l'exploration nationale qu'en y versant ;
- relais de l'exploration derrière un réglage, sans repli silencieux sur
  l'index local si le hub est injoignable ;
- banc d'essai à trois briques piloté par `scripts/federation.sh`.

Inclut les retours de recette #649, #650, #651 et #652 sur l'exploration, et
l'extension du jeu d'essai qui rend #652 reproductible.

Suites vertes : 2551 backend, 2557 frontend, 61 hub, 13/13 banc, 6/6 E2E.
#653)

À l'acceptation d'un compte, le super admin devait décider seul s'il fallait
promouvoir le nouvel utilisateur en administrateur d'organisme, sans jamais
savoir si cet organisme en avait déjà un. La demande de validation expose
désormais ce contexte (`organisme_admins`) et la fiche de détail l'affiche :
liste des administrateurs existants, ou encart d'alerte quand l'organisme n'en
a aucun / vient d'être créé.

Le second point de l'issue — ne notifier le super admin qu'à défaut d'admin_og
— était déjà le comportement de `_get_registration_validators` : deux tests le
verrouillent désormais.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un super administrateur est le validateur final d'un changement de rôle
admin_og : lui faire déposer une demande qu'il ira ensuite approuver lui-même
est un aller-retour sans destinataire. `request_admin_promotion` et
`request_admin_demotion` appliquent donc le changement immédiatement quand le
demandeur est super admin, et renvoient `applied: true`.

La trace n'est pas perdue : le changement crée une `ValidationRequest` déjà
approuvée, au même endroit que les demandes ordinaires. Le motif devient
facultatif dans ce cas — il n'y a plus personne à convaincre. Le parcours d'un
admin_og (demande soumise au super admin) est inchangé.

Côté interface, le modal et l'infobulle basculent sur un vocabulaire d'action
(« Promouvoir ») au lieu d'un vocabulaire de demande, et la liste des
utilisateurs se recharge pour que le rôle affiché suive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Un admin d'organisme voit déjà tous les plans des sites de son organisme
(`plan_scope_q`), mais la liste des plans lui proposait quand même de
« demander l'accès » à ces mêmes plans : une demande sans destinataire, pour un
accès déjà acquis, et le plan apparaissait deux fois à l'écran.

`user_manages_plan()` (apps/plans/access.py) nomme ce périmètre — super admin,
rédacteur principal, admin d'un organisme gestionnaire ou rédacteur du plan —
et `request_plan_access` le refuse désormais. Il reste volontairement distinct
de `user_can_access_plan`, vrai pour tout compte simplement lié au plan : un
utilisateur rattaché à un site garde le droit de demander à être rattaché au
plan.

Côté interface, la section « Demander l'accès à d'autres plans » disparaît pour
ces mêmes comptes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…658)

L'endpoint /api/validations/ est servi par `UsersPagination` (classe de
pagination par défaut du projet), qui renvoie le total dans
`pagination.count` et non à la racine. Le service typait la réponse à la
forme plate DRF : `response.count` valait `undefined`, le `mat-paginator`
recevait une longueur nulle et le bouton « page suivante » restait
désactivé alors que l'API exposait bien 2 pages.

`getValidationRequests()` normalise désormais les deux formes de réponse
(plate et imbriquée) vers `PaginatedResponse`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le composant partagé `app-pagination` (Utilisateurs, Sites, Plans,
Organismes, suivi des actions) n'avait plus aucun style : sa feuille
vivait dans `_filters.scss`, supprimé en #592. Les boutons étaient donc
rendus par défaut du navigateur — fond gris #EFEFEF, bordure `outset`
noire de 2px, police système 13px — d'où des icônes très laides à côté
du `mat-paginator` de la page des validations.

Le style vit désormais avec le composant, conforme à la spécification
« Pagination » du design system : page active bleu-vert texte blanc,
pages inactives blanches bordées, chevrons primary sans bordure,
« 1-20 sur N » en gris. Ajouts au passage :

- libellés a11y (`aria-label` traduits, `aria-current="page"`) ;
- l'ellipsis devient un `<span>` non cliquable ;
- `track $index` : les deux ellipses partageaient la clé -1, ce qui
  levait une erreur de clés dupliquées dès qu'un jeu de pages en
  affichait deux ;
- suppression des règles `.pagination` d'`admin-users.component.scss`,
  inopérantes (encapsulation de vue : le markup est dans l'enfant).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aschlegle and others added 19 commits August 24, 2026 12:32
…on d'un compte (#656)

L'identifiant servant à se connecter (en plus de l'email) était facultatif :
un compte pouvait donc exister sans, et n'être joignable que par son email.

- Inscription publique : champ requis côté formulaire (étoile, message d'erreur)
  et côté `PublicRegistrationSerializer` (blanc ou espaces refusés).
- Création par l'API (`RoleCreateSerializer`) et par le formulaire Django admin :
  identifiant requis, unicité insensible à la casse contrôlée aussi contre les
  demandes d'inscription en attente, qui deviendront des comptes.
- Modification (`RoleUpdateSerializer`) : un identifiant transmis ne peut plus
  être vidé ni dupliquer celui d'un autre compte.

Les comptes existants sans identifiant restent inchangés : la page Profil
conserve son ajout facultatif, aucune migration ni blocage à la connexion.
La version de l'instance est désormais visible en pied de la sidebar
d'administration, depuis n'importe quelle page et pour tous les profils
ayant accès à l'administration (référent, admin organisme, super admin).

La page « Mise à jour » existait déjà mais n'était liée nulle part : elle
gagne son entrée de menu (super admin, comme sa route).

Deux défauts découverts au passage, corrigés :

- la version affichée était « 0.0.0 » en production. Le contexte de build
  de l'image backend est ./backend, or version.txt vit à la racine du
  dépôt : il n'entrait jamais dans l'image. Elle est maintenant injectée
  au build (ARG CICADA_VERSION → ENV CICADA_APP_VERSION, alimenté par le
  tag dans le workflow), la variable primant sur le fichier ;
- « 0.0.0 » également en dev, version.txt n'étant pas monté. Le montage
  vise la racine du conteneur et non /app, qui est le bind de ./backend :
  y créer le point de montage déposerait un fichier vide dans le dépôt.

L'endpoint /api/system/version/ reste réservé au super admin — il porte
aussi l'état de mise à jour et son déclenchement. Le pied de sidebar est
alimenté par /api/system/app-version/, ouvert à tout compte authentifié
et limité à la version. Version non chargée → rien n'est affiché, plutôt
qu'un numéro faux.

Tests : 18 backend (endpoint + résolution de version), 5 Jest sur le
layout admin (affichage, échec API, visibilité de l'entrée de menu).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DOCenCEN, la gestion documentaire du CEN, doit rattacher ses documents aux
plans rédigés dans CICADA. Il lui faut deux choses : un identifiant de plan
qui ne bouge pas, et un endroit où venir chercher régulièrement ce qu'elle
n'a pas encore.

Identifiant — ni `id_pg` ni `slug` ne conviennent : le premier est une
séquence tirée localement (deux instances produisent couramment le même
numéro), le second suit le nom et change au renommage. D'où `uuid_plan`,
immuable, et une référence « cicada:<instance>:<uuid> » : le préfixe dit de
quel outil vient la clé, l'instance dit de quelle base. La migration tire un
UUID par plan existant en trois temps — `default=uuid.uuid4` n'est évalué
qu'une fois par un `AddField`, toutes les lignes recevraient la même valeur.
`build_version_plan` retire le sien : `_dup` recopie tous les champs
concrets, et sans ce tirage une nouvelle version écraserait la précédente
côté GED.

API — servie par l'instance et non par le hub (#636), qui ne stocke ni
rédacteurs, ni dates de validation, ni identifiant Doc'Gestion : les y
mettre élargirait le contrat de publication pour un besoin qui est local.
Ouverte, sans jeton, conformément au besoin exprimé, mais sous
`SiteConfiguration.api_publique_plans` coupé par défaut — ouvrir un endpoint
sans authentification est une décision de la structure, pas l'effet de bord
d'une mise à jour. Coupée, tout répond 404.

Périmètre : métadonnées seules, et jamais un brouillon. Une GED documente
des documents, elle n'a pas besoin de leur substance ; et un brouillon est
un plan dont le nom et la période changent encore. `?statut=draft` est
refusé par un 400 plutôt qu'ignoré en silence.

La liste est ordonnée par date de modification **croissante**, avec un
filtre `modifie_depuis`. Un rattrapage pagine pendant que la base vit : en
ordre croissant un plan modifié en route part vers la fin et sera vu deux
fois, ce qu'un upsert absorbe ; en ordre décroissant il remonterait avant le
curseur et serait purement manqué.

Backend : `views_public.py`, `serializers_public.py`, `urls_public.py`,
montés sur /api/public/. Frontend : section dédiée dans
/administration/parametres. Doc : docs/API_PUBLIQUE_PLANS.md.
Tests : 27 cas d'intégration (interrupteur, identifiant, cloisonnement,
filtres, lecture seule).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…férents (#647)

L'import en masse rattachait tous les sites au seul organisme de
l'importateur, et faisait de lui l'unique référent : sur un import de
plusieurs centaines de sites, il fallait corriger en SQL après coup.

Deux colonnes facultatives sont désormais reconnues dans le fichier
(GeoJSON ou CSV), auto-détectées comme les autres :

- « organisme » (nom, id ou uuid ; plusieurs valeurs séparées par « ; »,
  la première devient le gestionnaire principal)
- « référent » (email ou identifiant de connexion)

Résolution insensible à la casse et aux accents, en deux requêtes pour
tout le lot (jamais une par ligne). Une valeur introuvable, ambiguë,
désactivée ou hors périmètre ne bloque pas l'import : elle est signalée
en avertissement à l'étape de vérification et la ligne retombe sur
l'ancien comportement (organisme de l'importateur, importateur référent).
Un admin d'organisme ne peut rattacher que ses propres utilisateurs.

Les valeurs sont re-résolues côté serveur au moment de l'import : la
réponse de validation transite par le navigateur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le hub ne pouvait démarrer que sur un poste de développement : son compose
monte le code depuis le dépôt et, surtout, monte les référentiels nationaux
depuis `backend/` — répertoire qui n'existe pas sur le serveur du hub.

L'image de production les embarque donc (840 Ko), ce qui impose un contexte de
build à la racine du dépôt, d'où le `.dockerignore` qui ramène ce contexte de
tout le dépôt à 1,2 Mo. Ce sont les mêmes fichiers source des deux côtés : les
documents publiés voyagent en codes (INSEE, mnémoniques), deux jeux divergents
produiraient des zones introuvables en silence.

Réglages durcis, un seul module servant au développement et à la production :

- `DEBUG` par défaut à False, et refus de démarrer si la clé de développement
  survit (la réserve sur pytest évite que le garde-fou casse la suite au lieu
  de protéger la production) ;
- `SECURE_PROXY_SSL_HEADER`, le hub étant servi par Apache ;
- `DATA_UPLOAD_MAX_MEMORY_SIZE` à 100 Mo : Django refusait tout corps de
  requête au-delà de 2,5 Mo, y compris en JSON. Une page de lot porte les
  fiches rendues de plusieurs plans et dépasse ce seuil — c'était un 400
  garanti à la première publication réelle, qu'un banc d'essai à trois plans
  de test ne peut pas atteindre ;
- logs fichier avec rotation, `docker logs` étant tronqué à la rotation du
  conteneur alors que c'est l'historique des dépôts qu'on veut relire.

La release produit désormais trois images sous le même tag. Un dépôt commun
n'empêche pas un déploiement séparé, et le numéro partagé évite la question
« quelle version du hub parle à quelle version d'instance » tant que le
contrat d'échange bouge.

Refs #636

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aucune variable de fédération n'existait en production : une instance
installée par paquet Debian ne pouvait pas rejoindre le hub, quelle que soit
la configuration du hub en face.

Les huit variables `CICADA_*` sont ajoutées aux deux composes de production, sur
`web` **et** `celery-worker` — c'est le worker qui exécutera la publication, et
sans elles la tâche se serait terminée en « non configurée » alors même que le
conteneur web parlait au hub. Toutes vides par défaut : un déploiement isolé se
comporte exactement comme avant.

La publication devient automatique (2h30), planifiée sur toutes les instances
et retenue par trois conditions distinctes :

- hub et jeton de dépôt configurés — la mécanique est branchée ;
- `CICADA_HUB_PUSH_AUTO`, nouveau réglage : lire l'exploration nationale exige
  l'URL du hub, donc sans ce booléen, configurer le relais aurait déclenché la
  publication dans le dos de l'exploitant ;
- le consentement de la structure, relu à chaque exécution parce qu'il peut
  être retiré entre deux nuits. Son retrait sort en `info` et non en erreur :
  une décision assumée ne doit pas se signaler comme une panne.

Un dépôt porte l'état complet et non un différentiel : une nuit sautée ne perd
rien. Un échec, lui, remonte — le journal des tâches est le seul endroit où
l'exploitant le verra, personne n'attendant devant une tâche de 2h30.

L'installeur du paquet Debian propose une section « Exploration fédérée »
facultative, qui valide l'identifiant d'instance : c'est le seul moment où
l'erreur se corrige sans conséquence, puisqu'il est ensuite écrit dans chaque
ligne d'index. Le jeton de lecture n'est exigé que pour relayer l'exploration,
publier et lire étant deux droits distincts.

Note : l'installeur existe en deux copies divergentes et `build-deb.sh` ne
copie jamais `packaging/installer/`. Seule celle du tree `debian/` est
modifiée ici, puisque c'est elle qui est packagée.

Refs #636

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les jetons vivaient dans l'environnement, lus au démarrage. Trois choses
rendaient ce montage intenable au-delà de deux instances : enrôler imposait un
redémarrage du hub — donc une coupure pour toutes les autres —, révoquer ne
laissait aucune trace, et les secrets voyageaient en clair dans les fichiers de
déploiement.

Le registre (`ccd_search.t_instance`) porte l'identité, l'état et l'empreinte
des deux jetons. SHA-256 et non PBKDF2 : ces jetons sont tirés au sort par le
hub sur 256 bits, jamais choisis par un humain — il n'y a ni dictionnaire à
opposer ni réutilisation à craindre, et un hachage lent se paierait à chaque
page d'un dépôt qui en compte des centaines.

L'environnement reste accepté en amorce, mais **uniquement pour une instance
absente du registre**. C'est la règle qui fait tout tenir : sans elle, une
instance désactivée ou dont le jeton a été renouvelé resterait admise par une
variable oubliée dans un déploiement — autrement dit, révoquer ne révoquerait
rien.

`enroler_instance` délivre, renouvelle, suspend et liste. Les jetons ne
s'affichent qu'une fois ; l'identifiant est validé à la création, seul moment
où l'erreur se corrige sans conséquence. La commande signale les instances qui
publient encore par jeton d'environnement : c'est la liste de ce qu'il reste à
enrôler.

`GET /api/federation/instances/` rend l'état de la fédération — enrôlement,
activité, dernière publication réussie, volumes. Ces informations étaient déjà
toutes en base, mais réparties dans trois tables qu'il fallait interroger en
SQL sur le serveur ; or la question « untel publie-t-il encore ? » se pose
depuis l'extérieur, souvent dans l'urgence. Aucun jeton ni empreinte n'y
figure : une empreinte permettrait de vérifier hors ligne un jeton deviné sans
que le hub ne journalise rien.

Suspendre n'est pas dépublier : l'index déposé reste servi, le retrait restant
une décision de l'instance.

21 tests : usage croisé des jetons, renouvellement invalidant l'ancien,
préséance du registre dans les trois cas (révoqué, suspendu, absent),
réciprocité toujours appliquée aux instances enrôlées, non-fuite des secrets.

Refs #636

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nouvelle note `docs/DEPLOIEMENT_HUB.md` : prérequis, installation, vhost
Apache, enrôlement d'une instance, publication, montée de version et recette.

Les quatre pièges qui coûtent le plus cher y sont nommés — ne pas réécrire
`/api`, ne pas filtrer les en-têtes d'authentification, aligner `ALLOWED_HOSTS`
sur le `ServerName`, et faire correspondre `LimitRequestBody` avec la limite
Django. Un cinquième : ne pas lancer le compose de production sur un poste de
développement, les deux fichiers pilotant le même stack.

La recette après déploiement est une séquence HTTP, pas le banc d'essai : ce
dernier pilote les briques par `docker exec` sur des noms de conteneurs figés
et ne peut donc pas viser un hub distant. Il couvre le contrat et la parité des
deux `filters.py` — à lancer avant de tagger, pas après avoir déployé.

Est aussi consigné ce qui manque encore : un `push_federation` à volume réel,
seul moyen d'éprouver les limites de taille et de durée.

Refs #636

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'exploration fédérée mélangeait, dans une même liste triée par pertinence,
des plans de plusieurs structures sans que rien ne dise lesquels. La donnée
était pourtant tracée — `instance_id` est dans chaque ligne d'index et dans
toutes les clés d'unicité — mais elle n'était jamais montrée.

Le banc d'essai en donne l'illustration : les deux instances ont un plan
`bac-a-sable-plan-initial-2010-2020`, même nom, mêmes années, même type de
document. Dans la liste agrégée, rien ne les distinguait, et un plan distant
se lisait comme un plan local.

Ce qui change :

- l'instance **se nomme en publiant** : `push_federation` transmet
  `CICADA_INSTANCE_LABEL` et `CICADA_PUBLIC_URL` à l'ouverture du lot. Le
  libellé n'autorise rien — l'émetteur reste déduit du jeton. Il est stocké
  sur le **lot** et non sur `Instance` : créer une ligne de registre à la
  publication ferait basculer l'instance du côté « enrôlée » et
  `identifier_porteur` refuserait alors son propre jeton d'environnement ;
- le hub résout un nom lisible en cascade (`hub/apps/index/identites.py`) :
  registre, puis dernière déclaration, puis l'identifiant. Jamais vide ;
- toute réponse de lecture porte `instance_id` et `instance_libelle` ; la
  fiche y ajoute `url_instance` et `date_publication` — c'est un instantané
  déposé, pas une lecture en direct, et son âge doit se voir ;
- `GET /api/exploration/instances/`, servi par le hub ou par l'instance
  (une seule structure, la sienne) : même forme de réponse des deux côtés ;
- `?instances=` filtre les deux modes, honoré aussi par l'index local — une
  URL de recherche est faite pour être partagée, et l'ignorer rendrait les
  plans d'ici sous un filtre qui demandait ceux d'ailleurs ;
- interface : pastille de provenance sur chaque tuile, bandeau et lien vers
  la structure sur la fiche, filtre « Structure d'origine » (masqué en deçà
  de deux structures), portée chiffrée annoncée avant la recherche.

Au passage, un défaut latent : les tuiles du mode « plan » étaient suivies
par `id_pg`, une séquence locale à chaque instance. Deux structures ayant
couramment un plan n° 42, Angular n'en aurait rendu qu'une. Suivi par
référence désormais.

Tests : 16 cas de provenance côté hub (98 au total), 5 côté CICADA (166 dans
`tests/apps/search/`), un cas de banc et deux cas E2E — la provenance
traverse la couture entre les deux projets, chaque moitié peut être juste
sans que le nom n'arrive jamais.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…igatoire

Obligation du financement LIFE BIODIV'FRANCE : tout outil de communication
porte le bloc marque officiel (UE + LIFE) et, dès qu'il diffuse du contenu,
la clause de non-responsabilité de l'UE dans un encart délimité.

- composant partagé app-eu-funding-notice (bloc marque + mention
  obligatoire + texte facultatif du projet), en pied de page de l'accueil
- remplace les deux logos séparés par le bloc marque officiel
- même encart en fin de README

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Complète be2fc7c, qui ne contenait que la suppression des anciens logos.

- composant partagé app-eu-funding-notice (bloc marque officiel + mention
  UE obligatoire + texte facultatif du projet), en pied de page de l'accueil
- même encart en fin de README

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…programmation (#600)

L'import des actions datait d'avant la refonte : un seul mode de ventilation
(« par type de budget »), l'ancienne case « déclinaison par poste », la colonne
« financé ? ». Une action importée n'était donc pas enregistrée comme par sa
fiche, et ses budgets pouvaient ne pas s'y afficher.

- Onglet Actions : mode de ventilation (6 modes), « déclinaison par type de
  coût » et « saisie automatique du coût salarial » ; déduits des onglets
  Budgets / RH s'ils sont laissés vides.
- Onglet Budgets : budget total, enveloppes ou détail des coûts, par année ou
  par organisme, rangés comme par la fiche action. Une colonne hors du mode
  est une erreur (le montant serait invisible).
- Onglet RH : cible poste / organisme / globale selon le mode, catégorie de
  dépense (#597) ; les anciens fichiers « financé ? » restent lisibles.
- Onglets de référence Postes (nom local #632, homonymes numérotés #611, type
  #633, organisme #599, coût jour) et Organismes (gestionnaires des sites).
- Actions ventilées par organisme rattachées aux sites correspondants.
- Les actions rattachées à l'indicateur par leur seule métrique sont
  exportées et comptent pour le refus « plan déjà rempli ».

Tests : aller-retour export → import pour les 6 modes, règles miroir de
operation-budget.ts, endpoints HTTP de l'import des actions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Plan construit pour le test (arborescence, postes, 3 actions dans 3 modes de
ventilation) → export du classeur → téléversement refusé tant que le plan a
ses actions → réimport par la page Paramètres → actions identiques
(paramétrage, montants par année / organisme, lignes RH). Ajoute les
data-testid du bloc d'import des actions et deux helpers binaires
(apiGetBuffer, apiPostFile).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Version 2.0 en cours de relecture : les passages nouveaux ou modifiés depuis
la v1.0 (juin 2026) sont en violet et la note de relecture est à retirer avant
diffusion. L'outillage de génération (docs/notice/) reste hors dépôt tant
qu'il n'est pas finalisé.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Workflow déclenché à la publication d'une release (et à la main) : backend,
frontend (dépendances de production), hub et tracking-api, en version « prod ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Les merges de branches dans develop suivent la convention « merge: <sujet> dans
develop », que la règle type-enum rejette et qui bloque la PR develop → main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… la fiche action (#668)

La grille mensuelle était posée sur les colonnes des années du plan et
tronquée à la dernière : un plan de 5 ans n'affichait que Janv → Mai.
Elle est désormais repliée (trimestres, semestres, année) selon la place
disponible, sans élargir la feuille.

Closes #668

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ease

Les releases GitHub ne sont plus publiées depuis v0.1.39 : seuls les tags v*
marquent une mise en production, comme pour docker-publish et build-deb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aschlegle
aschlegle merged commit 72a2a50 into main Sep 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant