Jalon : develop vers main, la chaîne complète et l'application sur le serveur #170

Merged
lenaic merged 214 commits from develop into main 2026-09-07 13:50:38 +00:00
Owner

Le serveur est resté au 3 septembre. main porte encore 4a78583, et le déploiement du 07/09 à 13h30 a rejoué ce socle-là : il a réussi, il n'avait simplement rien de neuf à poser.

Cette demande apporte 198 commits, 88 demandes fusionnées, 339 fichiers.

Une action avant de fusionner, et une seule

La clé de signature de l'API n'est dans aucun coffre. vault_api_jwt_secret figure dans vault.yml.example, pas dans vault.yml, qui n'a bougé qu'une fois depuis main et pour le jeton d'alerte du #42.

Sans elle, api.env n'est pas rendu et la pile api est sautée bruyamment, ce qui est le comportement voulu : une API démarrée avec une clé vide accepterait des jetons forgés. Mais cette pile porte deux conteneurs, ev-api et ev-dashboard-build. Ni l'API ni la construction du tableau de bord ne tourneraient, c'est-à-dire précisément ce qu'on veut montrer.

python3 -c "import secrets; print(secrets.token_urlsafe(48))"
ansible-vault edit infra/ansible/group_vars/all/vault.yml
#   vault_api_jwt_secret: "la-valeur"

Le reste du socle part de toute façon : le rôle ne bloque plus sur ce secret, c'est le correctif de la #160.

Ce qui arrive sur le serveur

La chaîne de données, de bout en bout. Zone argent (#123, #143) et zone or (#124, #159) entrent en cron, aux minutes 0, 17 et 27. Les alertes de la source traversent jusqu'à public.alerte (#167, #169). Le rattrapage ne dégrade plus une passe riche (#162).

L'application. API sur les routes L1 à L4 (#137), écrans Parc, Site et Qualité (#139, #140, #155), page de connexion branchée sur l'API réelle (#138).

L'IA. Contrat de prévision H+1 et sa référence publiée (#161), entraînement et promotion du modèle (#166), avec une MAE de 2,018 kW contre 21,443 pour la persistance. Les trois règles de recommandation (#154).

L'exploitation. Cinq alertes notifiées dans la forge (#152), copie des sauvegardes hors serveur (#146), configuration de l'exécuteur versionnée (#145), squelette Terraform et plan de migration chiffré (#150, #163).

Trois migrations s'appliqueront : 0016 agrégats, 0017 référence de prévision, 0018 libellé et état des alertes. La base en est à 15.

Ce qui va démarrer, et ce qui sera sauté

Pile Sort Pourquoi
postgres, minio, mlflow recréées build: true, elles le sont à chaque passage
supervision démarrée le jeton d'alerte est au coffre depuis la #152
api sautée, sauf action ci-dessus pas de vault_api_jwt_secret

app_compose_wait_timeout passe de 180 à 600 s : au premier passage, ev-api construit son environnement Python depuis PyPI et ev-dashboard-build fait un npm ci puis un vite build. Attendez-vous à un déploiement long, cinq à dix minutes.

Ce qui a été vérifié avant d'ouvrir

main sur le serveur    4a78583, identique à la forge, aucune dérive locale
crontab                les trois lignes en place, silver comprise
collecteur             8 passes réussies pendant la bascule de 13h30, 0 erreur
develop                ruff, ruff format et shellcheck propres sur tout le périmètre

Le risque, et la reprise

Le déploiement continu joue --tags app,proxy et recrée PostgreSQL. Le volume survit, les connexions tombent. Le collecteur écrit dans MinIO et non en base : la relève de la minute n'est pas menacée, c'est mesuré sur le déploiement de 13h30.

Si ça tourne mal, main revient à 4a78583 et le déploiement se rejoue : le socle du 3 septembre est reconstructible, il l'a été il y a une heure.

Critères de sortie

  • vault_api_jwt_secret est au coffre, 32 caractères minimum
  • Le déploiement sort en failed=0
  • https://app.g2.enervision/api/v1/sites répond 200, et non 502
  • https://app.g2.enervision sert le tableau de bord
  • Les trois migrations sont appliquées, 0018 comprise
  • Les cinq lignes de crontab sont posées, gold-daily et load-postgres comprises
  • La relève de la minute n'a pas de trou pendant la bascule
Le serveur est resté au **3 septembre**. `main` porte encore `4a78583`, et le déploiement du 07/09 à 13h30 a rejoué ce socle-là : il a réussi, il n'avait simplement rien de neuf à poser. Cette demande apporte **198 commits, 88 demandes fusionnées, 339 fichiers**. ## Une action avant de fusionner, et une seule **La clé de signature de l'API n'est dans aucun coffre.** `vault_api_jwt_secret` figure dans `vault.yml.example`, pas dans `vault.yml`, qui n'a bougé qu'une fois depuis `main` et pour le jeton d'alerte du #42. Sans elle, `api.env` n'est pas rendu et la pile `api` est **sautée bruyamment**, ce qui est le comportement voulu : une API démarrée avec une clé vide accepterait des jetons forgés. Mais cette pile porte **deux** conteneurs, `ev-api` et `ev-dashboard-build`. Ni l'API ni la construction du tableau de bord ne tourneraient, c'est-à-dire précisément ce qu'on veut montrer. ``` python3 -c "import secrets; print(secrets.token_urlsafe(48))" ansible-vault edit infra/ansible/group_vars/all/vault.yml # vault_api_jwt_secret: "la-valeur" ``` Le reste du socle part de toute façon : le rôle ne bloque plus sur ce secret, c'est le correctif de la #160. ## Ce qui arrive sur le serveur **La chaîne de données, de bout en bout.** Zone argent (#123, #143) et zone or (#124, #159) entrent en cron, aux minutes 0, 17 et 27. Les alertes de la source traversent jusqu'à `public.alerte` (#167, #169). Le rattrapage ne dégrade plus une passe riche (#162). **L'application.** API sur les routes L1 à L4 (#137), écrans Parc, Site et Qualité (#139, #140, #155), page de connexion branchée sur l'API réelle (#138). **L'IA.** Contrat de prévision H+1 et sa référence publiée (#161), entraînement et promotion du modèle (#166), avec une MAE de 2,018 kW contre 21,443 pour la persistance. Les trois règles de recommandation (#154). **L'exploitation.** Cinq alertes notifiées dans la forge (#152), copie des sauvegardes hors serveur (#146), configuration de l'exécuteur versionnée (#145), squelette Terraform et plan de migration chiffré (#150, #163). **Trois migrations** s'appliqueront : `0016` agrégats, `0017` référence de prévision, `0018` libellé et état des alertes. La base en est à 15. ## Ce qui va démarrer, et ce qui sera sauté | Pile | Sort | Pourquoi | |---|---|---| | postgres, minio, mlflow | recréées | `build: true`, elles le sont à chaque passage | | supervision | démarrée | le jeton d'alerte est au coffre depuis la #152 | | **api** | **sautée**, sauf action ci-dessus | pas de `vault_api_jwt_secret` | `app_compose_wait_timeout` passe de 180 à 600 s : au premier passage, `ev-api` construit son environnement Python depuis PyPI et `ev-dashboard-build` fait un `npm ci` puis un `vite build`. Attendez-vous à un déploiement long, cinq à dix minutes. ## Ce qui a été vérifié avant d'ouvrir ``` main sur le serveur 4a78583, identique à la forge, aucune dérive locale crontab les trois lignes en place, silver comprise collecteur 8 passes réussies pendant la bascule de 13h30, 0 erreur develop ruff, ruff format et shellcheck propres sur tout le périmètre ``` ## Le risque, et la reprise Le déploiement continu joue `--tags app,proxy` et recrée PostgreSQL. Le volume survit, les connexions tombent. Le collecteur écrit dans MinIO et non en base : la relève de la minute n'est pas menacée, c'est mesuré sur le déploiement de 13h30. Si ça tourne mal, `main` revient à `4a78583` et le déploiement se rejoue : le socle du 3 septembre est reconstructible, il l'a été il y a une heure. ## Critères de sortie - [ ] `vault_api_jwt_secret` est au coffre, 32 caractères minimum - [ ] Le déploiement sort en `failed=0` - [ ] `https://app.g2.enervision/api/v1/sites` répond 200, et non 502 - [ ] `https://app.g2.enervision` sert le tableau de bord - [ ] Les trois migrations sont appliquées, `0018` comprise - [ ] Les cinq lignes de crontab sont posées, `gold-daily` et `load-postgres` comprises - [ ] La relève de la minute n'a pas de trou pendant la bascule
lenaic self-assigned this 2026-09-07 13:48:55 +00:00
[67] Bootstrap de l'état Terraform distant
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 1m39s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m46s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 13s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 20s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m23s
caffe61824
Crée hors Terraform le conteneur qui portera terraform.tfstate : le backend
azurerm est lu avant tout `resource`, il ne peut pas créer son propre support.

Le script s'écarte du ticket sur quatre points, imposés par l'abonnement école :

- le groupe de ressources est rg-FHeuze2023_cours-projet-eadl, pas
  rg-FHeuze-eadl, qui n'existe pas ;
- le compte stenervisiong2tfstate existait déjà et est adopté plutôt que
  doublé : l'étiquette storageaccountnumber du groupe plafonne leur nombre ;
- une politique en effet deny exige une étiquette `user` sur toute ressource,
  reprise de celle du groupe pour que le script serve à chaque membre ;
- le rôle Devops-cours-projet-eadl n'a aucune dataAction : créer le conteneur
  ne donne pas le droit d'y écrire. Storage Blob Data Contributor est donc
  attribué au compte connecté, sans quoi le `terraform init` du #68 échouerait
  en 403 avec tout en place. L'attribution passe par az rest, az role
  assignment échouant en MissingSubscription faute d'accès à Entra ID.

Aucune clé de compte n'est lue : tout passe par le plan de gestion et
l'identité d'az login, comme le demande l'ADR 0007.
[34] Ajout zone argent
Some checks failed
Intégration / Qualité du code Python (pull_request) Failing after 59s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m30s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 13s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 22s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m43s
15f1d7115a
Quatre entrées, dans un service nouveau : `services/etl/` n'existait pas, le
README racine n'attribuait aucun chemin à l'agrégation, et la mettre sous
`services/collector/` aurait fait du collecteur « collecte + imputation +
agrégation + chargement ».

    etl.agregation   zone argent → zone or, `qualite_jour` comprise
    etl.chargement   zone or → `mesure` et `qualite_jour`, en upsert
    etl.export       export de reporting horaire, et sa reconstitution
    etl.lignage      contrôle ENF-07 : remonte un échantillon jusqu'au bronze

**La zone or n'est pas un agrégat de plus.** `public.mesure` porte la même
série au pas de la minute que la zone argent, sur la même clé, sans table de
correspondance : c'est cette identité de bout en bout qui rend l'ENF-07
vérifiable par échantillon. Un agrégat horaire à la place aurait coupé le
lignage à l'endroit exact où l'exigence le demande. Les agrégats sont dérivés,
en base, et c'est la migration 0016 qui les pose.

**Le chargement refuse les jours déjà comprimés.** Un `on conflict do update`
qui retombe dans un fragment comprimé décomprime les segments touchés, pour un
coût sans rapport avec le nombre de lignes chargées — 35 Mo pour 5,4 Mo,
relevé le 3 septembre sur `enervision_preprod`. Recharger un vieux jour reste
possible, avec `--forcer` : c'est un geste délibéré, pas un effet de bord d'un
job de cron. Le message porte la commande de décompression.

**Trois choses que la mise en place a révélées, et qui valent plus que le code.**

1. DuckDB rendait les horodatages dans le fuseau de la machine. Le même jour
   exporté depuis un poste en CEST et depuis le serveur en UTC donnait deux
   fichiers différents — ce qui suffit à casser le « reconstituable à la mesure
   près » du ticket. `SET TimeZone = 'UTC'` est posé dans la connexion.
2. `pytz` est une dépendance que rien n'importe. DuckDB en a besoin pour rendre
   un `TIMESTAMPTZ` en objet Python : sans lui, le job échoue au `fetchall` du
   chargement, donc après l'agrégation, sur une machine où tout paraissait
   installé. Un cas de test garde la dépendance, sinon quelqu'un la retirera
   comme inutilisée.
3. La lecture en partitionnement Hive s'active toute seule dès qu'un segment de
   chemin ressemble à `clé=valeur`, et ajoute `domain`, `table` et `dt` aux
   colonnes du fichier. Le chargement nomme donc ses colonnes une par une.

Le job échoue plutôt que d'écrire une journée douteuse : au-delà de 30 % de
trous (§11), sur une valeur d'énumération que la base refuserait, ou sur plus
de relevés disponibles que la cadence n'en permet — un doublon de zone argent
donnerait sinon un taux de disponibilité au-dessus de 100 %.

62 cas unitaires, 91 % de couverture sur le service, plus la chaîne entière sur
une journée de 1 440 minutes dans `tests/integration/gold/` — le répertoire que
le ticket nomme. Les fixtures de zone argent sont construites en Parquet local
et non versionnées : `.gitignore` exclut `*.parquet`, et un binaire de test que
personne ne peut relire est un binaire que personne ne corrige.

Le job d'agrégation lit le schéma du §10, qui est figé : il tourne donc dès que
le #34 livre une zone argent réelle, sans rien changer ici.
db: matérialise l'agrégat horaire et le taux de charge en 0016 (#35)
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 57s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m25s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 12s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 18s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m33s
Intégration / Aucun secret commité (pull_request) Successful in 2s
f2d273277b
Le §12 de `docs/data/etl-pipeline.md` décrivait la zone or comme trois tables —
`mesure_horaire`, `qualite_jour`, `charge_site`. En base il y en a sept, dont
`mesure` au pas de la minute, et ni `mesure_horaire` ni `charge_site`
n'existaient : le document et la base ne parlaient pas de la même chose, et
« la zone or est écrite dans les hypertables » n'avait pas de cible.

`mesure_horaire` devient un **agrégat continu TimescaleDB**, `charge_site` une
**vue**. Aucun des deux ne porte de donnée propre : ils dérivent de
`public.mesure`, ligne pour ligne.

**Ce qui a été écarté**, et pourquoi : deux tables ordinaires alimentées par le
job de chargement, comme le §12 les décrivait. Elles auraient été uniformes
avec les cinq autres tables du #105, et c'est leur seul avantage. Contre elles,
un agrégat rechargé en même temps que la série peut la contredire le temps
d'une transaction, il faut le rejouer quand on rejoue la série, et il faut
écrire le code qui le fait. TimescaleDB est là depuis le #29 et tient l'agrégat
à jour sans une ligne de Python.

**Le choix est réversible**, et c'est ce qui a permis de le trancher sans
attendre l'arbitrage du 04/09 : les deux objets se suppriment sans rien perdre,
`public.mesure` restant la seule source.

**`WITH NO DATA`, délibérément.** Une migration s'applique au démarrage du
serveur : un `WITH DATA` matérialiserait les 352 807 lignes de `mesure` — en
lisant 28 fragments comprimés — à un moment que personne n'a choisi. La
politique de rafraîchissement remplit l'agrégat à l'heure, en revenant sur trois
jours pour couvrir un rechargement, et en laissant l'heure courante dehors : une
heure en train de se remplir donne une moyenne qui bouge à chaque passage, et un
tableau de bord qui change de valeur à chaque rafraîchissement fait douter du
reste.

**La vue ne classe pas le taux en paliers.** Les quatre paliers du §3 du
glossaire (70 %, 85 %, 95 %) y ont leur source unique ; les recopier dans un
`case` en ferait un second endroit à corriger, et c'est le moteur de règles
versionnées du #39 qui doit les lire. La vue rend le taux, `NULL` quand la
mesure manque — un zéro ferait de chaque panne de capteur le meilleur élève du
parc.

Le `grant` à `grafana` est explicite, comme dans la 0007 et pour la même raison,
mais sous garde d'existence du rôle : un `grant` sec ferait échouer cette
migration, donc toutes les suivantes, sur une base de développement montée à la
main.

Neuf cas relisent le fichier sans base, dont un qui compare la définition de
l'agrégat à celle de l'export de reporting : deux définitions d'un même agrégat
finissent par diverger, et la divergence ne se voit pas — deux chiffres
plausibles, dont un faux.

Le §12 est réécrit sur ce qui est posé, le §14 referme son point ouvert
« chargement gold → PostgreSQL non spécifié », et le tableau d'exposition
Grafana de `docs/POSTGRESQL.md` passe les deux objets au même crible table par
table que les sept du #105.
infra: Caddy sépare app. et grafana. de la forge par nom d'hôte
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 54s
Intégration / Tests unitaires et couverture (pull_request) Successful in 55s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 15s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 18s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m24s
d94ade0d19
Le proxy de la forge tient déjà le 443 ; il sert maintenant trois noms au
lieu d'un, séparés par nom d'hôte et non par port :

- app.g2.enervision : le tableau de bord en statique (page d'attente
  versionnée jusqu'au build #94), /api vers le mandataire FastAPI 8000 ;
- grafana.g2.enervision : Grafana sur 3001 ;
- forge.g2.enervision : inchangé, hormis le nom du fichier de journal.

Réglages communs (TLS interne, compression, en-têtes) factorisés en
snippet. Manuel docs/runbooks/caddy.md : ligne /etc/hosts, rechargement
validé puis à chaud sans couper la forge, preuve curl du ticket. Ligne
/etc/hosts ajoutée au README.
[34] Modification pour qualité Ruff
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 56s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m5s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 14s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 18s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m35s
ab3f262c2a
Merge branch 'develop' into gabriel/32-caddy-noms-hote
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 51s
Intégration / Tests unitaires et couverture (pull_request) Successful in 57s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 13s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 22s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m25s
4c2d602b6e
# Conflicts:
#	docs/adr/README.md
#	docs/data/etl-pipeline.md
[34] Merge develop
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 54s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m2s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 24s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 34s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m55s
Intégration / Aucun secret commité (pull_request) Successful in 3s
89ce7df835
infra: Caddy retire le préfixe /api avant de relayer vers FastAPI
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 48s
Intégration / Tests unitaires et couverture (pull_request) Successful in 52s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 12s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 21s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m25s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m7s
115730a7d2
handle_path au lieu de handle : l'API sert /health et /auth/* à la
racine, sans préfixe. Avec handle, elle recevait /api/health et
répondait 404. Le front vise VITE_API_BASE=.../api en conséquence ;
uvicorn --root-path /api côté pile API (#94) pour que /api/docs charge
ses actifs. caddy.md §3 et §7 corrigés.
Merge branch 'develop' into gabriel/32-caddy-noms-hote
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 48s
Intégration / Tests unitaires et couverture (pull_request) Successful in 53s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 15s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 21s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m27s
Intégration / Aucun secret commité (pull_request) Successful in 3s
672083c2d9
Merge branch 'develop' into justine/34-zone-argent
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 52s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m0s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 12s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 18s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m29s
e9483f9790
Merge pull request '[34] Ajout zone argent' (#123) from justine/34-zone-argent into develop
All checks were successful
Intégration / Qualité du code Python (push) Successful in 51s
Intégration / Tests unitaires et couverture (push) Successful in 1m0s
Intégration / Dépendances du tableau de bord (push) Successful in 13s
Intégration / Images épinglées par version (push) Successful in 3s
Intégration / Tableau de bord (push) Successful in 21s
Intégration / Aucun secret commité (push) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (push) Successful in 1m37s
bcecb81415
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/123
Reviewed-by: marvin <marvin@noreply.10.105.200.41>
Reviewed-by: florian <florian@noreply.10.105.200.41>
Reviewed-by: lenaic <lenaic@noreply.10.105.200.41>
mlflow: épingle anyio, dont une reconstruction a cassé le serveur
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 51s
Intégration / Tests unitaires et couverture (pull_request) Successful in 59s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 11s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 23s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m38s
ee28bbb37e
MLflow était épinglé à 3.15.2, ses dépendances indirectes ne l'étaient pas. Le
3 septembre à 17 h 08, la reconstruction déclenchée par la mise en production a
tiré anyio 4.15.0, passé aux imports paresseux, qui n'expose plus from_thread en
attribut. starlette 1.6.0 l'appelle après un simple « import anyio » : le serveur
démarrait, répondait 500, et restait unhealthy — ce qui a fait échouer le
déploiement, wait: true attendant une pile saine.

L'image tournait « Up 21 hours (healthy) » le matin même avec les mêmes fichiers.
Seules les versions indirectes avaient changé, et c'est précisément ce que le
commentaire en tête du fichier voulait empêcher.

Vérifié dans un conteneur jetable avant de pousser : avec « anyio<4.15 », pip
résout 4.14.2, from_thread redevient accessible et starlette.middleware.wsgi
s'importe.

Reste à trancher, et c'est le #130 : la pile mlflow porte build: true, donc son
image est reconstruite à CHAQUE déploiement. postgres aussi. Épingler ferme ce
trou-ci, pas la classe entière.
Merge pull request 'mlflow: épingle anyio, dont une reconstruction a cassé le serveur' (#131) from lenaic/130-mlflow-anyio into develop
All checks were successful
Intégration / Qualité du code Python (push) Successful in 54s
Intégration / Tests unitaires et couverture (push) Successful in 1m2s
Intégration / Dépendances du tableau de bord (push) Successful in 13s
Intégration / Images épinglées par version (push) Successful in 3s
Intégration / Tableau de bord (push) Successful in 19s
Intégration / Aucun secret commité (push) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (push) Successful in 1m34s
cf9295e515
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/131
Reviewed-by: gabriel <gabriel@noreply.10.105.200.41>
Merge branch 'develop' into gabriel/32-caddy-noms-hote
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 54s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m27s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 4m51s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 5m9s
5c4445dca5
Deux conflits, tous deux nés de la #115 qui a posé grafana.g2.enervision
sur develop pendant que cette branche restructurait le même fichier.

Caddyfile. La structure de cette branche est retenue, les extraits (commun)
et (journal) évitant de répéter les réglages sur chaque hôte. Le bloc grafana
de develop y est fondu plutôt qu'écrasé : son en-tête X-Real-IP est conservé,
il manquait ici, et ses précisions sur la pile de supervision, sur l'absence
de compte par défaut et sur le fait que ce bloc est la seule porte d'entrée
sont reprises dans le commentaire. Le journal reste access-grafana.log, que
« import journal grafana » produit à l'identique.

README de la pile. Le texte de cette branche décrit les trois noms d'hôte et
remplace donc celui de develop, qui annonçait encore app.g2.enervision comme
à venir. Le pointeur vers ../supervision/ que develop apportait est récupéré.

Configuration vérifiée avec « caddy validate » sur l'image caddy:2.8-alpine
avant ce commit : Valid configuration. L'avertissement de formatage en ligne
28 préexiste sur cette branche et n'est pas touché.
docs: manuel d'exploitation pour la perte d'accès SSH au serveur
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 56s
Intégration / Tests unitaires et couverture (pull_request) Successful in 59s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m29s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 3m12s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 3m44s
99f9f37d56
L'incident du 3 septembre a coûté 1 h 34 d'accès administrateur et deux heures
de diagnostic sur trois fausses pistes. Rien de tout cela ne vivait ailleurs
que dans le journal d'équipe, qui est un récit et non un manuel. Ce geste-là
n'avait pas le sien alors que c'est le plus sensible de tous : quand il échoue,
aucun des neuf autres manuels ne se joue.

Le manuel tient en neuf sections et ne suppose aucun accès au serveur pour ses
quatre premières. Il apprend surtout à lire ce que le client ne dit pas :
« Permission denied (publickey) » décrit un refus et jamais un interlocuteur,
et une empreinte de clé d'hôte qui change ne signifie pas que le serveur a
changé les siennes. Une bannière « OpenSSH_10.0 » nue, sans suffixe de
distribution, désigne une image Alpine donc un conteneur, pas le sshd du
système. Un port fermé répond en quelques millisecondes, un port filtré fait
patienter : la confusion entre les deux a produit un faux diagnostic.

Suivent la cause connue et son déclencheur, la mise à jour automatique qui
arrête ssh.service et laisse le conteneur prendre le port, la voie de retour
par l'exécuteur Actions avec la mise en garde qu'elle est le trou du #103, la
remise en état dans l'ordre, et ce qu'il ne faut pas faire.

FORGE.md affirmait que le port 22 de l'hôte n'était pas modifié par le
déploiement. C'était vrai le 31 août et c'est devenu la phrase qui a coûté deux
heures. Elle est corrigée plutôt que retirée, avec le renvoi vers le manuel et
vers la neutralisation posée par la #101.

Toutes les commandes publiées ont été exécutées, sur le serveur ou contre lui.

Ticket #44.
Merge pull request '[32] Caddy : noms d'hôte app. et grafana.' (#125) from gabriel/32-caddy-noms-hote into develop
All checks were successful
Intégration / Qualité du code Python (push) Successful in 52s
Intégration / Tests unitaires et couverture (push) Successful in 1m3s
Intégration / Dépendances Python et inventaire applicatif (push) Successful in 1m28s
Intégration / Images épinglées par version (push) Successful in 4s
Intégration / Tableau de bord (push) Successful in 3m48s
Intégration / Dépendances du tableau de bord (push) Successful in 5m7s
Intégration / Aucun secret commité (push) Successful in 3s
370ba43f2e
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/125
Reviewed-by: lenaic <lenaic@noreply.10.105.200.41>
outillage: la chaîne ne réinstalle plus trois fois la même chose
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m9s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 4m7s
2ea036441f
Sept jobs sur un exécuteur unique ne s'exécutent pas en parallèle, ils font la
queue. Le coût n'était pas dans les contrôles mais dans ce qu'ils refaisaient :
quatre apt-get, trois « pip install -r requirements-dev.txt » sur la même image
épinglée au même sha256, deux « npm ci » sur le même projet, et aucun cache
nulle part. Sept minutes trente sur la demande #125.

Les trois jobs Python n'en font plus qu'un, les deux jobs npm également. Aucune
étape n'est perdue ni réécrite : elles gardent leurs commandes, leurs gardes et
leurs commentaires, seul le préambule est mutualisé. L'ordre est revu pour que
le format et le typage échouent en quelques secondes, les audits en dernier.
S'ajoutent un cache pip et un cache npm, dont les clés portent sur les fichiers
qui décrivent réellement ce qui sera installé.

La protection de branche exige « Intégration / * », un joker : aucun nom de job
n'y est figé, les réunir et les renommer ne casse aucun contrôle obligatoire.

Un cas d'essai est ajouté : les liens relatifs des fichiers Markdown mènent à un
fichier qui existe. Dix manuels se citent les uns les autres et un fichier
renommé casse ces renvois en silence, jusqu'au jour où on en a besoin, c'est-à-
dire en panne. Il examine 111 liens dans 62 fichiers, passe sur develop, et son
échec a été éprouvé sur un lien volontairement cassé. C'est le seul contrôle que
la documentation ait à elle : elle ne saute rien, elle a le sien.

Le job « Images épinglées par version » accueillait déjà trois cas d'essai qui ne
parlaient pas d'images. Il est renommé « Contrôles statiques du dépôt », ce qu'il
est devenu.

Ticket #133, élévation du #40.
Merge pull request 'docs: manuel d'exploitation pour la perte d'accès SSH au serveur (#44)' (#132) from lenaic/44-runbook-acces-serveur into develop
Some checks failed
Intégration / Images épinglées par version (push) Waiting to run
Intégration / Tableau de bord (push) Waiting to run
Intégration / Aucun secret commité (push) Waiting to run
Intégration / Qualité du code Python (push) Successful in 52s
Intégration / Tests unitaires et couverture (push) Successful in 1m6s
Intégration / Dépendances du tableau de bord (push) Has been cancelled
Intégration / Dépendances Python et inventaire applicatif (push) Has been cancelled
5756d08180
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/132
Reviewed-by: olivier <olivier@noreply.10.105.200.41>
Merge pull request 'outillage: la chaîne ne réinstalle plus trois fois la même chose (#133)' (#134) from lenaic/40-ci-plus-rapide into develop
Some checks failed
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m8s
Intégration / Contrôles statiques du dépôt (push) Successful in 4s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Failing after 7m13s
8c2e44238f
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/134
Reviewed-by: olivier <olivier@noreply.10.105.200.41>
Six fichiers de configuration, branchés sur le conteneur d'état du #67. L'état
distant est désormais réel : `terraform plan` affiche « No changes ».

Trois choix méritent d'être relus :

- `main.tf` ne crée aucune ressource — c'est le #43 — mais il n'est pas vide
  pour autant. Un fichier sans rien afficherait « No changes » sans jamais
  joindre Azure : la preuve demandée par le ticket ne vaudrait rien, et un
  défaut d'authentification n'apparaîtrait qu'au premier apply du #43. La
  lecture du groupe de ressources fait travailler le fournisseur à chaque plan.
  Elle sert aussi à reprendre l'étiquette `user` qu'exige la politique de
  l'abonnement, plutôt que de la coder en dur.
- `resource_provider_registrations = "none"` dans `providers.tf`. Par défaut
  azurerm 4 enregistre ses espaces de noms au démarrage, ce qui demande une
  autorisation que le rôle Devops-cours-projet-eadl n'accorde pas. Sans cette
  ligne, chaque commande échoue sur un droit dont on n'a pas besoin :
  l'abonnement école a déjà tout enregistré.
- `use_azuread_auth` dans `backend.tf`, plutôt que la clé de compte. La lire
  supposerait `listKeys`, hors du rôle, et poserait un secret de plus à faire
  tourner (ADR 0007).

Le premier apply n'a créé aucune ressource : il n'écrit que les sorties dans
l'état, jusque-là vide. C'est ce qui rend le critère « plan No changes »
vérifiable. Il a suivi la manœuvre qu'il documente — plan -out, relecture du
plan enregistré, apply de ce plan.

`.gitignore` gagne `tfplan` : un plan enregistré porte les valeurs de l'état,
secrets compris, et rien ne l'en écartait (ENF-12). La négation sur
`*.tfvars.example` passe après `*.tfvars`, faute de quoi elle ne servait à rien.
Premier lot du ticket #94. La zone or n'existe pas encore : les routes du
tableau de bord sont servies par un jeu figé, repris des maquettes et observé à
un instant de référence gelé.

- GET /api/v1/sites et GET /api/v1/sites/{site_id}, session obligatoire
- un site inconnu et un site non autorisé rendent tous deux 404 : un 403
  confirmerait son existence
- dashboard/repository.py prend déjà une connexion en premier argument, comme
  auth/repository.py. Le passage à la vraie base ne changera que des corps de
  fonctions, ni les routeurs ni les schémas de réponse
- le filtrage par site (ENF-02) est isolé dans dashboard/autorisation.py, seul
  endroit à reprendre quand la table d'autorisation existera
- les fixtures ne sont pas dans data/, que .gitignore exclut

Les états des sites ne sont pas posés à la main : un test les recalcule depuis
leurs propres chiffres, et une valeur changée sans sa cohérence fait rougir la
chaîne.

Ruff, mypy strict et 95 tests passent. Couverture globale 86,75 % (seuil 70),
zones sensibles 90 % (seuil 85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZSzujf2cJNhX7gSpqXrNk
Deuxième lot du ticket #94.

- GET /api/v1/parc/synthese : les cinq indicateurs de tête, tous dérivés des
  sites visibles plutôt que posés à la main
- GET /api/v1/parc/mesures?fenetre=24h|7j|30j : la courbe agrégée, le point de
  prévision H+1, la pointe, le creux et le compte de points imputés. Le pas
  découle de la fenêtre, une fenêtre inconnue rend 422
- le filtrage par autorisation passe désormais par un seul helper du routeur,
  les agrégats du parc le respectent comme la liste des sites

Le jeu gagne un profil horaire de parc, les trois alertes ouvertes de la
maquette et l'instant estimé du dépassement de Couëron. Les alertes portent
l'origine « systeme » : nous les produisons, elles ne viennent pas du flux amont
— la maquette les annonçait à tort comme ingérées de la source (EF-05).

La courbe et l'indicateur de tête ne peuvent plus diverger : le profil à 16 h
vaut la consommation instantanée, et un test le vérifie.

Deux écarts avec la maquette, tranchés en faveur du calcul :
- disponibilité moyenne à 98,5 % et non 98,6 % — c'est la moyenne des sept
  sites que la maquette affiche elle-même juste en dessous
- « 1 critique » devient « 1 alerte », pour tenir un seul vocabulaire de
  sévérité entre les sites et les alertes

Ruff, mypy strict et 134 tests passent. Couverture globale 88,91 % (seuil 70),
zones sensibles 90 % (seuil 85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZSzujf2cJNhX7gSpqXrNk
Le jeu était calé sur les maquettes : sept sites de l'agglomération nantaise,
capacités et vocabulaire inventés. docs/GLOSSAIRE.md est la source unique de
ces valeurs, et la PR #106 vient de poser le schéma réel. Tout divergeait.

Référentiel repris du glossaire : SITE001 à SITE007, leurs libellés, leurs
types en anglais et leurs capacités — 4 130 kW au total, contre 6 200 inventés.
Le schéma suit la migration 0007_zone_or_mesure.sql, d'où « site_id » et
« libelle » plutôt que « id » et « nom ».

L'état d'un site n'est plus une règle maison. C'est le taux de charge et les
quatre paliers du glossaire (70 / 85 / 95 %, bornes incluses à gauche). Mes
seuils SEUIL_IMPUTATION_PCT et RETARD_RELEVE_MAX_S disparaissent : ils n'avaient
aucune source, et le glossaire dit qu'un seuil en dur qui n'y figure pas est un
défaut.

La consommation devient nullable, et un site l'exerce : SITE006 a le capteur
muet, donc pas de taux ni de palier. Jamais zéro — un site muet n'est pas un
site au repos. Les agrégats du parc l'excluent et le disent : sites_mesures 6
sur sites_total 7.

Énumérations alignées : types de site en anglais (glossaire §1), sévérités
low/medium/high/critical (§4), types d'alerte threshold/spike/anomaly/outage/
sensor (§4), méthodes d'imputation interpolated/forward_fill/none (ADR 0006,
trois régimes), qualité good/imputed/suspect/critical (migration 0007).

La synthèse expose désormais deux grandeurs de charge, parce qu'elles ne se
confondent pas : consommation_kw est une somme, taux_de_charge_moyen_pct une
moyenne — la seule grandeur comparable entre sites.

Un test vérifie qu'une alerte de seuil porte exactement la sévérité du palier
de son site, un autre que le référentiel n'a pas dérivé du glossaire.

Reste sans source : disponibilite_cible_pct à 99 %, marqué d'un NOTE. À faire
ajouter au glossaire ou à retirer.

Ruff, mypy strict et 159 tests passent. Couverture globale 89,56 % (seuil 70),
zones sensibles 90 % (seuil 85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZSzujf2cJNhX7gSpqXrNk
GET /api/v1/sites/{id}/mesures — un appel par site affiché, comme l'annote
la maquette #24. Chaque site est mis à l'échelle du profil du parc plutôt
que de porter sa propre courbe inventée (aucune donnée historique par site
n'existe encore dans le jeu de démonstration).

Un site sans consommation courante (capteur muet, SITE006) lève
SourceIndisponible côté dépôt, traduite en 503 côté route : le front garde
les autres sites affichés, comme l'état « source indisponible » de la
maquette.

Pas de reference_kw par site : la référence de comparaison (EF-07) n'est
publiée qu'au parc, en inventer une par site afficherait un nombre que
personne n'a calculé.

19 tests neufs, couverture dashboard/ à 100 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnUAobdPeQynk8EnCeMLdW
L3 de #94 : les trois routes qui manquaient à la vue Site.

- SerieSite.reference_kw : la référence du parc (EF-07, méthode de
  persistance) proratisée à l'échelle du site — la même technique, déjà
  revue, qui construit sa série depuis le profil du parc. Pas un chiffre
  inventé.
- GET /sites/{id}/recommandations : fixtures, comme les prévisions — #94
  met explicitement le moteur de règles hors périmètre, pas la route.
  Liste vide si aucune n'a été émise, ce n'est pas une erreur.
- GET /sites/{id}/tracabilite?horodatage=... : rejoue le générateur de
  points de /mesures, donc ne peut jamais diverger de ce que la courbe
  affiche au même instant. 404 sur un instant hors fenêtre.

_site_visible_ou_404 factorisée : la même vérification était dupliquée
trois fois avant cette série de routes, quatre après.

19 tests neufs, couverture dashboard/ toujours à 100 %.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
L4 de #94 — dernier lot de routes de l'écran Qualité :

- GET /qualite/synthese : disponibilité moyenne et sa cible, imputation
  moyenne, sites sous la cible.
- GET /qualite/collecte?site_id=... : trois jours de journal par site
  (migration 0013_zone_or_qualite_jour.sql). Le jour de référence reprend
  exactement disponibilite_pct/imputation_pct de la fiche du site — pas de
  contradiction entre les deux, même principe que la courbe qui rejoint la
  consommation instantanée.
- GET /alertes : ouvertes et résolues, la plus récente d'abord — jusqu'ici
  seules les ouvertes étaient exposées (synthese_parc). alertes_ouvertes()
  devient un sous-ensemble d'alertes(), le filtre n'est plus dupliqué.

Une quatrième alerte fixture (résolue, alr-0004) : sans elle, EtatAlerte.
RESOLUE n'était exercé nulle part dans le jeu. Trois tests existants
ajustés en conséquence (ils verrouillaient « trois alertes, toutes
ouvertes »).

Avec ce lot, #94 (API + fixtures) couvre ses quatre lots (L1 à L4). Reste
sa dernière case : le README des routes.

19 tests neufs (dont 3 ajustés), couverture dashboard/ toujours à 100 %.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dashboard: connexion du back avec le front de la page connxion fait
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m5s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 3s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 25s
02a0d16be0
api : le tableau de bord sert /v1, le préfixe /api revient à Caddy
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 27s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 3s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m29s
27ce5f1266
Le routeur portait « /api/v1 ». Or le Caddyfile relaie en
« handle_path /api/* », qui RETIRE « /api » avant de transmettre : un appel
navigateur « /api/v1/sites » arrivait à l'API en « /v1/sites », que rien ne
servait — 404 en production.

Invisible en développement, où le proxy Vite relaie « /api » sans le retirer :
l'API y recevait bien « /api/v1/sites ». Dev et prod divergeaient en silence,
et les tests, qui tapent l'application directement, ne voyaient ni l'un ni
l'autre.

Le préfixe appartient donc à Caddy, pas à l'API — même convention que « /auth »
et « /health », déjà à la racine pour cette raison. Le front vise
VITE_API_BASE=…/api en regard (corrigé côté tableau de bord).

Au passage, un « f » sans interpolation que ruff refusait dans les tests de
traçabilité.

Refs #94, #24

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6BwMkMyRBYJJYaHheLwGY
Le garde-fou de sortie compare le nombre de trous à `minutes_attendues`, que
`grille_du_jour` fixait à 1440 quelle que soit l'heure. Les minutes pas encore
arrivées comptaient donc comme manquantes : à 7 h du matin le job annonçait
70,1 % de trous sur une collecte pourtant intacte, et refusait d'écrire.

Le lanceur est prévu pour tourner toutes les heures sur la journée courante.
Les deux règles étaient incompatibles : le job ne pouvait jamais réussir avant
17 h UTC, quel que soit l'état des données. La zone argent est restée à zéro
objet depuis le déploiement du #34.

`minutes_attendues(jour, instant)` rend 1440 pour une journée révolue et les
seules minutes écoulées pour la journée en cours. La minute en cours est exclue,
le collecteur relevant à la minute. La normalisation passe par `a_la_minute` et
non par un `astimezone` direct, qui présumerait l'heure locale et rendrait deux
heures de grille de trop sur un serveur en UTC+2.

À minuit pile, aucune minute n'est révolue : la passe sort sans écrire plutôt
que de poser trois tables vides, qui effaceraient le rejeu de la veille. La
tâche planifiée passera par ce cas chaque nuit.

`disponibilite_jour` gagne une colonne `journee_complete`. Sans elle, une ligne
à 32 % de disponibilité se lit comme une journée catastrophique alors qu'elle
est simplement en cours, et un entraînement la prendrait pour une journée finie.

Les contextes de test dataient la passe du jour même, ce qui donnait des grilles
partielles partout. Ils passent au lendemain, situation d'un rejeu, la seule où
la journée compte ses 1440 minutes.

Onze cas ajoutés, dont dix virent au rouge sur le défaut réintroduit. Le
onzième vérifie que borner la grille ne désarme pas le garde-fou.

Ferme #135
Écran Parc du ticket #24 : sept sites, consommation agrégée et son profil
horaire, disponibilité moyenne contre sa cible, alertes ouvertes par
sévérité (SyntheseParc, GET /parc/synthese + /parc/mesures) — au-dessus
de la comparaison multi-sites déjà en place (un appel /sites/{id}/mesures
par site coché, ligne de moyenne, infobulle, source indisponible sans
bloquer les autres, défilement horizontal sur téléphone).

Colonne fraîcheur (dernière relève) ajoutée au tableau des sites.

Reprise de session au chargement (App.vue) : session.reprendre() existait
dans le dépôt sans être jamais appelée — un rechargement de page perdait
une session pourtant valide côté cookie.

Menu du compte dans l'en-tête (EnteteApplication, MenuProfil) : profil,
panneau admin si le rôle l'autorise, déconnexion — qui redirige maintenant
vers /connexion, comme une connexion réussie redirige vers /graphiques.
/profil et /administration sont des pages minimales, sans contenu inventé :
le changement de mot de passe (#21) et la gestion des comptes (#16) restent
à construire, aucun lien mort en attendant.

42 tests neufs (154 au total), palette catégorielle validée par le skill
dataviz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnUAobdPeQynk8EnCeMLdW
Un 401 reçu par un appel du tableau de bord (api/sites.js — le seul module
qui parle à des routes gatées) signale l'expiration au dépôt de session
(signalerExpiration()), qui vide le profil et ouvre ModalReconnexion.

La modale se pose par-dessus la page dans son état incomplet : ni Échap ni
clic sur le fond ne la ferment, piège à focus minimal (Tab/Maj+Tab bouclent
dedans). Deux issues : une reconnexion réussie (le dépôt referme expiree
lui-même, symétrique à connecter()), ou « Retourner à la connexion » qui
abandonne franchement la page plutôt que de laisser croire que tout va
bien. Montée une fois dans EnteteApplication, donc présente sur toutes les
pages authentifiées.

Distinct des 401 que /auth gère déjà (identifiants refusés, rafraîchissement
épuisé au démarrage) : ceux-là restent traités par connecter() et
reprendre(), signalerExpiration() ne couvre que l'expiration en cours de
route.

13 tests neufs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Supprime la route /administration, la page AdministrationView.vue (stub du
ticket #16, sans contenu réel) et son lien conditionnel au rôle dans le
menu du compte. #16 reste ouvert sur la forge, non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
La coquille commune (EnteteApplication) passe de bandeau horizontal
permanent à colonne latérale au delà de 900 px — marque, puis le compte,
puis la navigation. Un seul lien aujourd'hui, « Parc » : le seul écran qui
existe, en ajouter vers des écrans absents inventerait une arborescence que
personne n'a validée.

Fond clair (--ev-paper, celui de la page) plutôt que sombre, sans bordure :
la colonne se fond dans le fond plutôt que de trancher dessus. Le
déclencheur du menu du compte devient une ligne pleine largeur — avatar,
adresse, chevron — au delà du même point de rupture, au lieu du rond
compact du bandeau mobile.

GraphiquesView et ProfilView passent en rangée au même point de rupture
pour que le contenu se place à droite de la colonne plutôt que dessous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Padding, taille du chiffre principal et espacements resserrés — le bloc
prenait plus de la moitié de l'écran pour quatre chiffres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dashboard : les appels du parc visent /v1 et passent par VITE_API_BASE
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m5s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 1m5s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
eeea397e94
sites.js écrivait « /api/v1/… » en dur et ignorait VITE_API_BASE, alors
qu'authentification.js, lui, le lit depuis toujours. Deux conventions dans le
même dossier, et une seule correspond à ce que Caddy attend.

Caddy relaie en « handle_path /api/* », qui RETIRE le préfixe : « /api/v1/sites »
arrivait à l'API en « /v1/sites », qu'elle ne servait pas — 404. Le proxy de
Vite, lui, conservait « /api », donc le développement marchait et la faute ne
serait apparue qu'une fois déployée.

Le routeur du tableau de bord sert désormais « /v1 » (commit précédent), ce
module vise « /v1/… » et préfixe par VITE_API_BASE comme /auth, et le proxy de
Vite relaie « /v1 » au lieu de « /api » pour que les deux environnements
tapent le même chemin.

Au passage, .env.example annonçait qu'une base vide était « le bon réglage »,
y compris en production. C'est faux et silencieux : Caddy ne relaie que
« /api/* », donc « /v1/sites » tombe sur le bloc qui sert la SPA et rend
index.html en 200, que le front lit en JSON. La variable est obligatoire à la
construction de production — dit explicitement, avec la valeur attendue.

Deux tests gardent la régression : l'un vérifie que la base configurée
préfixe bien l'appel, l'autre qu'elle ne préfixe rien par défaut. Aucun test
ne regardait l'origine jusqu'ici, seulement le chemin — c'est pour ça que
personne ne l'a vu.

Refs #24

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6BwMkMyRBYJJYaHheLwGY
dashboard : écran Site — fiche, courbe, recommandations, traçabilité (ticket #24)
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 1m28s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 3s
Intégration / Aucun secret commité (pull_request) Successful in 2s
c49b00821f
Nouvel écran sur /sites/:id : FicheSite (référentiel et état courant),
GraphiqueSite (une série, sa référence en pointillé, prévision H+1 et son
écart), RecommandationsSite, TracabiliteDetail. Quatre widgets, quatre
statuts indépendants (stores/site.js) — une fiche en échec ne bloque pas
la courbe ni les recommandations.

Chaque barre de GraphiqueSite est cliquable : elle charge la traçabilité
de ce point (EF-04, « une valeur jusqu'à la lecture bronze »), affichée
par TracabiliteDetail à côté — pas une route à deviner, le point qu'on
regarde déjà. Les points imputés se distinguent en permanence sur la
courbe (teinte allégée + légende), pas seulement au survol : le manque
laissé sur l'écran Parc est corrigé ici.

Intégration : le nom d'un site dans le tableau du Parc mène désormais à
sa fiche.

Les trois routes ajoutées ici (fiche, recommandations, traçabilité) visent
« /v1/… » comme les précédentes : le préfixe « /api » appartient à Caddy, qui
le retire avant de relayer, et c'est VITE_API_BASE qui le pose.

43 tests neufs (207 au total pour le front), build de production propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6BwMkMyRBYJJYaHheLwGY
[67] Revue de Gabriel : fiabilise le bootstrap de l'état Terraform
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 49s
Intégration / Tests unitaires et couverture (pull_request) Successful in 51s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m46s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 2m31s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 58s
Intégration / Tableau de bord (pull_request) Successful in 5m22s
2fcdb2ee65
Le repli d'extraction de l'OID décodait le payload du jeton comme du base64
standard. C'est du base64url non paddé : « base64 -d » s'arrête au premier
« - » ou « _ », et rend une chaîne vide dès que ce caractère précède l'oid.
Le repli existait précisément pour les postes sans accès à Entra ID, donc
c'était le chemin le plus exercé qui était le plus fragile. Padding rétabli,
alphabet ramené au standard, et le résultat n'est retenu que s'il a la forme
d'un GUID.

L'idempotence de l'attribution de rôle reposait sur une lecture dont toutes
les erreurs étaient avalées : un throttle du plan de gestion faisait conclure
« rien n'est attribué », tenter un PUT, récolter un 409 RoleAssignmentExists,
et annoncer un échec alors que tout était en place. Lecture en échec et
lecture sans résultat sont désormais deux cas distincts — dans le premier,
rien n'est tenté. Un 409 malgré tout est reconnu et rapporté pour ce qu'il
est : le rôle est là.

Le reste suit la même revue : gardes sur l'abonnement et sur l'identifiant du
compte, contrôle du tenant verrouillé par l'ADR 0007 plutôt qu'un « le groupe
n'existe pas » trompeur, comparaisons de booléens insensibles à la casse pour
les CLI qui rendent « True », erreur de réaffirmation qui nomme l'étiquette
« user » quand la politique refuse, et bit exécutable posé.

Le runbook cessait de dire vrai sur un point : le script réaffirme quatre
propriétés, pas l'état complet du compte. Le SKU, le kind et l'étiquette n'en
font pas partie, et c'est délibéré sur un compte adopté.

Vérifié : les cinq branches d'attribution de rôle et les quatre gardes jouées
sur un faux « az », le décodage éprouvé sur un jeton portant un « _ » avant
l'oid — vide avant, correct après — et le script rejoué contre Azure, sortie
inchangée.

Refs #67
infra: planifier la transformation vers la zone argent
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 2m16s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 58s
25363f2350
Le seau `silver` est à zéro objet alors que le code du #34 est fusionné et
déployé, et que bronze en contient 9090. Le job n'était appelé par rien, et une
ligne de crontab posée en l'état aurait échoué à chaque passage.

Trois obstacles, dont deux invisibles tant qu'on ne les cherche pas.

`silver-daily.sh` faisait `exec python3`, là où les lanceurs du collecteur font
`exec "${ENERVISION_PYTHON:-python3}"`. Le python du système n'a pas duckdb : la
tâche serait sortie en ModuleNotFoundError toutes les heures, le même mode de
panne que celui relevé par Olivier en relecture de la #114. Le lanceur vérifie
maintenant ses dépendances avant de partir et sort en 3 avec un message qui
nomme la cause, plutôt qu'une trace d'import au milieu du journal.

La tâche de planification construisait le chemin avec `services/collector/bin/`
en dur, dans la tâche de stat comme dans la ligne de crontab. C'était juste tant
qu'il n'y avait que des collecteurs. Le chemin vient désormais du champ
`service` de la tâche, et `collector_taches` devient `taches_planifiees` : la
liste ne décrit plus le seul collecteur.

L'entrée silver tourne à la minute 0 de chaque heure, sur la journée courante.
Elle suppose le #135, sans lequel la passe refuserait d'écrire avant 17 h.

Le banc d'essai du rôle gagne un contrôle sur ce chemin, éprouvé dans les deux
sens : vert sur le rôle corrigé, rouge sur le chemin en dur réintroduit. Aucun
linter ne voit un chemin littéral qui se trouve être le bon pour deux entrées
sur trois, et c'est exactement ce que ce banc existe pour attraper.

`docs/runbooks/etl.md` dit comment vérifier que ça tourne, comment lire un refus
du garde-fou sans le confondre avec une panne, et comment rejouer une journée.

Ferme #136
etl: la passe de minuit ferme la veille au lieu d'ouvrir le jour
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m8s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 7m15s
9d80715ab7
Trou trouvé en relisant le correctif précédent. La tâche est prévue toutes les
heures sur la journée courante, et la grille s'arrête aux minutes révolues : la
passe de 23 h écrit donc jusqu'à 22 h 59, et celle de minuit porte déjà sur le
jour suivant. La dernière heure de chaque journée n'aurait jamais été écrite.

Minuit est le seul moment où la journée d'avant est complète : c'est là qu'on la
clôt. La règle ne vaut que pour une passe sans `--jour`. Un jour nommé
explicitement n'est jamais réinterprété, le rejeu manuel devant traiter la
journée qu'on lui donne.

La sortie sans écriture reste, pour le cas d'un `--jour` sur le jour même à
minuit, et reste éprouvée.

Quatre cas ajoutés sur le choix de la journée, dont celui d'une horloge en
UTC+2 : 00 h 30 à Paris fait 22 h 30 la veille en UTC, et sans la conversion la
passe traiterait le mauvais jour deux heures par nuit.
- EF-02 / ENF-05 : PR #111 et #99 mergées, statut passé de « relecture » à « fait »
- #113 (issue) cité à la place de #114 (PR) pour le déploiement complet
- ENF-08 : supervision « en cours » (PR #115), tickets #31 #42 #112
- réductions de périmètre rattachées à leurs tickets #116 #117 #118
- #24 recadré : les trois écrans branchés sur l'API ; #94 = API + fixtures
- déploiement continu (#95) livré par la PR #96, retiré du hors-périmètre
- exigences.md (copie skill) : ENF-01/ENF-02 alignées sur l'abandon de Keycloak
docs: BACKLOG — #48 passe « en cours », ajoute les comptes-rendus J1→J4
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m4s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 5m1s
d5cae5e289
Le suivi de projet (#48) n'est plus « pas démarré » : le journal J2/J3 est mergé
(PR #76) et les comptes-rendus d'activité J1→J4, reconstruits de Git et de la
forge, sont ajoutés dans docs/journal/ (j1.md…j4.md).
Le #34 (demande #123, fusionnée depuis) a créé `services/etl/etl/` de son côté.
Cinq fichiers en conflit add/add, et surtout deux dispositions incompatibles :
la zone argent dans `etl/silver/`, la zone or à plat dans `etl/`. Après fusion
on aurait eu `etl/agregation.py` à côté de `etl/silver/`, ce qui ne se lit pas.
Point bloquant n°1 de la relecture de Justine.

Ce que la résolution retient, et que le #34 avait anticipé en sortant
`config.py` du sous-paquet :

- `etl/silver/` + `etl/gold/`, un seul `etl/config.py`, un seul
  `pyproject.toml` réunissant les quatre dépendances (duckdb, minio, psycopg,
  pytz). Le plancher DuckDB est celui de la version épinglée dans
  `requirements-dev.txt` : deux planchers pour un moteur installé se
  contredisent au premier `pip install` ;
- **un seul nom pour le garde-fou de trous** : `ENERVISION_ETL_TAUX_TROUS_MAX`,
  défaut 0,30, pour les deux zones. `TAUX_TROUS_MAXIMAL` disparaît — même
  préfixe, même concept, même défaut et deux noms, un exploitant en aurait
  réglé une et se serait étonné que l'autre job ne bouge pas ;
- les entrées deviennent `python -m etl.gold.<module>` ; lanceurs de cron,
  README du service et §12 du document data suivent ;
- `tests/unit/etl/` devient `tests/unit/gold/`, en regard de
  `tests/unit/silver/`. Son `test_qualite.py` devient `test_qualite_jour.py` :
  deux modules de test de même nom sans `__init__.py` se marchent dessus à la
  collecte, et le message de pytest ne parle pas de doublon de nom.

Les champs propres à la zone or portent un défaut dans `Settings`, pour qu'une
passe argent construite à la main — les doubles de `tests/unit/silver/` — n'ait
pas à les nommer. Les identifiants MinIO restent obligatoires hors racine
locale, comme avant, dans les deux zones.

Un cas garde désormais l'identité des trois énumérations avec ce que
`etl.silver` **produit** (`imputation.py`, `mesure.py`), là où elle n'était
constatée qu'en relecture. Elle l'était déjà avec ce que la migration 0010
accepte : la zone or est tendue entre ces deux bords, et un régime ajouté d'un
seul côté ferait refuser en bloc une journée d'argent parfaitement valide.

Au passage : le tableau d'état du §2 avait gardé trois lignes en double de la
fusion du #123. Dédoublonné, les deux zones y sont en « partiel ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
infra: la configuration de l'exécuteur entre au dépôt
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 57s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 4m54s
5cd6403e67
Elle n'existait qu'en un seul exemplaire, sur le serveur, sous
/opt/g2-forge/data/runner/config.yml — un chemin exclu par .gitignore. C'est
pourtant elle qui force le réseau hôte des conteneurs de tâche, sans quoi rien
ne démarre sur ce LXC où aucun bridge Docker ne monte. Un clone du dépôt sur
une machine neuve donnait donc un exécuteur qui s'enregistre, s'affiche actif,
et ne lance jamais rien : les demandes de fusion restaient « en attente », sans
message. Le dernier geste manuel non documenté du socle, et une entorse à
ENF-09.

Le fichier arrive dans infra/compose/forge/runner-config.yml, à côté de la
composition qui le monte, et le rôle ci_runner l'y dépose. Il est sémantiquement
identique à celui en service, vérifié clé à clé : appliquer ce rôle sur le
serveur actuel ne change rien, ce qui est exactement ce qu'on veut d'un premier
passage.

Le rôle suit ce que fait déjà proxy avec le Caddyfile : il ne possède pas la
pile de la forge, il tient un fichier. Il valide la configuration avant de la
déposer, sauvegarde la précédente, redémarre l'exécuteur et vérifie qu'il est
reparti. Sans différence de contenu, il ne fait rien.

Trois précautions, chacune pour une panne qu'on préfère ne pas vivre. L'exécuteur
ne relit sa configuration qu'au démarrage : le redémarrer tue les conteneurs de
tâche en vol, donc le rôle refuse tant qu'un FORGEJO-ACTIONS-TASK-* tourne
(ci_runner_forcer pour passer outre). L'étiquette « ci » reste hors de
« --tags app,proxy » : le déploiement continu s'exécute sur cet exécuteur et se
couperait lui-même au milieu de son propre job, à chaque fusion sur main. Et
capacity reste à 2 — la monter ne retirerait aucune vague, « python » dominant
seule le chemin critique depuis la #133, mais mettrait trois conteneurs de tâche
de plus sur quatre cœurs déjà chargés.

.runner ne peut pas suivre : il porte le jeton d'enregistrement. Les libellés,
qui y vivent aussi, sont donc déclarés côté rôle et posés à l'enregistrement.
Ils répondent au passage à une question laissée ouverte dans l'en-tête de
ci.yml : le label « docker » vaut node:22-bookworm. Cette image a bien node et
npm, et n'a pas pip — voilà l'explication des exécutions #14 et #15, en échec
silencieux. Ce n'est plus une supposition, et le commentaire le dit.

Un banc d'essai est ajouté, et son échec éprouvé sur cinq régressions
volontaires : réseau repassé en bridge, force_pull à true, cache coupé, capacité
ramenée à 1, étiquette « ci » ajoutée au déploiement continu. Il tourne dans la
tâche « Contrôles statiques du dépôt », donc sur node:22-bookworm : en bash,
grep et awk, sans interpréteur Python.

Le manuel gagne « Reconstruire la chaîne sur une machine vierge », et se
remet à jour au passage — il annonçait encore six tâches et une absence de
cache, deux affirmations que la #133 avait périmées.

Ticket #141.
infra: la copie hors serveur emporte le stockage objet
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 31s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m7s
891cb3f68a
MinIO n'était sauvegardé nulle part. Ni la tâche PostgreSQL de 2 h 30 ni celle
de la forge de 12 h 30 ne le touchent, et l'archive rapatriée sur le poste ne
couvrait que /opt/g2-forge/backups et /var/backups/postgresql. Toute la zone
médaillon, 79 Mo et 9851 objets ce matin, tenait sur un seul disque sans copie.

Ce n'est pas rattrapable après coup : l'API bouchon ne rend pas d'historique au
delà de sa fenêtre, donc une zone bronze perdue est perdue. Le runbook le disait
déjà, « le relevé instantané de 14h32 n'existe qu'une fois ».

Le runbook proposait bien un `mc mirror`, mais vers /srv/enervision/sauvegardes,
c'est-à-dire le même disque que MinIO. Ça protège d'une fausse manoeuvre, pas de
la perte de la machine, et c'était manuel.

MinIO tourne sur un seul disque : la copie du répertoire de données se restaure
telle quelle, `.minio.sys` compris, qui porte la configuration des seaux.

La vérification compte les objets par seau plutôt que de constater la présence
d'un répertoire, qui ne prouverait que la présence d'un répertoire. Seul bronze
est exigé non vide : silver et gold peuvent l'être légitimement tant que la
transformation n'a pas tourné, et faire échouer la copie pour ça apprendrait à
ignorer ses échecs.

`ARCHIVE=<chemin>` vérifie une archive déjà là sans rien retélécharger ni
purger. C'est le geste à faire avant de se fier à une copie ancienne, et c'est
ce qui rend ces contrôles éprouvables.

Éprouvé dans les trois cas, contre le vrai serveur :

  archive du jour        objet bronze : 9851 ... copie vérifiée
  bronze vidé à la main  ÉCHEC : zone bronze vide dans la copie, code 1
  archive du 3 septembre ÉCHEC : le stockage objet est absent, code 1

L'archive passe de 49 à 52 Mo : le JSON de bronze se comprime bien.

L'en-tête documente aussi l'horaire. La copie était planifiée à 18 h 30 et n'a
jamais tourné, le poste étant éteint à cette heure là : aucun journal.log n'a
jamais été écrit, alors que le miroir planifié à midi, lui, tourne. Une
sauvegarde qui dépend d'une machine allumée se planifie quand la machine est
allumée.

Contribue au #92
infra: planifier la copie après le vidage de la forge, pas avant
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m4s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 7m12s
58b4be5677
12 h 15 tombait quinze minutes avant la tâche de la forge, qui vidange à 12 h 30.
La copie aurait donc systématiquement emporté l'archive de la veille, ce que sa
propre vérification signale déjà par « ATTENTION : la plus récente est
dump-<hier>.zip ». Un avertissement quotidien qu'on finit par ne plus lire.

45 12 laisse quinze minutes au vidage, dont l'archive fait 34 Mo.
`tests/conftest.py` disait lui-même qu'« une fabrique recopiée dérive de la
table qu'elle est censée imiter ». C'en était une, et elle avait dérivé : 23
colonnes contre les 38 que `silver.mesure` écrit. Manquaient les quatre
colonnes de température et d'humidité — alors que le commentaire au-dessus
annonçait que les quatre grandeurs explicatives étaient là pour vérifier que la
projection de la zone or les laisse derrière elle, contrôle qui ne portait donc
que sur deux d'entre elles — plus `voltage_v_method`, `power_factor_method`,
`consumption_kwh_imputed`, les trois motifs de rejet, `site_type`,
`capacity_kw` et `silver_run_id`. Point 2 de la relecture de Justine.

Plus rien n'est recopié :

- les colonnes et leurs types viennent de `etl.silver.entrepot.SCHEMAS`, que
  le #34 expose pour cette raison exacte ;
- l'écriture passe par `PuitsDuckDB`, le puits de la zone argent lui-même, dont
  seule la **cible** est détournée vers le disque. Le `CREATE TABLE`, l'ordre
  des colonnes et le `COPY` sont ceux de la production ;
- l'indicateur quotidien est calculé par `silver.qualite.disponibilite_du_jour`.
  La fabrique produit désormais les **deux** tables que la zone or lit,
  `mesure` et `disponibilite_jour`, et pas `journal_capteur` que rien ne lit ;
- `_verifier_couverture` refuse une ligne dont le jeu de colonnes s'écarte du
  schéma. `PuitsDuckDB.ecrire` lit par `ligne.get(nom)` : sans ce garde-fou,
  une colonne ajoutée en argent redeviendrait un NULL silencieux.

Effet de bord attrapé au passage : l'objet écrit porte maintenant, comme en
production, la colonne `dt` **en plus** du segment `dt=` du chemin — l'ancienne
fabrique passait par `PARTITION_BY (dt)`, qui la retire du fichier. DuckDB 1.5
résout ce doublon en faveur de la partition, donc la lecture de la zone or
fonctionne des deux façons ; elle n'était vérifiée que sur celle que la zone
argent n'utilise pas.

Le référentiel des sept sites (type, capacité) est celui de la migration 0009,
et un cas le garde. La consommation du jeu d'essai vaut 60 % de la capacité
déclarée du site : les deux valeurs en dur — 120 kW pour SITE001, 600 pour
SITE002 — étaient déjà exactement cela, mais la règle tient pour les cinq
autres sites, dont deux qu'un forfait de 600 kW aurait mis au-dessus de leur
capacité.

`tests/unit/gold/test_fabrique_argent.py` garde ce qui reste inévitablement
recopié : les noms des familles de grandeurs, comparés à
`etl.silver.grandeurs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge develop dans florian/67-bootstrap-etat-terraform
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m26s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 6m3s
957aa5de57
Un seul conflit, dans docs/runbooks/README.md : les deux côtés ajoutent des
lignes au même endroit du tableau des manuels. Aucune opposition de fond, les
trois entrées sont conservées — terraform-etat.md de cette branche, puis
supervision.md et acces-serveur.md venues de develop.
L'EF-06 était calculé deux fois, sur deux dénominateurs : la grille régularisée
de 1 440 minutes en zone argent (`silver/disponibilite_jour`), les lignes
présentes en zone or (`public.qualite_jour`). Point 3 de la relecture de
Justine. Les deux chiffres se valaient tant que les deux zones s'accordaient,
et le premier changement de règle d'imputation les aurait fait diverger sans que
rien ne le dise.

**Le §10 du document data avait déjà tranché**, écrit par le #34 lui-même : « la
zone or le matérialisera sous `qualite_jour` (§12) sans le recalculer, ce qui
évite deux définitions de la disponibilité qui divergeraient au premier
changement de règle. » C'est donc `disponibilite_jour` qui porte l'exigence, et
la zone or qui la sert.

`gold.agregation` ouvre désormais les **deux** partitions de zone argent, et
`gold.qualite` projette l'indicateur au schéma de la table :

    taux_disponibilite  ← taux_de_disponibilite
    releves_attendus    ← minutes_attendues
    releves_manquants   ← minutes_attendues − mesurees − imputees

Les chiffres ne changent pas, et c'est vérifiable : en argent, une minute non
imputable porte la méthode `none` ; en or, la même ligne porte `critical`. Les
deux définitions du « relevé disponible » coïncident exactement, et le nouveau
contrôle le garde.

**Le caveat disparaît.** Ce module assumait de ne pas voir un site totalement
absent de la zone argent — « le taux de 0 % qu'il mériterait n'apparaît nulle
part ». La grille de la zone argent lui donne ses 1 440 lignes même sans une
seule collecte, ce qu'elle sert précisément à rendre visible : le site muet
arrive maintenant en base à 0 %, et un cas le vérifie.

**Ce qui reste calculé en or**, parce que la zone argent ne le publie pas : la
répartition par méthode. Ses totaux sont confrontés à l'indicateur, site par
site, par une jointure externe des deux côtés — un site présent dans une seule
des deux tables est le cas le plus grave, une jointure interne l'écarterait en
silence. `IndicateurDiscordant` remplace `CadenceIncoherente` et attrape la
même faute (déduplication du §8.2 non jouée, grille plus fine que la cadence)
plus celles que l'ancien contrôle ne voyait pas.

**Deux conséquences, et un défaut corrigé au passage :**

- `ENERVISION_ETL_RELEVES_ATTENDUS` disparaît. Plus personne ne le lisait après
  ce changement, et un réglage que rien ne lit est un piège : on le règle, et
  rien ne bouge. Le dénominateur vient de la seule zone qui sait combien de
  minutes la journée comptait. La fixture `settings_journee` disparaît avec lui
  — elle n'existait que pour désarmer le contrôle de cadence.
- `CadenceIncoherente` n'était **pas** attrapée par `main()` : un exploitant
  aurait eu une trace d'appels et le code 1, là où le README annonce 4.
  `IndicateurDiscordant` l'est.

Les noms de tables de la zone argent sont lus dans `silver.entrepot`, comme le
reste : « mesure » et « disponibilite_jour » écrits en dur dans la zone or
auraient été deux chaînes de plus à tenir à jour à distance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge pull request '[135] La grille de la journée en cours s'arrête aux minutes écoulées' (#142) from lenaic/135-grille-journee-en-cours into develop
Some checks failed
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m8s
Intégration / Contrôles statiques du dépôt (push) Successful in 3s
Intégration / Aucun secret commité (push) Successful in 2s
Intégration / Tableau de bord — dépendances, tests et construction (push) Has been cancelled
14b12735e2
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/142
Reviewed-by: justine <justine@noreply.10.105.200.41>
Merge branch 'develop' into lenaic/136-planifier-silver
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m9s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Has been cancelled
de087a6663
Le critère 2 du ticket demande la latence de lecture d'une fenêtre de 24 h sous
charge légère. Le cas était bien construit — vingt exécutions, p95, relevé
horodaté non versionné — mais son chiffre ne pouvait pas servir de preuve, pour
les trois raisons que Justine a relevées. Elles sont traitées dans l'ordre de
leur importance, qui n'est pas celui où elles se voient.

**1. L'objet mesuré.** La requête recalculait un `time_bucket` sur
`public.mesure`, alors que sa définition est **exactement** celle de
`public.mesure_horaire`, l'agrégat continu que la 0016 livre dans cette même
demande. Un chiffre relevé sur un objet que l'API ne servira pas ne dit rien de
ce que l'utilisateur attendra. Deux relevés sont maintenant exigés, un par vue
du tableau de bord :

    Parc entier, agrégat horaire        public.mesure_horaire
    Un site, série à la minute          public.mesure

Le recalcul direct reste mesuré et imprimé, **sans être exigé** : l'écart entre
lui et l'agrégat est ce qui justifie la migration 0016, et un lecteur du ticket
doit pouvoir le vérifier au lieu de me croire.

**2. Le volume.** 2 880 lignes mettaient le résultat sous la cible d'avance. Le
jeu passe à sept jours et aux sept sites du référentiel, soit 70 560 lignes
chargées par le fichier lui-même — indépendamment de ce que la base porte déjà —
et le relevé imprime les deux comptes : le total de `public.mesure` et la part
qui tombe dans la fenêtre mesurée. Deux journées auraient suffi à ce que la
fenêtre de 24 h soit **pleine**, et c'est un défaut que je n'avais pas vu : elle
commence en milieu de journée d'hier, la version précédente ne chargeait
qu'aujourd'hui, donc ne mesurait que les heures écoulées depuis minuit.

**3. La concurrence.** Quatre lecteurs simultanés, chacun sur sa connexion,
chacun ses vingt exécutions, durées mises en commun. Quatre parce que le
tableau de bord a une poignée d'utilisateurs et que le serveur partage 8 Go :
au-delà on mesurerait la machine.

**Un défaut de la base attrapé en écrivant :**
`refresh_continuous_aggregate` ne s'exécute **pas** dans une transaction, et
psycopg en ouvre une dès le premier ordre. Le cas de concordance de l'agrégat
l'appelait sur la connexion ordinaire : il aurait échoué à la première exécution
contre un vrai PostgreSQL, sur un message qui ne parle pas de transaction. Une
seconde connexion en `autocommit` lui est dédiée.

Un cas s'ajoute au passage, que les sept sites rendent possible : le taux de
charge de **chaque** site reste sous sa capacité déclarée. Tant que le jeu ne
portait que SITE001 et SITE002, un forfait de 600 kW sur SITE006 — 180 kW au
référentiel — aurait donné un taux de 330 % sans que rien ne rougisse.

Ces cas se sautent toujours faute de base joignable depuis ce poste : le chiffre
reste à relever sur la préproduction, une fois les migrations 0008–0016
appliquées (#114). Ce qui est vérifié ici est ce qui peut l'être — la chaîne de
préparation du jeu, jouée à blanc : 70 560 lignes de zone argent construites,
projetées et contrôlées, sept lignes de qualité par jour à 1 440 relevés
attendus et 32 manquants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
db: la 0016 cite l'ADR 0011 au lieu d'appeler une fiche de plus (#35)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m58s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Has been cancelled
e974508aac
La demande posait la question en relecture : le choix de forme des deux agrégats
mérite-t-il une fiche de décision ? Non, et la réponse existait déjà. L'ADR 0011,
introduite par le #34, se termine par « une agrégation pure, sans règle, n'a
aucune raison de quitter le SQL ». C'est exactement le cas de `mesure_horaire`
et de `charge_site` : ils ne portent aucune règle, ils résument.

La distinction est écrite en tête du fichier SQL : le *pourquoi en SQL* est
tracé par la fiche, la *forme* — agrégat continu plutôt que table — ne l'est pas
et n'a pas à l'être, puisqu'elle est réversible. Reste à la confirmer avec qui
portera le #38, ce que le point ouvert du §14 dit maintenant en renvoyant à la
fiche.

Au passage : le titre de la fiche portait encore « ADR 0010 », report de la
renumérotation du #34 — deux fiches s'appelaient 0010 alors que l'index en
distingue bien 0010 et 0011. Corrigé, puisque la migration la cite désormais par
son numéro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into florian/67-bootstrap-etat-terraform
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 22s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 3s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
dd1f52a6e7
Merge pull request '[136] Planifier la transformation vers la zone argent' (#143) from lenaic/136-planifier-silver into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 26s
Intégration / Contrôles statiques du dépôt (push) Successful in 3s
Intégration / Aucun secret commité (push) Successful in 2s
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m4s
Infra Ansible / Playbooks Ansible valides (push) Successful in 2m2s
d043bbe97f
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/143
Reviewed-by: marvin <marvin@noreply.10.105.200.41>
Reviewed-by: gabriel <gabriel@noreply.10.105.200.41>
La revue de Gabriel fait afficher au script les cinq lignes à reporter dans
`backend.tf`, `resource_group_name` compris. Il manquait ici : avec
`use_azuread_auth`, `subscription_id` et `tenant_id` suffisent, et l'`init`
passait sans lui. Mais un membre qui suit la sortie du script à la lettre
obtenait un fichier différent du nôtre, et devait deviner lequel faisait
autorité. Vérifié : init, validate et plan « No changes » avec la ligne.

Elle crée une homonymie, signalée en commentaire : ce groupe-là héberge le
compte de stockage de l'état, partagé par tout le groupe, tandis que celui de
`variables.tf` est l'endroit où chaque membre créera ses propres ressources.
Ils coïncident sur le poste où le compte d'état a été créé, pas ailleurs.

Refs #68
Merge branch 'develop' into florian/67-bootstrap-etat-terraform
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Has been cancelled
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
23ac71b299
Trois routes que le front n'appelait pas encore, et que l'API sert déjà :
la synthèse qualité, le journal de collecte d'un site et les alertes.

« site_id » est un paramètre de requête sur /qualite/collecte, pas un
segment de chemin comme sur toutes les autres routes de ce module. Le
commentaire le dit et un test l'ancre : écrit comme les autres, l'appel
rendrait 404 sans que rien ne l'explique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAFDyP1qfZvdwy8V9Vuz68
Les deux tables vivaient dans TracabiliteDetail. L'écran Qualité ventile
les relevés par la même énumération : les y laisser aurait donné deux
traductions du même mot, qui divergent au premier ajout.

Deux registres, une seule table : « libelleMethode » pour une colonne de
tableau, « descriptionMethode » pour la fiche de traçabilité, où l'on veut
savoir ce qui a été fait à la valeur qu'on regarde (EF-04). Un test tient
les deux registres alignés sur les mêmes clés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAFDyP1qfZvdwy8V9Vuz68
Merge pull request '[67] Bootstrap de l'état Terraform distant' (#121) from florian/67-bootstrap-etat-terraform into develop
Some checks failed
Intégration / Contrôles statiques du dépôt (push) Successful in 4s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Failing after 1m22s
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m8s
eb12df2b19
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/121
Reviewed-by: gabriel <gabriel@noreply.10.105.200.41>
Quatre blocs, quatre statuts indépendants, comme les dépôts du parc et du
site : un écran qui parle de la qualité de la donnée ne peut pas mentir
sur la sienne quand une source ne répond pas.

Le référentiel vient de /v1/sites, appelé pour son propre compte plutôt
que lu dans le dépôt du graphique — celui-là charge les sept séries de
mesures au passage, dont cet écran n'a aucun besoin. « disponibilite_pct »
et « imputation_pct » sont déjà dans SiteOut : aucune route de plus pour
la colonne par site.

Les journaux de collecte se chargent à la demande, un site à la fois. La
garde « déjà chargé » exclut explicitement les entrées en erreur : traiter
une erreur comme un journal laisserait la ligne en panne jusqu'au
rechargement de la page. Un test le tient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAFDyP1qfZvdwy8V9Vuz68
Merge branch 'develop' into gabriel/realignement-forge-prd
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m7s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 7m15s
29ae0ced6c
Les deux blocs qui portent le critère « disponibilité par site, part de
points imputés » du ticket. Aucune route de plus : disponibilite_pct et
imputation_pct sont déjà dans SiteOut.

Le tableau trie du pire au meilleur — un écran de qualité montre d'abord
ce qui ne va pas — et le dit dans sa légende, sinon la première ligne
passe pour le premier site du référentiel.

« Sous la cible » se lit en toutes lettres à côté de la puce, jamais par
la couleur seule (RGAA, critère du ticket). Le seuil vient de l'API, il
n'est pas recalculé ici.

La disponibilité prend une couleur de statut parce qu'elle a une cible
publiée ; la part de points reconstitués reste en encre neutre, faute de
cible à laquelle la comparer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deux lots de la zone argent sont passés pendant la relecture, et les deux
touchent ce que la zone or lit :

- **#135** — la grille de la journée en cours s'arrête aux minutes révolues, et
  la passe de minuit clôt la veille. `disponibilite_jour` gagne une colonne
  `journee_complete` ;
- **#136** — la transformation vers la zone argent est planifiée par le rôle
  Ansible `app`, dont la liste de tâches sait désormais porter un service autre
  que le collecteur.

Sans conflit, et la fabrique de zone argent a absorbé la nouvelle colonne sans
qu'une ligne de test change : c'est exactement ce que la dérivation du schéma
(commit `fc453cf`) devait produire. Les 442 cas unitaires passent.

Le raccordement des deux zones dans le temps, lui, demande un correctif — voir
le commit suivant.
etl: la zone or prend la journée que la zone argent a écrite, pas « aujourd'hui » (#35)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m55s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 7m17s
dcc76db10f
Le #135 a changé la façon dont la zone argent choisit sa journée : sa grille
s'arrête aux minutes révolues, et sa passe de minuit **clôt la veille** au lieu
d'ouvrir le jour. Mes lanceurs, eux, passaient `date -u +%F`. Le raccordement
des deux zones se serait donc trompé deux fois par nuit :

- **à 00 h 17, la partition du jour n'existe pas encore.** La zone argent ne
  l'écrira qu'à 01 h 00 : `gold_daily` sortirait en « partition absente »
  chaque nuit, code 3, dans un journal que personne ne lit tant qu'un tableau
  de bord n'est pas vide ;
- **la veille ne serait jamais reprise complète.** La dernière passe portant sa
  date a tourné à 23 h 17, sur une grille arrêtée à 22 h 59. La dernière heure
  de chaque journée n'atteindrait jamais la zone or, et `qualite_jour` garderait
  pour toujours un `releves_attendus` de 1 380 — un chiffre plausible, donc
  invisible. Un point de surveillance est ajouté au §13 pour ce cas précis.

`etl/gold/jour.py` porte la règle pour les quatre entrées : la journée demandée,
sinon la dernière que la zone argent a écrite. Elle n'est pas recopiée de la
zone argent, elle lui est **demandée** — `silver.grille.minutes_attendues` est
la seule à savoir à quelle heure une journée n'a encore aucune minute révolue,
et si ce seuil change la zone or suit.

En Python et non dans les lanceurs, pour la raison qui a fait sortir la même
règle du shell côté zone argent : une règle qui décide d'une journée mérite des
cas d'essai, et `tests/unit/gold/test_jour.py` en donne six à horloge fixe,
minuit et fuseau local compris. `--jour` devient donc facultatif sur les quatre
entrées, et les lanceurs ne calculent plus de date.

**Les deux lanceurs adoptent aussi le garde-fou du #136** : le python du venv
par `ENERVISION_PYTHON`, et un contrôle des clients avant de lancer le job.
`gold-daily` veut DuckDB, `load-postgres` DuckDB et psycopg — tous deux importés
tardivement pour que les tests tournent sans eux, donc leur absence ressortait
en trace brute au milieu du job, avec le code 1. C'est le mode de panne déjà
relevé sur le collecteur (#114) et sur la zone argent (#136).

Enfin, ce que la journée en cours donne dans `qualite_jour` est écrit au §12 :
`releves_attendus` vaut les minutes écoulées, et 1 440 après la passe de minuit.
Le drapeau `journee_complete` que la zone argent publie n'est pas repris en base
— la migration 0013 n'a pas la colonne, et `releves_attendus < 1440` le dit
déjà. À trancher avec qui portera le #38 : ce serait une 0017, pas ce lot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La vue, la route « /qualite » et son onglet dans l'en-tête.

Deux onglets désormais, Parc et Qualité. L'écran Site n'en a pas : on y
entre par un site nommé, jamais dans l'absolu, et un onglet qui
demanderait « lequel » n'aurait rien à ouvrir.

Le journal de collecte et les alertes viennent ensuite. Les deux blocs
livrés couvrent déjà « disponibilité par site, part de points imputés »
du ticket, et un écran atteignable vaut mieux qu'un écran complet mais
invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dashboard : une session expirée n'affiche plus une page en panne (#24)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
bf3402e36d
Derrière la modale de reprise, la page se couvrait de messages rouges —
« indicateurs du parc indisponibles », « impossible de charger les
sites » — alors que rien n'était en panne : les appels avaient reçu un
401, la session avait expiré. Ces messages proposaient de réessayer ce
qui ne pouvait pas revenir avant une reconnexion, et faisaient passer une
déconnexion pour une avarie.

« estExpiration » classe l'échec dans api/erreur.js, et chaque dépôt s'en
sert pour se taire plutôt que marquer une erreur : le bloc repart à
« attente », les séries et journaux concernés sont retirés. La page ne
rend plus rien derrière la modale.

Second défaut, plus discret : une reconnexion réussie refermait la modale
sur cette page vidée, sans rien recharger — écran mort, sans autre issue
qu'un rechargement à la main. « surRepriseDeSession » recharge l'écran
courant quand l'expiration est levée ET qu'une identité est revenue. La
seconde condition écarte « Retourner à la connexion », qui lève
l'expiration sans session derrière : recharger là relancerait un appel,
donc un 401, sur une page qu'on quitte.

Un test du dépôt du parc prenait un 401 comme exemple de panne : il passe
à 503, et la règle nouvelle a le sien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge pull request 'docs: réaligne PRD et BACKLOG sur l'état réel de la forge, ajoute les comptes-rendus J1→J4 (#48)' (#144) from gabriel/realignement-forge-prd into develop
Some checks failed
Intégration / Contrôles statiques du dépôt (push) Successful in 4s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m5s
Intégration / Tableau de bord — dépendances, tests et construction (push) Failing after 7m15s
02727f8695
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/144
Reviewed-by: olivier <olivier@noreply.10.105.200.41>
[68] PULL_REQUEST_TEMPLATE.md Suprression des modifications
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 3s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 5m17s
5dd4be7f4c
outillage: borner, découper et garder bloquant le job du tableau de bord
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 1m45s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m11s
52eed13849
Le job échouait cinq fois sur treize, toujours à 433-435 s. Ce n'est pas un
travail à durée variable, c'est la signature d'un délai réseau avec réessais :
npm attend 300 000 ms par tentative et réessaie deux fois, et rien dans le dépôt
ne bornait ça. Un ralentissement de la salle bloquait la fusion de tout le monde
pendant sept minutes, avec un message final qui ne parlait pas de réseau.

Les bornes entrent dans services/dashboard/.npmrc plutôt qu'en option sur une
ligne de commande : la chaîne, un poste et un conteneur doivent se comporter
pareil, sans quoi personne ne reproduit l'incident. 60 s par tentative, deux
réessais, trois minutes au pire contre quinze.

Trois autres défauts vivaient au même endroit.

Une seule étape enchaînait installation, audit et inventaire, donc le journal
n'imputait rien : le coût a longtemps été mis sur le dos de `npm ci`, qui prend
moins d'une seconde. Elles sont séparées, et l'audit affiche sa durée. Le
chronomètre passe par `date` et non `$SECONDS`, que dash ne connaît pas, et le
code de retour est capté par `|| code=$?`, sans quoi `sh -e` sortirait avant la
ligne qui l'affiche.

Le cache portait sur /root/.npm avec un repli `npm-`. Il porte maintenant sur
node_modules, avec une clé exacte et sans repli : un arbre construit pour un
autre verrou passe les tests et casse ailleurs. La clé porte aussi la version de
l'image, un arbre npm contenant des binaires compilés.

Le job installait openssl par apt à chaque passage, pour contrôler l'expiration
du CA local que le job Python contrôle déjà, sur le même fichier, dans la même
exécution. Plus aucun apt-get. Au passage, NODE_EXTRA_CA_CERTS était posé APRÈS
les commandes npm qui en avaient besoin.

Et l'inventaire ne s'exécutait plus du tout depuis que l'audit échouait : la
même étape enchaînait les deux. On perdait la nomenclature sans le remarquer.

`tests/ci/test-chaine-tableau-de-bord.sh` interdit le retour de chacun de ces
défauts. Éprouvé dans les deux sens : vert sur le fichier sain, rouge sur les
sept défauts réintroduits un par un, y compris un `npm audit || true`.

Le contrôle qui compte est le dernier. Borner un appel réseau ne doit pas
devenir « ignorer les vulnérabilités » : `npm audit --audit-level=high` reste
bloquant, et le banc refuse aussi bien sa disparition que sa neutralisation.

docs/runbooks/ci.md décrivait encore les six tâches d'avant le #133. Réaligné
sur quatre, avec un tableau pour distinguer en une ligne un échec d'audit dû à
une CVE d'un échec dû au registre.

Ferme #148
Merge pull request '[92] La copie hors serveur emporte le stockage objet' (#146) from lenaic/92-sauvegarde-minio into develop
All checks were successful
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m3s
Intégration / Contrôles statiques du dépôt (push) Successful in 4s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 4m7s
d5d5aa7316
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/146
Reviewed-by: olivier <olivier@noreply.10.105.200.41>
api : root_path, lanceur bin/api et README complet (relecture #137)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 3s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m34s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 5m15s
c98ac01594
Trois points relevés en relecture de la PR #137.

root_path. Le correctif du préfixe ne portait que sur l'entrée : Caddy retire
« /api » (handle_path), les routeurs sont donc à la racine. Mais FastAPI émet
aussi des URL vers lui-même, préfixées par root_path — dont le /openapi.json
que charge la page /docs. Sans réglage, .../api/docs demandait
.../openapi.json et n'affichait rien. Nouveau réglage ENERVISION_API_ROOT_PATH,
vide par défaut, « /api » en production ; le Caddyfile et le runbook, qui
parlaient de « --root-path » en ligne de commande, sont alignés dessus.

bin/api et bin/api.ps1. Le ticket #94 les cite dans « comment on vérifie » et
« à savoir », ils n'existaient pas. Une seule sonde Python partagée
(bin/_sonde_api.py) : configuration illisible, base joignable ou non. Sans
base l'API démarre quand même — les migrations de démarrage sont coupées —
et le script dit que seul /health répondra.

README. Le tableau des routes du tableau de bord s'arrêtait au lot L2 : 4 des
10 routes. Les six autres sont ajoutées, groupées par écran, et la mise en
route se fait désormais sur le jeu de démonstration via bin/api.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017t9ACoY2UR7Hv6JieW1Uqh
outillage: corriger la relecture de la #149
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 27s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m5s
472ec346ef
Trois points bloquants, tous justes.

L'inventaire n'était pas réparé. Le diagnostic était bon, le correctif ne
l'était pas : séparer les étapes ne change rien, une étape en échec interrompt
les suivantes. Placé après l'audit, l'inventaire restait sauté exactement dans
le cas qui nous occupe. Il passe avant, plutôt qu'un `if: always()` qui le
produirait aussi quand l'installation a échoué et qu'il n'y a rien à inventorier.

La demande ne corrigeait pas la panne observée. Un 503 de npmjs mettait toujours
toute l'équipe au rouge, en trois minutes au lieu de sept, et une demande qui ne
touche que du Python restait bloquée sans que personne puisse rien y corriger.
« npm a trouvé une faille » et « npm n'a pas pu joindre son registre » rendaient
le même code de sortie. Le JSON les distingue, une panne portant un champ
`error` : une CVE bloque, une indisponibilité avertit, et le message dit que les
dépendances ne sont PAS contrôlées sur ce passage. Un JSON illisible échoue,
plutôt que de passer pour un registre en panne.

Le cache changeait de nature en mal. Le magasin /root/.npm est adressé par
contenu et vérifié par empreinte, donc un repli y est sûr, alors qu'il ne l'est
pas sur l'arbre installé. En supprimant les deux, tout changement de verrou
repartait chercher 244 paquets sur le registre qui rend justement des 503, et
sur les demandes les plus lentes. Les deux caches coexistent, chacun avec sa
règle.

Le banc gagne quatre contrôles, dont deux qu'il n'aurait pas dû ne pas avoir :
l'ordre des étapes, qui était la cause du cinquième défaut, et le décompte des
vulnérabilités, qui est le verrou réel.

Ce dernier vient d'une injection restée verte. La garde cherchait
`--audit-level=high` n'importe où dans le job, et le trouvait dans le chemin
d'échec alors que la commande décisive l'avait perdu. Elle porte maintenant sur
la ligne qui produit le JSON, et sur le décompte lui-même.

Onze défauts réintroduits un par un, onze rouges, vert sur le fichier sain.

Bornes de lecture insensibles à l'ordre des jobs, lecture du délai qui survit à
un commentaire de fin de ligne, et refus d'un jeton dans le .npmrc versionné,
qui est l'endroit canonique où en atterrit un.
outillage: le verdict d'audit sort du YAML et devient éprouvable
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 2m20s
0f32ffd938
Relecture d'Olivier sur la #149. Trois points bloquants, tous justes, et le
remède du troisième commandait les deux autres.

Le banc restait vert sur trois désarmements qu'il annonçait refuser. Un
`continue-on-error: true` sur l'étape d'audit, qui est la neutralisation
idiomatique d'Actions et dont la clé figure déjà deux fois dans le même job. Un
`restore-keys` posé au-dessus de `key:`, l'ordre des clés d'un mapping YAML
étant libre alors que mon awk ne balayait que les lignes d'après. Et surtout un
`process.exit(0)` gardant `v.high` dans un affichage : le banc cherchait les
sous-chaînes, pas leur usage dans la décision.

Le verdict quitte donc le workflow pour .forgejo/scripts/verdict-audit-npm.js,
et tests/ci/test-verdict-audit-npm.sh l'EXÉCUTE contre sept rapports figés :
sain, moderée seule, CVE haute, CVE critique, registre injoignable, rapport
tronqué, rapport sans décompte. Une assertion sur du texte ne dit rien de ce que
le code décide.

`--audit-level=high` cesse d'être surveillé sur la commande qui produit le JSON.
L'option n'agit que sur le code de sortie de npm, code que l'étape jette
puisqu'elle recalcule son verdict : la garde protégeait un drapeau sans effet, et
faisait croire protégé ce qui ne l'était pas. Elle reste dans le chemin d'échec,
où elle sert à réafficher le rapport en clair.

Le pire cas n'est pas trois minutes mais environ 3 min 55 s : trois requêtes de
60 s plus 5 s et 50 s d'attente entre essais, le facteur de npm valant 10.
Corrigé dans le .npmrc et dans le manuel. Le banc borne maintenant
`fetch-retries`, dont il ne vérifiait que la présence : `fetch-retries=50` le
laissait vert pour un pire cas d'environ cinquante minutes.

Deux trous trouvés en éprouvant les correctifs eux-mêmes. La garde sur la
délégation trouvait le nom du script dans le commentaire de l'étape, comme celle
sur apt-get avant elle. Et chercher un seul « exit 1 » ne suffisait pas : en
retirant celui de la branche des vulnérabilités, celui de la branche « rapport
illisible » subsistait. Le banc compare maintenant le nombre d'erreurs annoncées
au nombre de sorties en échec, et exige une branche par défaut.

Dix-huit défauts réintroduits un par un, dix-huit rouges, vert sur l'arbre sain.

Broutilles reprises : `[[:space:]]` au lieu de `\s`, ancre de fetch-retries
alignée sur celle de fetch-timeout, apt-get cherché hors des commentaires.
infra: corriger la relecture de la #145
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m22s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m10s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 5m32s
527afd4377
Deux défauts, deux mineurs, et un périmètre resserré.

Le handler redémarrait par `docker restart`, qui échoue sur une cible neuve où
la pile forge est clonée mais pas encore démarrée : le conteneur n'existe pas
encore, et la situation est pourtant normale. Compose part de la déclaration,
pas de l'existant, et c'est la voie qu'emprunte le reste du projet.

Les assertions de conformité lisaient des clés sans `is defined`. Une clé
absente de runner-config.yml levait une erreur « undefined » au lieu de rendre
le fail_msg, si bien que l'exploitant lisait une trace Jinja plutôt que la
marche à suivre, au moment où il en a le plus besoin.

Le banc surveille maintenant les deux, et rien ne les surveillait. Éprouvé dans
les deux sens : rouge sur `docker restart` remis, rouge sur un `is defined`
retiré, vert sur le rôle corrigé. Le contrôle sur les conditions a d'ailleurs
trouvé un cas que j'avais laissé, `capacity | int >= 2` sur sa propre ligne sans
garde.

Le périmètre de `docs/runbooks/ci.md` tombe de 150 lignes à 85, et ne porte plus
que la section « Reconstruire la chaîne sur une machine vierge ». Le reste était
le rattrapage documentaire de la #133, sans lien avec l'exécuteur : il est
traité par la #149, et c'est ce qui causait le conflit entre les deux demandes.

Mineurs : le contrôle sur l'étiquette `ci` couvre aussi la forme courte `-t`, et
l'en-tête du banc dit sa dépendance à git, absent de l'image node *slim*.

`min_ansible_version: "2.17"` est aligné sur les huit rôles, vérifié.

Sur le quatrième point, le chemin Ansible des assertions : je n'ai pas pu
l'éprouver en l'exécutant. Un banc qui lirait ces conditions dans le rôle pour
les faire évaluer se heurte au modèle de confiance d'ansible-core depuis 2.19,
qui refuse d'évaluer comme gabarit une chaîne lue dans un fichier, et le filtre
prévu pour lever ce marquage n'est pas exposé en 2.21.3. La garde est donc
statique, et le banc le dit plutôt que de le laisser croire.
docs: inscrire la décision sur l'audit npm en panne de registre
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 1m18s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m3s
b755aef111
Tranché le 4 septembre : la chaîne passe au vert avec un avertissement quand le
registre npm ne répond pas, plutôt que de bloquer toute l'équipe. Une vraie
faille bloque toujours.

Ce qu'on accepte est écrit : une dépendance vulnérable introduite pendant une
panne passe, et n'est rattrapée qu'au passage suivant. Une décision qui ne vit
que dans le code n'est pas une décision.
Merge branch 'develop' into olivier/35-etl-zone-or
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 23s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m54s
57bfa491eb
supervision: cinq alertes d'exploitation, notifiées dans la forge (#42)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 22s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m6s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m10s
87f1b47cf4
Moteur : l'alerting unifié de Grafana, pas d'Alertmanager. Cinq règles
provisionnées par fichier (grafana/provisioning/alerting/), évaluées sur
Prometheus. Canal unique : un webhook vers un petit relais qui ouvre une
issue dans la forge, assignée au destinataire nommé par la règle, et la
referme à la résolution. Réseau clos : la forge est la seule destination,
pas de relais SMTP.

Les cinq alertes : collecte arrêtée, disque bientôt plein, API source
lente ou muette (sonde blackbox), cible de supervision tombée, sauvegarde
PostgreSQL en échec. Cette dernière ferme l'écart n°4 de POSTGRESQL.md
(« aucune alerte sur échec — attend Prometheus »).

- collecte-current.sh et pg-backup.sh déposent des métriques ev_ops_*
  dans /var/lib/node_exporter/textfile, que node-exporter republie
- blackbox-exporter ajouté à la pile (127.0.0.1:9115) ; job blackbox
  dans prometheus.yml sonde l'API source, la forge et Grafana
- relais-forge : bibliothèque standard, image python montée telle quelle,
  jeton du coffre (vault_forge_alerte_token, portée « issue »)
- rôle app : création du répertoire textfile, garde du jeton dans l'assert
- test-supervision.sh : cinq règles, destinataire nommé, point de contact
  webhook, aucun jeton en clair, YAML du provisioning valide
- runbook supervision.md § 7 : tableau des cinq alertes, déclenchement
  réel pour la preuve ENF-08, réglage des seuils, pannes du relais
Merge pull request 'etl : agrégation et matérialisation de la zone or, export et lignage (#35)' (#124) from olivier/35-etl-zone-or into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 4s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 23s
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m49s
0c5df7d7e1
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/124
Reviewed-by: justine <justine@noreply.10.105.200.41>
Merge branch 'develop' into marvin/94-api-tableau-de-bord
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 2m18s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 7m14s
317ad14fc3
Le dépôt les chargeait déjà et rien ne les affichait : à chaque arrivée
sur /qualite, l'appel partait, réussissait, et le résultat était jeté. On
avait le nombre d'alertes sur l'écran Parc, jamais lesquelles.

Ouvertes d'abord, résolues ensuite, et dans chaque groupe la plus sévère
en tête : une liste d'alertes se lit par ce qui appelle une action, pas
par ordre d'arrivée. Le tri groupe par-dessus l'ordre de l'API, donc deux
alertes de même sévérité gardent leur ordre chronologique.

Les résolues restent affichées, en retrait plutôt que masquées : une
liste qui rétrécit sans qu'on sache ce qui en est sorti se lit mal, et la
trace de ce qui s'est résolu seul a sa valeur sur un écran de qualité.

Chaque alerte nomme sa sévérité en toutes lettres à côté de la puce, et
porte l'exigence qu'elle sert. Une alerte sans site_id concerne le parc
entier : elle le dit, plutôt que d'afficher un lien mort.

Les libellés de type, d'origine et d'état rejoignent le glossaire, repris
mot pour mot de docs/GLOSSAIRE.md §4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
outillage: le banc garde la condition et le fil du verdict de l'audit
All checks were successful
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 2m18s
1198fb73c8
Deux désarmements passaient encore au vert sur les deux bancs, relecture de
la #149.

UNE ÉTAPE SAUTÉE EST UNE ÉTAPE VERTE. Rien ne contrôlait la condition de
l'étape d'audit : `if: false`, `== 'non'` au lieu de `== 'oui'`, et surtout
un `&& github.ref == 'refs/heads/develop'` ajouté à la condition laissaient
les bancs verts en supprimant l'audit. Le troisième est le plus vraisemblable
— « on n'audite que sur develop pour accélérer les demandes » — et il retire
l'audit précisément là où il sert, sur les demandes de fusion. La condition
doit maintenant être exactement celle des autres étapes du tableau de bord.

LE FIL ENTRE LE VERDICT ET L'ÉCHEC DE L'ÉTAPE. Éprouver ce que le script
décide ne sert à rien si sa décision n'est pas branchée : `|| true` au lieu
de `|| verdict=$?`, ou une réaffectation de `verdict` après la capture,
rendaient l'étape verte avec une CVE haute, script et fixtures intacts.
C'est le `|| true` contre lequel tout le fichier met en garde, remonté d'un
cran.

Huit défauts réintroduits un par un, huit rouges ; les six contrôles déjà en
place restent rouges sur leurs propres injections, et l'arbre sain reste vert.

Au passage, quatrième occurrence du même piège : la première écriture de la
garde plaçait sa fenêtre d'analyse sur le commentaire qui nomme le script, en
amont de l'initialisation `verdict=0`, et rougissait l'arbre sain. Les deux
nouvelles gardes ne lisent que les lignes exécutées.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le #39 exige que le contenu métier des trois règles vienne d'un ticket de
spécification. Ce ticket n'existe pas : ni seuils, ni conditions de
déclenchement, ni formulations. Le catalogue est donc vide, et le rester est
délibéré : écrire ici des règles inventées reviendrait à livrer un jugement
métier sous couvert de code.

Ce qui n'en dépend pas est fait, et éprouvé.

Le contrat d'entrée d'abord, parce qu'il décide du reste. Les règles ne vont pas
chercher leurs données, on les leur apporte : sans ça, trois règles liraient la
même grandeur de trois façons. C'est aussi ce qui rend le module indépendant de
la zone or et du service d'inférence, qui n'existent pas encore.

Trois verdicts et non deux. Une règle rend un déclenchement, un silence, ou une
INDÉCISION en nommant ce qui lui a manqué. Le troisième est celui qu'on oublie :
sans lui, « je n'ai pas les données » et « tout va bien » rendent la même chose,
et un site muet passe pour un site sain. C'est la distinction que la zone argent
fait entre une minute jamais collectée et une valeur nulle remontée.

La traçabilité que demande l'EF-09 tient dans la sortie : la règle, sa version,
l'instant de la donnée, celui de l'évaluation, et les valeurs déclenchantes. Les
deux instants sont distincts, les confondre rendrait un rejeu indiscernable de
la passe d'origine.

Le moteur reçoit son horloge au lieu de la prendre, comme le job de la zone
argent, sans quoi rien ne serait éprouvable. Une règle qui lève une exception
n'emporte pas les autres, mais rien n'est avalé : défaillances et indécisions
ressortent dans le résultat. L'ordre de sortie est stable.

Le module vit à part plutôt que dans `enervision_api/rules`, où la chaîne
l'attendait. Les règles n'ont besoin de rien de l'API, et les y enfermer
obligerait à monter l'API pour les éprouver. Le chemin de l'API reste déclaré en
zone sensible, vide et sauté tant qu'il l'est.

37 cas, couverture à 100 % pour un palier exigé à 85 %. mypy strict et ruff
propres.

Contribue au #39
Merge pull request '[148] Le job du tableau de bord : borné, découpé, et toujours bloquant' (#149) from lenaic/148-chaine-tableau-de-bord into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 20s
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Aucun secret commité (push) Successful in 2s
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m54s
75b6b94402
Merge branch 'develop' into marvin/94-api-tableau-de-bord
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 2m28s
e595684de0
Le verrou ne portait les empreintes que d'une plateforme. Un coéquipier sur un
autre OS le réécrivait à son `init` — churn et conflits de fusion — et le
`-lockfile=readonly` que prépare le #72 aurait échoué en chaîne. Regénéré pour
linux_amd64, darwin_arm64, darwin_amd64 et windows_amd64 ; quatre empreintes au
lieu d'une, et `init -lockfile=readonly` passe désormais.

`subscription_id` prend un défaut. Il est constant pour tout le groupe, et
l'exiger obligeait chacun à maintenir dans son `terraform.tfvars` une valeur
identique pour tous, à côté du seul champ qui varie vraiment. L'exemple se
réduit donc à `resource_group_name`.

L'étiquette `user` était indexée sans filet : un groupe qui n'en porte pas
faisait échouer le `plan` sur un « Invalid index » muet. Le contrôle est une
`postcondition`, pas une `precondition` — celle-ci est évaluée avant la
lecture et ne peut pas porter sur ce qu'elle rapporte, Terraform la refuse
comme auto-référence. Éprouvé en inversant la condition : le message nomme le
groupe et donne la commande de remise en état.

La validation de GUID acceptait trente-six tirets. Motif aligné sur celui de
`bootstrap.sh`, et vérifié dans les deux sens.

`region` renvoyait la variable, c'est-à-dire l'intention, sous un nom qui
laissait croire à un constat. Deux sorties distinctes : `region_du_groupe`, lue
sur Azure, et `region_des_ressources`, demandée pour le #43. Les voir diverger
est un signal utile en relecture.

Enfin la dérive #67/#68 : le heredoc de `bootstrap.sh` omettait `tenant_id` et
`subscription_id`, que `backend.tf` porte. Complété, et la comparaison des deux
blocs est maintenant exacte au caractère près.

Vérifié : fmt, init -lockfile=readonly, validate, plan « No changes » après
apply du renommage des sorties (0 ressource touchée), bootstrap rejoué contre
Azure, contrôle des liens Markdown, scan de fuite de la chaîne.

Refs #68
La dernière case du critère « écran qualité » du ticket. Le tableau donne
un chiffre de disponibilité par site, une photo de l'instant ; le journal
donne les trois derniers jours, avec ce qui était attendu, ce qui a
manqué, et la ventilation des relevés retenus par méthode (ADR 0006).

Déplié à la demande, un site à la fois : la route n'en sert qu'un, et
personne ne lit le détail des sept en même temps. Le sortir en colonnes
aurait demandé les sept journaux au chargement. Plusieurs lignes peuvent
rester ouvertes ensemble — comparer deux sites qui décrochent est le
geste même de cet écran.

Patron « bouton révélateur » du WAI-ARIA : un bouton aria-expanded qui
commande sa ligne de détail par aria-controls, et rien de plus. Un rôle
de grille engagerait une navigation aux flèches que ce tableau ne tient
pas.

« formaterJour » lit la date en UTC comme le reste du module : « jour »
est une date nue, qu'un poste à l'ouest de Greenwich rendrait la veille —
un journal décalé d'un jour, en silence.

Les chemins d'expiration du dépôt Qualité ont enfin leurs tests : ils
étaient arrivés avec le correctif de la modale, sans couverture ici.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le commit 5dd4be7 l'avait retirée par mégarde. C'est un critère d'acceptation
du #68 : sans elle, rien ne réclame la trace du plan avant fusion et celle de
l'apply après, et l'ADR 0007 n'a plus que la bonne volonté pour tenir.

Remise à l'identique de ce qui avait été retiré — vérifié en comparant les
lignes supprimées par 5dd4be7 aux lignes réintroduites — et au même endroit,
entre « Preuve », dont elle est le cas particulier, et « Relecture ». Le diff
ne porte que des ajouts : 25 lignes, aucune suppression.

Refs #68
Merge branch 'develop' into marvin/25-connexion-session
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m54s
0ae30ba74f
Merge develop dans florian/68-squelette-terraform
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 18s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m50s
7b591b2466
Merge branch 'develop' into lenaic/141-runner-reproductible
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m10s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m53s
e7140fa05b
Merge pull request 'infra : la configuration de l'exécuteur entre au dépôt (#141)' (#145) from lenaic/141-runner-reproductible into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 21s
Infra Ansible / Playbooks Ansible valides (push) Successful in 1m25s
Intégration / Python — qualité, tests et dépendances (push) Successful in 2m53s
8f5c4275a4
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/145
Reviewed-by: gabriel <gabriel@noreply.10.105.200.41>
Merge branch 'develop' into florian/68-squelette-terraform
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m52s
c10c11d20b
tests: le jeu d'essai de zone argent quitte conftest pour un module nommé
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m4s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m21s
f288c87a2b
UN CONFTEST NE S'IMPORTE PAS. Sans `__init__.py`, pytest donne à chaque
`conftest.py` le même nom de module de premier niveau — `conftest` — et un
seul fichier peut l'occuper. `tests/unit/gold/test_fabrique_argent.py`
faisait pourtant `from conftest import ...` en visant `tests/conftest.py` :
depuis que la #94 a ajouté `tests/unit/api/conftest.py`, c'est celui-là que
l'import trouvait, et ses deux cas tombaient en `ImportError`.

Aucune des deux branches n'était fautive seule — develop verte à la #290, la
#94 verte à la #279 — c'est leur réunion qui rougissait, aux exécutions #288
et #293. Un conflit qu'aucun des deux côtés ne pouvait voir chez lui.

Le référentiel et les familles de grandeurs vivent donc dans
`tests/jeu_essai_argent.py`, un module dont le nom ne peut désigner qu'un
fichier. `conftest.py` les importe pour ses fabriques : il fournit des
fixtures, il ne s'importe plus. Le reste du jeu d'essai, que personne ne
relit par son nom, reste où il est.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA13mGth6mJq7yTvgtfuLG
Merge branch 'develop' into marvin/94-api-tableau-de-bord
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m20s
ee0adb20a1
Merge pull request 'api : le tableau de bord sur données fictives - routes L1 à L4 (#94)' (#137) from marvin/94-api-tableau-de-bord into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Aucun secret commité (push) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 21s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m17s
109f0a5171
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/137
Merge branch 'develop' into florian/68-squelette-terraform
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m14s
e36cac2881
Merge pull request '[68] Squelette-terraform' (#150) from florian/68-squelette-terraform into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Aucun secret commité (push) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 21s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m24s
2dad40428b
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/150
Reviewed-by: justine <justine@noreply.10.105.200.41>
Merge branch 'marvin/25-connexion-session' into marvin/24-ecran-parc
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 38s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m57s
2340d69974
Un jour porte quatre grandeurs : c'est du tabulaire, et je l'avais rendu
en cartes « flex-wrap ». Ça tenait tant que l'API en servait trois
(_JOURS_JOURNAL = 3, en dur côté Python, sans paramètre de requête) et se
serait défait sans bruit au premier jour de plus — sept cartes sur deux
rangées, trente cartes en mur dans une ligne de tableau.

Une table encaisse trois lignes comme trente, et surtout elle aligne les
colonnes : c'est ce qui permet de suivre une disponibilité qui se dégrade
d'un jour à l'autre, ce qu'on attend d'un journal.

Son propre conteneur de défilement horizontal : celui de la table du
dessus ne la couvre pas, elle vit dans une cellule dont la largeur est
déjà contrainte.

Un test la monte sur trente jours pour que la disposition ne suppose plus
un petit nombre en silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fusionner develop dans la branche des recommandations
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m21s
0834461d4b
UNE MÉTHODE QUE SEULS SES TESTS APPELLENT EST MORTE. « reprendre() » était
écrit, exporté, couvert par quatre cas — et appelé de nulle part. Relecture
de Justine sur la #138 : recharger la page redemandait les identifiants
d'une session parfaitement valide, cookies encore bons. La PR annonçait la
reprise ; le fil n'était pas branché.

L'appel vit dans « App.vue » et non dans la page de connexion : les cookies
de session ne regardent aucun écran en particulier, et la reprise doit valoir
pour les écrans à venir (#23, #24). Il ne bloque pas le montage — attendre
l'API avant d'afficher quoi que ce soit ferait une page blanche le temps de
l'aller-retour, et sans fin si l'API se tait.

« tests/unit/App.test.js » couvre les trois chemins que « reprendre() »
distingue : cookie encore bon, accès expiré qui déclenche la rotation une
seule fois, et rien à reprendre. Les deux premiers rougissent sur l'App.vue
d'avant, vérifié.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dashboard: la démonstration ne poste plus sur /auth/logout (#25)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 22s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m14s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m56s
fc7342a6c3
« fermerSession() » était la seule des quatre routes à ne pas porter la garde
« MOCK_ACTIF » — relecture de Justine sur la #138. Sous « VITE_AUTH_MOCK=1 »,
la console annonçait qu'aucun appel ne partait vers l'API pendant que
« Changer de compte » postait sur /auth/logout. Sans API en face, l'appel
échoue ; « deconnecter() » avale l'échec et repose la page, donc rien ne se
voyait — un mode de démonstration qui n'en est plus tout à fait un.

La garde rend le « null » du 204, soit une fermeture réussie : la
démonstration ne pose aucun cookie, il n'y a rien à révoquer, et
l'utilisateur repose sur la page de connexion, ce qu'il a demandé.

Le cas ajouté exerce les trois routes gardées d'un coup et vérifie que
« fetch » n'est jamais appelé. Il rougit sans la garde, vérifié.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le journal se dépliait dans une ligne du tableau de l'écran Qualité et n'y
tenait pas : cinq colonnes imbriquées dans une cellule d'un tableau qui en
compte six, deux conteneurs de défilement l'un dans l'autre, et une
comparaison entre sites — le geste même de cet écran — qui demandait de
déplier ligne à ligne.

« /qualite/journal » rend les sept journaux en pleine largeur, l'un sous
l'autre, du site le moins disponible au plus disponible. Chaque ligne du
tableau y pointe désormais au lieu de déplier.

Premier onglet ajouté depuis que le dépôt s'en tient à « aucun onglet vers
un écran qui n'existe pas ». La règle n'est pas levée : elle interdit un
onglet vers un écran absent, pas vers un écran de plein droit. Celui-ci
existe et porte un critère du ticket. Le commentaire d'EnteteApplication
suit la décision.

« isExactActive » et non « isActive » sur les onglets : /qualite est un
préfixe de /qualite/journal, les deux s'allumeraient sinon. Un test le
tient.

« chargerTousLesJournaux » charge le référentiel puis un journal par site,
en parallèle. Chaque journal range son propre échec : un site en panne
n'empêche pas les six autres de s'afficher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(recommandations): les trois règles du #153
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m26s
43770df9b8
Gabriel a écrit le ticket de spécification, #153 : pour chacune des trois,
l'observation, la fenêtre, la condition exacte, la phrase rendue et l'action
recommandée. Rien n'est inventé ici.

  r1  prevision_kw >= 0,90 x capacite_kw
  r2  trois moyennes horaires consécutives >= 0,85 x capacite_kw
  r3  taux_disponibilite du jour < 0,80

Le contrat d'entrée s'aligne sur les colonnes réelles de la zone or plutôt que
sur des noms choisis ici : `valeur_prevue_kw` de `prevision`, `moyenne_kw` de
`mesure_horaire`, `taux_disponibilite` de `qualite_jour`, `capacite_kw` du
référentiel. Le #153 restreint les sources à la zone or, ni argent ni bronze.

Chaque seuil vit dans une constante en tête de son module. Le #153 les annonce
comme des valeurs de départ à recaler après 24 à 48 h : les changer doit coûter
un nombre et un incrément de correctif, jamais une retouche de la mécanique.

Les trois règles se déclarent indécidables plutôt que silencieuses quand la
donnée manque, en nommant ce qui manque. R2 en particulier : deux heures ne
permettent pas de juger d'une persistance sur trois, et se taire laisserait
croire que la charge est normale. Une capacité nulle ou négative est refusée
avant la division, pas après.

Le cycle de vie demandé par le #153 vit dans `cycle.rapprocher`, qui confronte
l'état connu au résultat d'une passe et dit quoi ouvrir, garder, résoudre. L'état
n'est pas ici, la persistance appartient à l'appelant.

LA DÉCISION DE CE MODULE : on ne résout que sur un silence, jamais sur une
indécision. Une règle qui n'a pas pu juger ne prouve pas que le problème a
disparu, et résoudre annoncerait « c'est réglé » alors qu'on n'a pas regardé.
Le moteur enregistre donc les silences, distincts des indécisions.

L'identité d'une recommandation active est le couple site et règle, pas la
version : recaler un seuil ne doit ni fermer ni rouvrir ce qui est en cours.

Les tests passent de `tests/unit/recommandations` à `tests/unit/rules`, chemin
que le #153 nomme dans sa section de vérification.

90 cas, couverture à 100 % sur 269 instructions pour un palier exigé à 85 %.
Dont la preuve demandée par le ticket : un parc de quatre sites produisant
exactement trois recommandations, une par règle, le quatrième site restant muet,
et un site sans zone or rendant trois indécisions plutôt que de passer pour sain.

Contribue au #39, ferme la dépendance sur #153
Rectifie le commit précédent, qui empilait les sept journaux sur une page
unique sous /qualite/journal. Un journal se lit site par site, et s'ouvre
depuis la fiche du site concerné : « /sites/:id/journal ».

L'onglet « Journal » de l'en-tête disparaît avec elle. On entre sur cette
page par un site nommé, comme sur /sites/:id : un onglet devrait demander
« lequel » avant d'avoir quoi que ce soit à ouvrir. La règle du dépôt
tient donc toujours, deux onglets et pas un de plus.

Deux entrées : le lien « Journal de collecte » en tête de la fiche du
site, et la colonne du tableau de l'écran Qualité, dont chaque ligne
pointe désormais vers le journal de son propre site.

« chargerTousLesJournaux » n'a plus d'appelant, il part. « chargerFiche »
arrive dans le dépôt du site : la page n'a besoin que du nom, du type et
de la disponibilité pour son en-tête, là où « charger() » lancerait trois
requêtes dont deux qu'elle n'affiche pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(recommandations): quatre défauts trouvés en relisant ma propre branche
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m29s
727860e9c0
Deux fonctionnels, deux de forme.

R1 déclenchait sur une prévision périmée, et l'affichait. Une prévision émise
pour midi, évaluée à 14 h, produisait « la consommation prévue à 12h00 atteint
480 kW » et recommandait de délester pour une heure déjà passée. Un exploitant
qui lit ça une fois ne lit plus les suivantes. La règle exige maintenant que la
prévision vise l'avenir, dans les deux heures : deux et non une, pour qu'une
passe en retard de dix minutes reste jugeable.

R2 ne pouvait pas vérifier que les trois heures se suivaient. Le #153 dit
« consécutives », et le contrat ne transportait que des nombres nus : la règle
était structurellement incapable de distinguer 11h, 12h, 13h de 9h, 14h, 18h, et
annonçait une pointe persistante qui n'avait jamais eu lieu. Le cas n'est pas
théorique, R3 existe précisément parce que la collecte a des trous.

Le contrat transporte donc `MoyenneHoraire`, l'heure avec la valeur. R2 trie,
vérifie la contiguïté au pas d'une heure, et refuse une fenêtre trop ancienne :
trois heures d'hier ne disent rien de la charge de maintenant.

Quatre `assert` servaient à rétrécir les types pour mypy. `python -O` les
supprime, et le code aurait continué avec des `None`. Remplacés par un `if` qui
rétrécit aussi bien et que rien ne supprime. Le motif d'indécision continue de
nommer précisément ce qui manque.

Et un de mes tests portait un nom faux : « la mise à plat est prête pour la table
recommandation » alors qu'aucune clé ne porte le nom de sa colonne. Il passait
parce qu'il vérifiait la fonction contre elle-même, ce que j'ai reproché à mon
propre banc ce matin. Renommé, et doublé d'un test qui consigne l'écart avec la
migration 0014 plutôt que de le laisser découvrir.

98 cas, couverture à 100 % sur 286 instructions.
dashboard : le journal s'ouvre depuis la disponibilité (#24)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 37s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m3s
ee4faab424
Le lien flottait à droite du fil d'Ariane sur la fiche du site, et
occupait une sixième colonne sans en-tête lisible dans le tableau de
l'écran Qualité. Dans les deux cas il ne disait ni ce qu'on allait y
trouver, ni à quoi il se rapportait.

Il est maintenant rattaché à la disponibilité, aux deux endroits : c'est
ce chiffre-là que le journal explique — d'où viennent ces 82,5 %, quels
relevés ont manqué, comment les trous ont été comblés. Posé sous la
valeur, à la taille des étiquettes : il la complète, il ne rivalise pas
avec elle.

Sur la fiche, le lien vit dans le « dd » et non à côté : dans une liste de
définitions à « div », ces div ne portent que des « dt » et des « dd », un
« a » frère n'y serait pas valide.

Le tableau retrouve cinq colonnes, chacune avec son en-tête. Un test le
tient, pour qu'aucune colonne muette n'y revienne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
supervision: corrige les points de relecture de la #42 (#152)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 23s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m13s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m6s
0a59d46b06
- l'assertion d'entrée du rôle app ne bloque plus tout le déploiement
  pour le jeton du relais d'alerte, absent tant que le compte de
  service n'existe pas
- documente le risque d'un relais non authentifié (relais-forge/README.md)
- purge les .prom.$$ orphelins de plus d'une heure dans les deux
  scripts qui écrivent des métriques textfile
- résout le conflit sur vault.yml.example en gardant les deux clés
  (#42 et #141)
infra: publie le plan de migration cloud chiffré et le plafond
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m24s
21b64e88d9
Le plan compare le coût technique local (11,73 €/mois, hypothèses déclarées)
aux deux scénarios hébergés chiffrés au catalogue public Azure du 04/09 :
reprise à l'identique 127,55 €/mois, services managés 119,58 €/mois. Un ordre
de grandeur d'écart, donc une bascule qui ne se justifie pas par le prix : les
quatre conditions qui la déclencheraient sont posées et vérifiables.

Trois droits manquent au rôle « Devops-cours-projet-eadl », mesurés à la portée
du groupe avec des corps volontairement invalides pour ne rien créer :
Microsoft.Consumption (budget), managementPolicies/write (cycle de vie), et un
second compte de stockage que « storageaccountnumber = 1 » refuse. Le conteneur
blob, lui, passe.

Le plafond est donc déclaré et non provisionné — 5 €/mois, posé en métadonnée
du conteneur pour être lisible dans l'état Terraform comme le ticket le demande.
Rien ne se déclenchera seul, et l'ADR 0012 le dit en clair plutôt que de laisser
le mot « alerte » le suggérer.

Closes #43
docs: journal J5, réaligne PRD et BACKLOG sur l'état réel de la forge (#48)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m14s
e078d8af15
Les tâches de la chaîne démarrent toujours, ce sont leurs étapes qui se
sautent — c'est voulu, une tâche filtrée par « on.paths » ne démarre pas et
bloque pour toujours un contrôle obligatoire. Mais une tâche pouvait finir
verte avec toutes ses étapes utiles sautées et rien pour le dire : ouverte
dans l'interface, elle paraissait vide. C'est le reproche formulé en
relecture.

Ce script enregistre un résultat par contrôle et rend un tableau en fin de
tâche : le contrôle, son résultat, et pour les sautés la raison. Écrit dans
GITHUB_STEP_SUMMARY quand l'exécuteur en propose un, et dans tous les cas
dans le journal — ce runner n'est pas documenté comme les supportant, la
sortie ne peut donc pas en dépendre.

POSIX strict : les étapes tournent sous « sh -e », et le sh de Debian est
dash.

Le banc l'exécute contre neuf cas plutôt que de le lire, dont les deux qui
comptent : un décompte qui contredit son propre tableau, et un rapport vide
qui se rendrait en silence. Un résumé qui ment est pire que pas de résumé,
parce qu'il est cru.
Reproche de relecture : la chaîne est en français et ses tâches ne disent pas
ce qu'elles font. Les deux sont traités, et la relecture des fichiers en a
sorti quatre défauts que personne n'avait vus.

CE QUI SE VOYAIT
- Tout passe en anglais : noms de workflows, de tâches, d'étapes, et sorties.
- Chaque tâche se termine par une étape « Report » en if: always() qui rend le
  tableau des contrôles. Une étape sautée y dit pourquoi.

CE QUI NE SE VOYAIT PAS
- SEPT NOMS D'ÉTAPES ÉTAIENT TRONQUÉS. « name: ... (ticket #77) » vaut, pour
  YAML, « ... (ticket » suivi d'un commentaire. L'interface affichait le nom
  amputé depuis toujours ; le fichier restant valide, rien ne le signalait.
- Aucun bloc « permissions: », nulle part. Ajouté, en lecture seule.
- Les quatre « checkout » laissaient leur jeton dans .git/config. Aucun ne
  pousse : persist-credentials: false.
- Douze actions référencées par tag. Épinglées par empreinte de commit.

L'épinglage a demandé de vérifier d'abord : cette forge résout les actions
depuis code.forgejo.org, pas github.com (aucun DEFAULT_ACTIONS_URL n'est posé).
Les empreintes du miroir ont été comparées tag par tag à celles de github.com
pour checkout, cache et upload-artifact : identiques. C'est le point resté
ouvert du runbook, refermé.

upload-artifact RESTE EN v3, délibérément. La v4 exige le backend d'artefacts
de Gitea 1.22+, que l'instance a, mais l'action officielle fait un contrôle
GHES auquel le backend Forgejo ne répond pas.

NOUVELLE TÂCHE « meta » : la chaîne se contrôle elle-même — actionlint
(+ shellcheck sur chaque bloc run:), zizmor, yamllint. zizmor rendait 25
constats sur l'ancienne chaîne, il en rend zéro.

COÛT NET NUL SUR UN EXÉCUTEUR UNIQUE. « images » et « secrets » ne lisaient
que des fichiers sur l'image nue : fusionnées en « repo ». Quatre démarrages
de conteneur avant, quatre après, un contrôle de plus. La granularité ne se
perd pas, le tableau de résumé nomme chaque contrôle.

Le banc d'hygiène refuse le retour de chacun de ces défauts, et refuse aussi
qu'un banc soit ajouté sans être joué.
Leur sortie s'affiche dans le journal de la chaîne : la laisser en français
laissait le reproche à moitié traité.

Traduction seule, logique inchangée — sauf deux points relevés en chemin :

- test-supervision.sh validait le JSON des tableaux de bord avec python3,
  alors que l'en-tête de la chaîne affirme qu'il n'y a pas d'interpréteur
  Python sur l'image du libellé. Le contrôle ne passait que parce que
  node:22-bookworm embarque python3 pour node-gyp. Le jour où l'image maigrit,
  tous les tableaux de bord seraient déclarés « JSON invalide », ce qui est
  faux et envoie chercher au mauvais endroit. Bascule sur node, seul
  interpréteur que cette tâche peut garantir.

- test-deploiement-continu.sh cherchait « repo_version=...github.sha » en
  clair. La valeur passe maintenant par une variable d'environnement, pour ne
  pas dilater d'expression dans du shell. Le banc suit la valeur au lieu de
  l'orthographe : il remonte de repo_version=$VAR à l'entrée env qui définit
  VAR et exige qu'elle vienne de github.sha. Éprouvé sur deux sabotages —
  github.ref à la place de github.sha, et repo_version figé en dur.

verifier-images.sh et son banc changent ensemble : le banc assertait sur le
texte du rapport. Idem pour verdict-audit-npm.js et le sien.

deps-services.py : sortie vérifiée identique à l'ancienne version.

tests/ci/test-role-app.yml reste en français, volontairement : c'est un
playbook Ansible, joué par la tâche Ansible, à côté de rôles qui restent en
français. La frontière est là — la chaîne parle anglais, l'infrastructure
qu'elle pilote reste en français.
Le tableau des tâches, les noms affichés et le motif de contrôle obligatoire
étaient devenus faux.

Ajouté : ce que fait la tâche « meta » et comment la jouer à la main, et la
section sur le résumé de tâche — pourquoi il existe et où il s'écrit.

Refermé : le point resté ouvert sur l'épinglage des actions, avec ce que la
vérification du miroir a montré. Ajouté à la place : pourquoi upload-artifact
reste en v3.

AVERTISSEMENT CONSERVÉ EN TÊTE : le motif obligatoire est préfixé par le nom
du workflow. « Intégration » devient « CI », les deux règles de protection
(develop et main) doivent donc passer de « Intégration / * » à « CI / * ». Une
tâche qui ne correspond à aucun motif obligatoire reste « en attente » pour
toujours et bloque sans rien expliquer.

docs/FORGE.md et le commentaire du Dockerfile mlflow nommaient des tâches qui
n'existent plus.
ci : durcissement après relecture hostile de la chaîne
Some checks failed
CI / Repository static checks (pull_request) Successful in 13s
CI / Python — quality, tests and dependencies (pull_request) Failing after 1m1s
CI / Dashboard — dependencies, tests and build (pull_request) Failing after 59s
CI / Workflows — lint and security audit (pull_request) Successful in 1m1s
Infra Ansible / Ansible playbooks are valid (pull_request) Successful in 53s
e10f60b07c
Deux relectures indépendantes de la refonte : la mienne, et une passe d'audit
sans aucun contexte du projet. Elles ont convergé sur le même défaut central et
en ont sorti une quinzaine d'autres. Ce que ça change :

LE RÉSUMÉ D'UN JOB ROUGE OMETTAIT CE QUI AVAIT CASSÉ. Les étapes tournent sous
« sh -e » : une commande en échec interrompt l'étape avant sa ligne de rapport,
donc le contrôle disparaissait du tableau. Le correctif est
.forgejo/scripts/ci-run.sh, qui lance la commande, enregistre dans les deux cas
et rend le code de sortie. Son banc a trouvé deux défauts dans le lanceur
lui-même : un code capté après un « if ... fi » (c'est celui du if), et « sh -e »
qui franchit le shebang quand on invoque « sh -e ci-run.sh ».

UN SCAN DE SECRETS QUI PASSAIT AU VERT SUR UNE ERREUR. grep rend 2 quand il ne
peut pas lire un fichier — non nul, donc la branche « rien trouvé ». Et il rend
2 même s'il a AUSSI trouvé : les lignes s'affichaient au-dessus d'un rapport
disant le contraire. Les trois issues sont séparées. Les motifs rataient par
ailleurs les formes les plus probables ici : PKCS#8 (« BEGIN PRIVATE KEY », ce
que produit ssh-keygen aujourd'hui) et la syntaxe YAML « password: valeur ». Un
premier motif élargi rendait dix faux positifs sur ce dépôt — un contrôle rouge
sur du code correct finit désarmé — il exige donc une valeur littérale entre
guillemets, sans interpolation. Éprouvé : cinq formes réelles attrapées, les dix
lignes légitimes ignorées.

UNE PANNE DU VERDICT npm ÉTAIT ANNONCÉE COMME UNE CVE CRITIQUE. La branche
« *) » attrapait aussi 127 (node absent) et 126 (bit d'exécution) : accusation
fausse et bloquante, dans le job dont tout le propos est de ne pas confondre les
causes.

actionlint LISAIT UN SHELL QU'ON N'EXÉCUTE PAS. Sans « defaults.run.shell: sh »
il suppose bash. Éprouvé : un « [[ ]] » et un « $SECONDS » injectés ne
produisent AUCUN constat sans la déclaration, SC3010 et SC3028 avec. Et
actionlint ne lit que les blocs « run: » — nos propres scripts n'étaient
analysés par personne, d'où l'étape shellcheck.

Le reste, plus court :
- un job mort avant ses scans affirmait « aucun Python dans le dépôt » ; une
  sortie vide n'est pas un « non ».
- un service sans [tool.mypy] était sauté sans un mot, sous un commentaire qui
  jurait le contraire.
- un fichier de certificat absent était diagnostiqué « expiré ». Et « -checkend 0 »
  ne prévenait jamais : trente jours de préavis en avertissement.
- la confiance au CA était posée après les étapes bloquantes, alors que les
  envois d'artefacts sont en « if: always() » : sur un job rouge ils partaient
  sans elle.
- « services/**/x » ne matche pas « services/x » : un service à plat échappait
  au typage et à l'audit.
- « infra/compose/*.yml » balayait tout le sous-arbre et donnait prometheus.yml
  et la config du runner au contrôleur d'images.
- ssh-keyscan en échec tuait l'étape avant son propre message d'erreur.
- tests/ci/test-role-app.yml était hors des paths du workflow qui le joue.
- un « | » dans un détail cassait les colonnes du tableau.
- deps-services.py tournait deux fois pour le même résultat.

Et sept commentaires qui affirmaient faux, dont « git n'est pas requis par
checkout » — il l'est, et sans lui « git ls-files » rendrait vide, laissant
trois contrôles verts sans rien lire.
ci : épingler contre le miroir que le runner utilise vraiment
All checks were successful
CI / Repository static checks (pull_request) Successful in 52s
CI / Workflows — lint and security audit (pull_request) Successful in 28s
CI / Dashboard — dependencies, tests and build (pull_request) Successful in 1m23s
Infra Ansible / Ansible playbooks are valid (pull_request) Successful in 53s
CI / Python — quality, tests and dependencies (pull_request) Successful in 4m22s
88d8330e6c
La chaîne a échoué au premier passage réel sur « Unable to resolve
0057852bfaa8b1baaa5b6f4c37c1fca1e70e02a3: reference not found ».

Ce runner résout les actions depuis data.forgejo.org. Les empreintes avaient
été vérifiées contre code.forgejo.org — un autre hôte Forgejo — où elles
correspondaient bien à celles de github.com, ce qui a été pris pour une preuve.
Ce n'en était pas une : le miroir servi n'est pas une copie à l'identique.
checkout@v4.3.1 y porte le même commit, cache@v4 non.

  cache@v4  data.forgejo.org : 0057852bfaa89a56745cba8c7296529d2fc39830
  cache@v4  github.com       : 0057852bfaa8b1baaa5b6f4c37c1fca1e70e02a3

Douze caractères communs en tête. L'œil glisse dessus, et une comparaison
tronquée à l'affichage aussi.

Les trois empreintes sont désormais vérifiées une par une contre
data.forgejo.org, et l'en-tête du fichier donne la seule commande qui vaut
preuve. Le runbook dit ce que l'épinglage achète — l'immuabilité sur l'hôte qui
sert l'action — et ce qu'il n'achète pas : la certitude que le code est celui
publié en amont. Comparer des identifiants n'est pas comparer des arbres.
docs(ci) : les résumés de tâche sont supportés, c'est mesuré
All checks were successful
CI / Repository static checks (pull_request) Successful in 7s
CI / Dashboard — dependencies, tests and build (pull_request) Successful in 18s
CI / Workflows — lint and security audit (pull_request) Successful in 19s
Infra Ansible / Ansible playbooks are valid (pull_request) Successful in 54s
CI / Python — quality, tests and dependencies (pull_request) Successful in 3m23s
0bd78d3e86
Le rendu a été écrit pour marcher avec ou sans GITHUB_STEP_SUMMARY, Forgejo ne
documentant pas cette variable. L'exécution 1424 du 2026-09-05 tranche : le
runner la fournit, les tableaux atteignent donc l'interface web et pas
seulement le journal. Le repli reste — il coûte une branche, et c'est lui qui
rendait la question sans conséquence.
contracts: poser le contrat de prévision H+1 et sa référence publiée
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 18s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m52s
80fabec3db
EF-07 demande une prévision d'heure suivante « avec une référence de
comparaison publiée ». Rien ne portait cette référence : `services/inference`
n'a qu'un README, `packages/contracts` était vide, et `public.prevision` est
posée depuis la 0011 sans qu'aucun `insert` du dépôt ne la vise.

Ce commit pose ce qui manque pour que les tickets #36 et #37 démarrent sans
partir d'une page blanche, et rien de plus : aucun modèle, aucune expérience,
aucun modèle enregistré ni promu dans MLflow.

- `docs/adr/0012` est **proposée**, pas acceptée. EC06 se défend
  individuellement ; la fiche met les options devant Justine et Olivier au lieu
  de trancher à leur place. Grain horaire sur `mesure_horaire`, cible H+1 par
  site, référence = persistance (naïf saisonnier en métrique secondaire),
  candidat `HistGradientBoostingRegressor` sur retards, promotion sur MAE à
  trois jours contre la persistance, nom `enervision-prevision-h1` **à
  confirmer**, alias `production`. Le job tourne en cron à l'heure, ce qui
  étend la dérogation de l'ADR 0008 à l'ADR 0001 — la fiche l'assume et le
  borne. Le T4 n'est pas requis, et la fiche dit pourquoi.
- La migration 0017 ajoute `reference_kw` à `public.prevision`. Elle NE DIT PAS
  la même chose que `valeur_reference_kw` de la 0011 : celle-ci est le réalisé
  recopié après coup pour mesurer la dérive, celle-là une prévision de
  référence connue à l'émission. Les confondre donnerait un écart nul par
  construction, donc une comparaison EF-07 qui ne compare rien et qui a l'air
  juste. Les deux colonnes portent désormais un `comment on column` qui renvoie
  à l'autre. Colonne nullable et sans défaut : `prevision` est une hypertable
  comprimée et une migration s'applique au démarrage du serveur, donc aucun
  fragment ne doit être décomprimé.
- `PrevisionH1` est volontairement plus strict que le schéma sur un point :
  `reference_kw` y est obligatoire, parce qu'une prévision publiée sans
  référence ne tient pas EF-07. La règle doit arrêter le producteur, pas la
  migration. Le contrat refuse aussi un identifiant hors format `SITE000`
  (même expression que la contrainte de la 0008), un instant naïf et un instant
  hors grain horaire — un instant sans fuseau décale d'un pas exactement sur
  une prévision horaire, et les courbes restent jolies.
- `packages/contracts` reçoit son `pyproject.toml` en disposition plate, comme
  les services. Sans lui, `deps-services.py` n'installe pas pydantic avant le
  typage et la boucle mypy de `ci.yml` ne voit pas le paquet : il serait le
  seul code du dépôt à n'être ni typé ni installé, sans que rien ne rougisse.
  `pytest.ini` gagne le chemin correspondant.

Vérifications : ruff check et ruff format sur `packages` et les deux répertoires
de tests, mypy strict sur `packages/contracts`, `pytest tests/unit/db
tests/unit/contracts -q` — 50 tests verts.
`taches_planifiees` ne portait que la relève, les alertes et la zone argent.
`gold-daily.sh` et `load-postgres.sh` sont écrits, testés et documentés depuis
le #124, mais rien ne les lançait : `public.mesure` et `public.qualite_jour` ne
se remplissaient que par un geste manuel. Or c'est cette zone or en base que
lisent l'API, Grafana métier, les règles de recommandation et le futur modèle.

Deux entrées, minutes 17 et 27, chaînées derrière la passe de la zone argent de
la minute 0 (§13 de docs/data/etl-pipeline.md). Les deux journalisent dans le
MÊME `gold.log` : elles forment un seul geste, « la journée passe en base », et
ouvrir deux fichiers pour savoir où il s'est arrêté est exactement ce qu'on ne
veut pas faire en panne. La rotation est déjà couverte, la tâche logrotate du
rôle porte sur *.log.

La ligne de cron seule n'aurait rien changé, et c'est le piège de ce lot.
`load-postgres.sh` source /etc/enervision/postgres.env pour y lire
ENERVISION_ETL_DATABASE_URL (l.22, l.35) ; le gabarit ne rendait que les cinq
PG_*_PASSWORD. Le job serait sorti en code 2 à chaque passe
(gold/chargement.py l.148-151, l.269-272) : une ligne par heure dans un journal
que personne ne lit, la zone or qui grossit dans MinIO, et la base vide. Une
passe sur deux réussit, ce qui rend le cas invisible.

Le gabarit rend donc la variable, et porte en commentaire la contrainte qu'elle
impose au mot de passe du coffre : il entre tel quel dans une URL, sans
encodage.

Éprouvé : `ansible-playbook site.yml --syntax-check`, `yamllint infra/ansible`,
`ansible-playbook tests/ci/test-role-app.yml` (10 contrôles, aucun échec), et le
gabarit rendu se source sans bruit en /bin/sh.

Suite du #136.
docs: étends le manuel de l'ETL à la zone or et rends la documentation exacte
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m4s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m27s
08c2d4bfaa
Le manuel ne couvrait qu'un seul programme (« un seul programme,
`etl.silver.job` »), alors que #44 promet un manuel par geste sensible. Il
couvre maintenant les trois passes du médaillon et les deux contrôles à la
demande, en gardant tout ce qu'il disait déjà de la zone argent.

Ce qu'il ajoute, dans l'ordre où on en a besoin en panne : la preuve par étage
(MinIO argent, MinIO or, `max(horodatage)` en base) ; les codes de sortie des
deux étages, relevés dans les scripts et non recopiés d'une documentation ;
comment rejouer une journée dans le bon ordre ; `--forcer` et ce qu'il faut
regarder AVANT de forcer ; le rafraîchissement de l'agrégat continu, que rien ne
documentait alors que sa politique ne revient que sur trois jours ; le lignage
et l'export, qui rendent un verdict en code 5 et non une panne.

La section qui compte est « Le piège de cette chaîne ». Deux étages sortent sous
les mêmes numéros : le lanceur shell écrit une ligne nue préfixée de son nom, le
programme Python une ligne horodatée avec son module. Le manuel donne la
signature exacte de la variable absente dans gold.log, et dit pourquoi le
journal a l'air sain — la ligne « agrégé » de la minute 17 est juste au-dessus.

Corrections portées par cette PR plutôt que par une PR « virgules » que personne
ne relirait :

- README racine : `services/etl/` y était listé deux fois avec deux
  descriptions ; `tests/` annonçait du bout en bout que `tests/e2e/` ne contient
  pas.
- docs/api/decisions.md : « ADR à écrire » pour le jeton, alors que l'ADR 0002
  existe depuis le 02/09. Remplacé par le renvoi à la fiche.
- tests/unit/README.md et tests/e2e/README.md n'étaient plus « à compléter » :
  ils disent ce qu'ils contiennent, et e2e dit qu'il est vide, pourquoi, et à
  qui appartient le sujet (#47) — plutôt que de laisser croire à un oubli.
- services/etl/README.md annonçait les deux lignes de cron « à poser ». Elles le
  sont.

.mailmap : `git shortlog -sne` est la preuve de croisement exigée par
EXIGENCES-collectives.md §9, et elle comptait quinze auteurs pour six personnes.
Elle en compte huit. Les identités sont relevées, jamais devinées : les deux qui
restent à part — un commit sous `Gabouil <gabriel.goldbronn@asphalte.com>` et le
compte partagé `g2-admin` — sont expliquées dans le fichier avec la ligne à
ajouter si Gabriel confirme.

Suite du #44.
ci: linte tout le Python du dépôt, pas seulement services et packages
All checks were successful
CI / Repository static checks (pull_request) Successful in 7s
CI / Dashboard — dependencies, tests and build (pull_request) Successful in 19s
CI / Workflows — lint and security audit (pull_request) Successful in 17s
Infra Ansible / Ansible playbooks are valid (pull_request) Successful in 1m29s
CI / Python — quality, tests and dependencies (pull_request) Successful in 3m28s
8212486bb0
L'étape nommait « services packages » et rien d'autre. Tout le Python vivant
ailleurs échappait donc à Ruff, à son contrôle de format et à tout le reste —
pendant que la chaîne annonçait « Ruff | passed », ce qui était vrai, sur des
répertoires qu'elle n'avait jamais ouverts. Un vert qui ne mesure rien.

Ce qui passait au travers : infra/compose/mlflow/exemple-execution.py,
bin/_sonde_api.py, db/migrate.py, et .forgejo/scripts/deps-services.py — un
script de cette chaîne elle-même, qui portait un bloc d'imports non trié que
personne ne pouvait voir. Il est corrigé ici. Le relais d'alerte ajouté sous
infra/ par la #152 porte un E741 et deux blocs non formatés pour la même
raison : la chaîne le dira désormais.

La définition de terminé dit « Ruff passe ». Pour ce code, Ruff n'était pas
appelé.

tests/ reste dehors, et c'est écrit plutôt que supposé : mesuré aujourd'hui,
Ruff y rend 15 erreurs et reformaterait 23 fichiers sur 55. Les y faire entrer
demande de réécrire les tests de plusieurs personnes en même temps — une
décision d'équipe, pas l'effet de bord d'une correction. La ligne du banc qui
l'exclut porte la mesure et la raison.

Le banc refuse désormais qu'un répertoire portant du Python sorte de la portée :
il compare ce que « git ls-files '*.py' » trouve à ce que l'étape nomme. Éprouvé
en retirant « infra » de la liste, qui est alors nommé.
Rien dans le dépôt ne déployait l'API ni le front : `app.g2.enervision` rend
404 (relevé le 06/09 sur 10.105.200.41), et l'application ne se démontre que
sur un poste. Le Caddyfile de la forge attend les deux depuis le #32 — `/api/*`
vers 127.0.0.1:8000, le reste servi depuis /srv/www/app — et il est déjà juste :
il n'est pas touché.

Deux services, tous deux en réseau hôte parce qu'aucun réseau bridge ne démarre
sur ce LXC :

  - `api`, python:3.12-slim épinglée par empreinte, uvicorn sur 127.0.0.1:8000,
    secrets dans /etc/enervision/api.env, mémoire bornée à 512 Mo. Les
    dépendances vivent dans un volume nommé et ne sont réinstallées que si
    l'empreinte de requirements.txt change : réinstaller à chaque démarrage
    rendrait l'API dépendante de PyPI pour redémarrer, ne jamais réinstaller
    ferait tourner l'ancien environnement après un déploiement, en silence ;
  - `dashboard-build`, node:22 épinglée, à exécution unique sur le modèle de
    `minio-init`. `npm ci` puis `vite build` avec VITE_API_BASE=/api — relative
    et non absolue, pour ne pas graver le nom d'hôte dans le paquet livré.

Images épinglées par empreinte et non par tag : le contrôle d'images de la
chaîne accepterait un tag, mais le groupe n'a pas de registre où publier une
image maison, et l'empreinte donne la même reproductibilité sans registre.
Relevées le 06/09 : Python 3.12.14, Node 22.23.2.

OÙ VA LA SORTIE DU BUILD, et c'est la décision de cette PR. Le montage de Caddy
est `./www:/srv/www:ro` et `www/app/index.html` est versionné : déposer un
`dist/` dans un répertoire suivi par Git serait un choix, pas un détail. Le
build écrit dans /opt/g2-forge/www/app SUR L'HÔTE, qui est le déploiement de la
pile forge et non une copie de travail — le répertoire versionné n'est donc
jamais écrasé. Le .gitignore ajouté est la ceinture par-dessus les bretelles,
pour le jour où quelqu'un déploiera la forge par un `git clone`.

La mise en ligne se fait par renommage de répertoire, après un build réussi, et
laisse `app.ancien` derrière elle : un `npm ci` en échec ne peut pas emporter ce
qui était servi, et le retour arrière ne demande pas de reconstruire.
La pile est déclarée sous le nom `api` et le chemin `app`, et cet écart est
délibéré : le rôle construit le nom du fichier de secrets depuis `name`, donc
la pile cherche /etc/enervision/api.env — le fichier qui porte le secret de
signature et le DSN. Le répertoire, lui, porte l'API ET la construction du
front, qui ne sont pas la même chose que « l'API ».

Elle est placée après postgres, dont l'API lit la base, et avant supervision,
dont la place en dernier tient à Grafana qui teste sa source au démarrage.

Conséquence utile de ce nommage : sans api.env, la pile est SAUTÉE bruyamment
par le dispositif déjà en place, au lieu de démarrer une API qui refuserait de
servir — `Settings.jwt_secret` n'a pas de valeur par défaut.

api.env.j2 pose ce que le Caddyfile attend depuis le #32, et rien de plus :
ROOT_PATH=/api pour que les URL émises retrouvent le préfixe que `handle_path`
retire, TRUST_PROXY=true sans quoi la limite de cinq échecs par IP compte l'IP
de Caddy et un seul poste peut verrouiller tout le monde, COOKIE_SECURE=true,
ALLOWED_ORIGIN vide parce que le front et l'API sont sur la même origine.
RUN_MIGRATIONS_ON_STARTUP=false : le rôle applique déjà les migrations, sous le
rôle applicatif et avant que les piles démarrent ; deux applicateurs pour une
base finissent par se croiser.

vault_api_jwt_secret entre dans la garde du rôle, avec un seuil de 32
caractères et non « non vide » : un secret de signature court se retrouve hors
ligne à partir d'un seul jeton capté, et un jeton forgé vaut une session admin.
La valeur appartient au coffre, elle n'est ni générée ni posée ici.

Le rôle crée /opt/g2-forge/www/app côté hôte. Ce répertoire appartient à la
pile app, qui le PRODUIT ; la pile forge ne fait que le SERVIR, en lecture
seule. Il est créé par Ansible et non par le démon Docker, qui le poserait en
root:root 0755 à un moment que personne ne relit. `deploy` n'écrit pas dans
/opt/g2-forge (drwxr-x--- root root, relevé le 06/09) et n'a pas à y écrire :
ce sont les conteneurs qui écrivent, par le démon.

L'attente de bonne santé passe de 180 à 600 s : au premier passage, l'API
construit son environnement Python et le front subit un `npm ci` puis un
`vite build`. Les passages suivants restent courts.
La base démarre vide et toutes les routes du tableau de bord exigent une
session ; `POST /auth/users` réclame déjà un administrateur. Sur une base
neuve, il n'existe donc aucun chemin par l'API pour créer le premier compte, et
la réponse était jusqu'ici « en SQL » (README de l'API). Un `insert` à la main
est exactement ce qu'on ne veut pas faire en démonstration : il faut produire
soi-même un condensé argon2id, et une adresse en majuscules passe la contrainte
d'unicité tout en restant introuvable au login.

Le script réutilise, il ne réimplémente pas : le hachage vient de
`enervision_api.auth.passwords`, l'insertion de `enervision_api.auth.repository`.
Un second hachage écrit ici dériverait des paramètres argon2 de l'API le jour où
ceux-ci changeraient, et les comptes créés cesseraient de se connecter sans le
moindre message — la vérification rendant simplement False.

Le mot de passe n'a pas d'option : une ligne de commande est lisible par `ps` le
temps de son exécution et reste dans l'historique du shell. Il est demandé au
clavier et confirmé, ou lu sur l'entrée standard pour un usage scripté. Le DSN
suit la règle de db/migrate.py.

Le seuil est celui de l'API, huit caractères, et pas un de plus : plus strict
ici refuserait un mot de passe que `POST /auth/users` accepte, et le suivant en
conclurait que la base est cassée. En dessous de quatorze on le dit, on ne
l'interdit pas. Le rôle par défaut est `user` : un rôle d'administrateur se
demande, il ne s'obtient pas par distraction.

Vingt cas d'essai, sans PostgreSQL, sur un double de connexion minuscule — un
double qui imiterait psycopg finirait par mentir sur psycopg. Ils portent sur
ce qui ne se voit pas à la relecture : l'adresse part en minuscules, c'est un
condensé argon2id vérifiable qui est inséré et jamais le mot de passe, le sel
est tiré à chaque fois, la transaction est validée, et rien n'est écrit quand la
saisie est refusée.
docs: publie le manuel d'exploitation de l'application
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m4s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m42s
05bedeff4e
Créer le premier compte, relancer la pile, reconstruire le front et revenir en
arrière, lire les journaux. Plus la séquence de première mise en service, dans
l'ordre où elle doit être jouée : le secret dans le coffre d'abord, sans quoi le
rôle refuse de déployer quoi que ce soit ; le déploiement ensuite, qui rattrape
au passage les 52 commits de retard du serveur ; le premier compte ; et
seulement à la fin la recréation de la pile forge.

Cette dernière est isolée parce que c'est le seul geste risqué : elle touche la
forge de six personnes. Le manuel dit comment constater qu'elle est nécessaire
avant de la faire — `docker inspect` sur g2-forge-proxy — parce qu'elle ne l'est
que si le montage /srv/www manque. C'était le cas le 06/09 : la pile en service
est plus ancienne que le dépôt, le volume a été ajouté au compose et la pile n'a
jamais été recréée. Un conteneur ne gagne pas un volume à chaud.

Le manuel dit aussi ce qu'il n'a pas encore prouvé. Rien de ce qui y est écrit
n'a tourné sur le serveur : les vérifications sont marquées « à éprouver », et
le tableau des messages nomme les cas qu'on rencontrera d'abord — le conteneur
qui boucle sur un jwt_secret absent, la pile sautée faute de secret, /api/docs
sans style quand ROOT_PATH manque, et le cgroup mémoire qui pourrait ne pas être
exposé dans ce LXC.
collector: une passe de rattrapage pauvre n'efface plus une passe riche
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m20s
7403d887ea
La source applique l'état de panne COURANT d'un site à tout son historique.
Mesuré le 06/09 : la même URL, la même fenêtre, appelée trois fois à vingt
secondes d'écart, rend 0 puis 21 puis 0 points sur 100. C'est pour ça que le
manuel dit de relancer le rattrapage plusieurs fois.

Sauf que l'écriture visait la même clé sans rien comparer. Une passe rendant
2 points remplaçait donc tranquillement une passe qui en avait rendu 22, et le
conseil « relancer jusqu'à ce que la liste se vide » supposait une amélioration
monotone que le code ne garantissait pas. Le versionnement du seau gardait bien
l'ancienne version, mais la zone argent lit la version COURANTE : personne ne va
parcourir des `version_id` à la main pour retrouver la bonne.

Le protocole `Depot` gagne donc une lecture, `metadonnees_objet`, et l'objet
porte désormais `points-avec-valeur` — écrit pour être relu par la passe
suivante. Un `stat` suffit à comparer, sans télécharger ni décompresser.

Une comparaison impossible laisse écrire : clé absente, dépôt qui ne répond
pas, objet antérieur à cette métadonnée, ou dépôt qui ne sait pas lire. On
préfère une écriture de trop à une passe utile perdue.

Deux cas existants changent d'assertion, pas d'intention. « L'idempotence tient
à la clé » et « la même commande rejouée n'en crée pas d'autres » restent vraies
et restent vérifiées ; c'est le compteur d'écritures qui décrivait un effet de
bord, et qui vaut maintenant un de moins quand la seconde passe n'apporte rien.
ci : la chaîne revient au français, comme le reste du dépôt
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 7s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 20s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 2m32s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m20s
c54a71339c
La refonte du 04/09 avait passé la chaîne d'intégration en anglais, à la suite
d'une remarque de formateur en mini-oral. Le dépôt est en français partout
ailleurs — code, commentaires, documentation, rôles Ansible, tickets. Tenir
deux langues dans un même projet n'est pas une convention, c'est une frontière
qui court là où le dernier chantier s'est arrêté : `infra/ansible` et
`.forgejo/` sont deux couches d'infrastructure, lues par les mêmes personnes en
panne, et l'une était en français quand l'autre était en anglais. Décision du
07/09 : une seule langue, celle du dépôt.

Traduction, pas refonte. La structure, les étapes, les commandes, les
empreintes d'actions, `defaults.run.shell: sh`, les `permissions:` et la
logique de chaque contrôle sont inchangés. Se traduisent les noms affichés
(workflows, tâches, étapes), les commentaires, les messages et annotations,
les noms de contrôles du tableau de résumé et son rendu. Restent tels quels
les identifiants — ids de jobs et d'étapes, clés de sortie (`found=yes`),
variables d'environnement, statuts du protocole de `ci-report.sh` (`passed`,
`failed`, `skipped`, `warned`, `missing`), noms de fichiers — parce qu'un
identifiant n'est pas lu, il est référencé, et le renommer n'apporte que des
occasions de casser.

Les couples script/banc ont été traduits ensemble : les bancs assertent sur
les messages des scripts (`image non épinglée :`, `illisible`, `registre`,
`code N, voir le journal de l'étape ci-dessus`), et une traduction séparée les
aurait désaccordés. Les libellés français d'origine sont repris mot pour mot
là où ils existaient.

Une conséquence heureuse : le workflow reprend son nom, `Intégration`. Le
motif de contrôle obligatoire `Intégration / *` des règles de protection de
`develop` et `main` redevient valide tel quel — il n'y a plus rien à changer
côté forge au moment de la fusion. Le runbook et les documents qui citaient
`CI / *` et les noms anglais des tâches sont réalignés.

Le contrôle d'hygiène qui refusait les lettres accentuées dans les noms
affichés est inversé : il refuse désormais les restes d'anglais évidents dans
un `name:`, par une liste de mots volontairement étroite — élargir ferait
rougir du français correct, et un banc qui crie faux finit débranché. Appliqué
aux versions anglaises, il signalait 41 noms ; aux françaises, zéro.

`docs/CONVENTIONS.md` gagne la section « Langue » qu'il n'avait pas, et qui
aurait évité la dérive : tout est en français, chaîne comprise, et la
question exacte du formateur — les conventions CI, ou tout le projet ? — reste
à lui poser avant de décider quoi que ce soit d'un bloc.

Vérifié : actionlint, yamllint, zizmor, shellcheck (scripts sh et bancs) et
ruff à zéro constat ; les dix bancs de tests/ci verts ; aucun nom d'étape
tronqué au chargement YAML ; `deps-services.py` rend une sortie identique.
Merge branch 'marvin/24-ecran-parc' into marvin/24-ecran-site
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 28s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m54s
6fbf3e5c3a
Merge pull request 'dashboard : la page de connexion parle à l'API réelle (#25)' (#138) from marvin/25-connexion-session into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 20s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m23s
f21a619209
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/138
Le paquet `model` sous `services/inference/`, que le README racine réserve à
EC06. Un paquet par ticket : `model` est l'entraînement et la promotion (#36),
`inference` sera le service qui sert la prévision (#37). Ils partagent le
répertoire et le `pythonpath` — un seul ajout à pytest.ini pour les deux — mais
pas leurs dépendances.

Les trois conventions que le registre partage sont en constantes, pas en
variables d'environnement : `docs/runbooks/mlflow.md` §4 demande au #36 de fixer
le nom du modèle « une fois pour toutes », et l'ADR 0012 le laissait « à
confirmer par le ticket #36 ». C'est fait : `enervision-prevision-h1`, alias
`production`, expérience `prevision-h1`. En faire des réglages aurait rendu
possible qu'un entraînement publie sous un nom que le #37 ne cherche pas.

`MLFLOW_TRACKING_URI` garde son nom standard, sans préfixe maison : le manuel §4
promet à un client qu'il n'a besoin que de cette variable. L'URL de la base est
lue sous deux noms, celui du modèle et celui de l'ETL, parce que
`/etc/enervision/postgres.env` définit le second et que l'entraînement lit la
même base en lecture seule — demander un changement de template Ansible pendant
que le #136 le modifie n'aurait rien apporté.

La source est `public.mesure_horaire`, l'agrégat continu de la 0016, et pas
`public.mesure` réagrégée ici : la règle qui exclut les relevés `critical` du
calcul est déjà écrite une fois, en SQL, et c'est la vue que l'API et Grafana
serviront. Deux vérités pour une question se seraient contredites au premier
chiffre affiché.

Un exemple prévoit l'instant `p` à partir des heures `p−1` … `p−24`, toutes
révolues à l'émission. Aucune statistique n'est calculée avant le découpage, qui
est chronologique : sur une série temporelle, un tirage aléatoire laisse le
modèle apprendre l'heure suivante d'une heure qu'il a déjà vue, et le score
obtenu ne dit plus rien. La coupure se calcule sur le dernier instant présent et
non sur `now()`, sans quoi le même appel ne serait pas reproductible d'un jour à
l'autre — quatrième critère du ticket.

Les trous ne sont pas rebouchés une seconde fois : l'imputation appartient à la
zone argent, bornée à trois régimes par l'ADR 0006. Une cible dont un retard
manque n'est pas produite, et le bilan compte séparément « cible fragile » et
« retard manquant » — la collecte et la profondeur d'historique ne se corrigent
pas de la même façon, les confondre reviendrait à ne rien savoir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le deuxième critère du ticket : « une référence naïve est publiée, et l'erreur
du modèle lui est comparée site par site ». Les deux moitiés comptent.

**Publiée.** Une référence calculée après coup, dans un tableur, au moment de
rédiger le rapport, ne se reconstitue pas — personne ne peut plus dire sur
quelles heures elle portait. Elle se calcule donc sur exactement les mêmes
exemples que le modèle, et le module est importable par le service du #37, dont
le troisième critère lui demande de servir la prévision « avec sa référence de
comparaison ». Deux calculs de persistance, un ici et un là-bas, finiraient par
diverger d'une heure sans que personne le voie.

Deux références, deux rôles, comme l'ADR 0012 les répartit : la persistance
(`y(p) = y(p−1)`) est celle qui se publie ligne à ligne et qui sert d'adversaire
au critère de promotion ; le naïf saisonnier (`y(p) = y(p−24 h)`) est une
métrique d'évaluation seulement — il dit si le modèle capte le cycle journalier,
ce que la persistance ignore par construction.

La position d'un retard se déduit de la liste des retards, jamais d'un indice en
dur : `retards_kw[0]` n'est la dernière heure connue que si `retards[0]` vaut 1,
ce qui est vrai aujourd'hui et n'a aucune raison de le rester quand le retard
hebdomadaire s'ajoutera.

**Site par site.** Une MAE agrégée sur les sept sites cache exactement ce qu'un
exploitant veut savoir : `test_une_mae_agregee_peut_cacher_un_site_inutilisable`
le montre plutôt que de l'affirmer — 30 kW en moyenne, 0 sur neuf sites et 300
sur le dixième.

La MAE est en tête parce qu'elle s'exprime en kW, dans l'unité du parc. La RMSE
l'accompagne : un modèle qui se trompe rarement mais énormément — le pire défaut
pour une alerte de dépassement — a une MAE flatteuse et une RMSE qui le dénonce.
Le MAPE n'est qu'indicatif et rend `None` plutôt que zéro quand aucune heure ne
dépasse le plancher : un zéro se lirait « le modèle est parfait », le contraire
de « on ne peut pas le dire ».

`math.sqrt` et non « ** 0.5 » : pour mypy strict, l'opérateur de puissance sur un
flottant rend « Any », et la fonction promettait un float sans que rien ne le
vérifie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le régresseur, le registre, l'entrée du job et son lanceur. Ce qui reste à faire
sur le ticket est une exécution réelle : les critères 1, 3 et 4 se prouvent par
une exécution sur le serveur, pas par du code.

**XGBoost et non scikit-learn, et c'est un écart assumé avec l'ADR 0012.** Cette
fiche, proposée le 06/09, écrit « le T4 n'est pas requis » et propose
`HistGradientBoostingRegressor`, qui n'a aucun support GPU. Le premier critère
d'acceptation du #36 dit l'inverse — « l'entraînement s'exécute en local sur le
Tesla T4 » — et réclame la sortie de `nvidia-smi` pendant l'entraînement comme
preuve. Les deux ne pouvaient pas être vrais ensemble. L'ADR se déclare
« proposée, et pas acceptée » et laisse explicitement les porteurs du #36
l'amender ; c'est le ticket qui fait foi. Reste à l'amender formellement, avec
Justine.

C'est le SEUL point de l'ADR qui est écarté : le jeu, la cible H+1 par site, la
persistance comme référence publiée, le critère de promotion et les noms du
registre sont pris tels quels. Et l'amendement reste réversible en un endroit —
`modele.construire_regresseur` est la seule fonction du dépôt qui nomme XGBoost.

**Le repli sur le processeur n'est jamais silencieux.** `--device cuda` échoue
sans support CUDA au lieu de se replier : un entraînement qui réussirait sur le
processeur avec une capture `nvidia-smi` vide produirait une preuve fausse, ce
qui est pire que pas de preuve. `auto` existe pour les postes de travail et le
dit dans le journal. Le module ne prétend pas non plus constater que le calcul a
eu lieu sur le T4 : cette certitude vient de `nvidia-smi`, échantillonné par le
lanceur pendant l'exécution et joint à l'exécution MLflow comme artefact — la
preuve voyage avec le modèle au lieu de vivre dans une capture d'écran perdue.

**La règle de promotion est du code, pas une phrase de manuel.**
`decider_promotion` est une fonction pure de deux nombres, et le seul endroit du
dépôt qui dise quand un modèle passe en ligne : strictement mieux que la
persistance, ou l'alias ne bouge pas. À égalité on garde la version en place —
déplacer l'alias coûterait un redémarrage du service du #37 sans rien apporter.
Un modèle qui ne bat pas la persistance ne va pas en ligne, et le dire est un
résultat, pas un échec : le tableau comparatif s'imprime aussi dans ce cas-là,
qui est précisément celui qu'il serait tentant de ne pas montrer.

La version en place se lit AVANT le déplacement de l'alias : après, il désigne
déjà le candidat et le journal dirait « promu de 4 vers 4 ». C'est ce numéro que
le retour arrière du manuel §5 demande, et le journal est le seul endroit où il
reste écrit — le registre n'est pas versionné dans git, d'où aussi les trois
étiquettes `promu_par`, `promu_le`, `promu_motif`.

**Les frontières sont doublées, pas contournées.** Le protocole `Registre` nomme
les dix opérations demandées à MLflow ; `RegistreMlflow` les traduit sans aucune
logique. Publication et promotion s'éprouvent donc entièrement sans registre,
sans réseau et sans client installé — même motif que les adaptateurs DuckDB et
MinIO de l'ETL, et même règle d'import tardif : `import model.registre` réussit
sans MLflow.

`registre.charger` est la couture avec le #37, et elle est ici parce que c'est
`config.py` qui fixe le nom et l'alias : deux endroits qui construiraient la même
URI `models:/<nom>@<alias>` finiraient par en construire deux différentes, et la
panne serait un service qui ne trouve aucun modèle dans un registre qui en
contient.

**La rejouabilité se mesure, elle ne s'affirme pas.** Ce qui se maîtrise l'est :
graine fixée, `n_jobs=1`, ordre des exemples trié, coupure calculée sur les
données. Sur GPU, l'ordre des réductions flottantes n'est pas garanti d'une
exécution à l'autre ; `entrainer.sh --deux-passes` enchaîne donc deux
entraînements sur des bornes figées, sans rien publier, et compare les deux
tableaux. Identiques : c'est constaté. Différents : le script sort en 4 et
affiche l'écart, à déclarer comme tolérance plutôt qu'à cacher derrière une
promesse de bit-à-bit qu'on n'a pas mesurée.

Contrôles joués : ruff et mypy strict propres sur `services` et `packages`,
683 tests unitaires au vert dont 86 nouveaux, couverture globale 89 % et 83 %
sur le paquet — le palier bloquant de la chaîne est à 70 %. `ci.yml` n'est pas
touché : la tâche Python découvre seule les tests et les manifestes, et la
PR #154 modifie déjà la liste des zones sensibles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into gabriel/48-journal-j5-realignement
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
ac1a1f8183
Merge pull request 'docs: journal J5, réaligne PRD et BACKLOG sur l'état réel de la forge (#48)' (#157) from gabriel/48-journal-j5-realignement into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 19s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m25s
58623944e8
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/157
Reviewed-by: lenaic <lenaic@noreply.10.105.200.41>
Merge branch 'develop' into lenaic/36-contrat-prevision
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m29s
434e4d0329
Merge branch 'develop' into florian/43-plan-migration-cloud
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m24s
15db2139aa
Sortir vault_forge_alerte_token de l'assertion d'entrée était le bon geste,
mais supervision.env.j2 l'interpole toujours sans garde et la clé n'est dans
aucun coffre : vault.yml est identique à celui de develop, develop ne
référence la variable nulle part, et le compte de service ci-alertes n'existe
pas encore.

Rejoué en local sur le gabarit réel, sans la clé :

  fatal: [localhost]: FAILED! => {"censored": "the output has been hidden
  due to the fact that 'no_log: true' was specified for this result"}
  'vault_forge_alerte_token' is undefined

La tâche « Rendre les secrets de supervision » est la dixième sur trente du
rôle app. Tout ce qui suit ne tourne pas : MinIO, le certificat TLS, les
quatre piles, l'environnement Python, la crontab, les migrations. Et comme
deploy.yml joue --tags app,proxy, c'est le déploiement continu entier.

La garde est dans le gabarit et pas dans defaults/main.yml : ansible-lint
exige le préfixe app_ pour toute variable posée dans les défauts d'un rôle
(var-naming[no-role-prefix], profil moderate), ce qui aurait fait rougir la
chaîne. Le gabarit est de toute façon l'endroit où le caractère facultatif du
jeton se lit.

Éprouvé dans les deux sens : sans la clé le rendu donne FORGE_ALERTE_TOKEN=,
avec une clé au coffre il donne sa valeur. ansible-lint passe au profil
production, tests/ci/test-supervision.sh reste vert.
Merge develop dans la branche des alertes (#42)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 21s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m27s
41e0b097f6
Conflits résolus :
- vault.yml.example : les deux clés coexistent, comme dans 0a59d46. La note
  qui expliquait la reprise manuelle de develop tombe, develop la fournit
  maintenant nativement.
- PRD.md, ENF-08 : la ligne de develop est retenue. Celle de la branche
  annonçait les cinq alertes comme livrées alors que la demande est encore
  ouverte.

# Conflicts:
#	docs/PRD.md
#	infra/ansible/group_vars/all/vault.yml.example
.env.example documentait la variable, mais rien ne l'appliquait : la CI
construisait sans, et un paquet livré sans base d'API appelle « /v1/… » en
relatif. Caddy ne relaie que « /api/* » ; le reste tombe sur try_files et rend
index.html en 200, que le front essaie de lire en JSON. La panne est totale,
silencieuse, et ne se voit qu'une fois déployée.

vite.config.js refuse donc la construction de production sans elle. Une
assertion et non une valeur par défaut : un défaut plausible (« /api »)
marcherait sur la pile actuelle et masquerait le jour où elle change.

Le fichier exporte désormais une fonction, pour connaître la commande et le
mode ; vitest.config.js l'appelle avec le contexte des tests, mergeConfig ne
sachant pas fusionner une fonction. Le job du tableau de bord passe la
variable, sinon l'assertion le mettrait au rouge sans rien apprendre à
personne. Le paquet réellement déployé reste construit par le rôle app (#94).

Trois tests gardent l'assertion : elle refuse une production sans base, laisse
passer le développement et les tests, et laisse passer une production réglée.

Relecture de la #139.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFGp2MvuHVuoJEss2G5nn1
dashboard: le profil horaire du parc se lit, et les corrections de relecture (#24)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 30s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m57s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 2m59s
d05ca020f0
Le profil horaire était le dernier critère du #24 en défaut : une polyligne de
20 px, aria-hidden, au fond du pavé « consommation ». mesuresParc était appelé
mais son résultat ne servait qu'à décorer — une courbe sans axe montre une
forme, jamais une valeur ni une heure, et aria-hidden la retirait de l'arbre
d'accessibilité sans rien mettre à la place.

Il a donc sa propre carte : deux axes gradués, l'un en heures UTC, l'autre en
kW depuis zéro, un résumé lu à la place du dessin (bornes et heure des
extrêmes) et un tableau hors écran qui porte les vingt-quatre valeurs. En SVG
et non avec Chart.js : une polyligne à deux axes ne vaut pas la dépendance, et
le SVG reste dans le DOM, donc inspectable.

Le reste de la relecture :

- glossaire.js rendait PALIER[palier].libelle sans repli, quand libelleType et
  libelleSeverite ont le leur : un palier hors référentiel faisait tomber
  TableauSites sur un TypeError.
- chargerSites() n'avait pas de catch et part d'un onMounted. Au-delà du rejet
  non géré, afficherTousLesSites() n'était jamais atteint : la liste restait
  vide, sans état d'erreur ni reprise, là où chargerSynthese fait déjà les
  deux. Elle les fait maintenant aussi.
- le titre et le pied d'infobulle du graphique annonçaient des kWh sur des
  données en kW (valeur_kw).
- TEINTES (variables CSS) et TEINTES_SERIE (hex) décrivaient la même palette
  dans deux fichiers, que jetons.css pouvait désynchroniser sans bruit. Une
  seule source, et un test qui relit jetons.css : la désynchronisation fait
  désormais échouer un test au lieu de peindre deux graphiques discordants.
  La classe .carte, elle aussi, devient commune plutôt que recopiée.
- ecartTexte(null) laissait « par rapport à hier » seul dans le pavé, ce qui
  se lit comme un écart nul. Une absence de mesure n'est pas une stabilité.
- la disponibilité sous cible ne se signalait que par la couleur (RGAA 1.4.1) :
  elle est écrite, la teinte ne fait que la redoubler.
- l'en-tête de GraphiquesView disait encore la coquille de navigation « hors
  périmètre », montée depuis 76e295d.

207 tests vitest passent, la construction de production est propre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFGp2MvuHVuoJEss2G5nn1
Décision d'Olivier le 07/09, qui retient l'ADR 0012 plutôt que le critère
d'origine du ticket. Le ticket #36 a été modifié en conséquence sur la forge :
titre, premier critère — « en local sur le serveur de la salle » au lieu de
« sur le Tesla T4 » — et preuve attendue, où l'identifiant d'exécution MLflow et
la version portant l'alias remplacent la sortie de `nvidia-smi`.

Le motif est celui qu'EXIGENCES-collectives.md §1 donne déjà : « le facteur
limitant est l'historique disponible, pas la puissance de calcul ». Sept sites,
un point par heure, quelques semaines : quelques milliers de lignes, où un GPU
n'apporterait rien de mesurable et coûterait une pile CUDA sur le serveur.
ENF-03 est tenue autrement — l'entraînement reste local et la mesure brute ne
quitte pas le réseau de la salle.

Écart consigné, pas tranché : EXIGENCES-collectives.md §1 porte encore
« Entraînement du modèle — en local, sur le Tesla T4 du serveur — arbitré le
01/09 ». Cette ligne se lit désormais « en local, sur le serveur qui porte le
T4 », lecture que l'ADR 0012 retient. Le fichier collectif n'est pas modifié
ici : c'est au groupe de le faire, comme pour le cas Keycloak.

Ce que le changement retire : `--device`, la sonde CUDA, `GpuIndisponible`, le
code de sortie 3 pour GPU absent, l'échantillonnage `nvidia-smi` du lanceur et
son artefact. Le code 3 sert maintenant à une dépendance manquante, ce que le
lanceur contrôlait déjà.

Ce que le changement apporte, et qui compte plus que le régresseur lui-même :
`early_stopping=False`. Laissé sur son défaut « auto », scikit-learn met de côté
10 % des exemples TIRÉS AU HASARD dès que l'échantillon dépasse dix mille
lignes. Sur une série temporelle, ce tirage est exactement la fuite que
`donnees.decouper` évite — la validation interne contiendrait des heures
postérieures à celles qu'elle sert à valider — et il ferait dépendre le résultat
d'un découpage qui n'est pas le nôtre, ce que le quatrième critère interdit.
`test_le_regresseur_desactive_l_arret_anticipe` le garde ; il est sauté là où
scikit-learn n'est pas installé et joué par la chaîne, qui l'installe depuis le
manifeste du service.

Le choix sert d'ailleurs ce quatrième critère : il n'y a plus de réduction
flottante sur GPU dont l'ordre varie d'une exécution à l'autre. La rejouabilité
reste mesurée par `entrainer.sh --deux-passes` et non affirmée — le nombre de
fils OpenMP peut encore faire varier les derniers chiffres, et
`OMP_NUM_THREADS=1` est le réglage à essayer si l'écart apparaît.

Le modèle est enregistré avec la saveur `mlflow.sklearn`. Le service du #37 le
charge par `mlflow.pyfunc.load_model` et n'a pas à connaître la saveur, mais il
lui faudra scikit-learn pour désérialiser l'objet.

Contrôles joués : ruff et mypy strict propres sur `services` et `packages`,
674 tests au vert et 1 sauté, couverture globale 89 %.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into lenaic/39-regles-recommandations
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m23s
a9daba7592
Merge branch 'marvin/25-connexion-session' into marvin/24-ecran-parc
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 28s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m59s
da5b4d25ae
App.vue et App.test.js avaient été écrits deux fois : une première ici, le
03/09, parce que le menu du compte avait besoin de la reprise de session ; une
seconde sur la branche de la #138, le 04/09, quand justine y a signalé que
« reprendre() » n'était appelée de nulle part. Deux créations indépendantes
depuis un ancêtre où le fichier n'existait pas — d'où un conflit de contenu et
un conflit add/add, et non deux intentions qui divergent.

La version de la branche de base est retenue pour les deux fichiers. Les
App.vue sont fonctionnellement identiques (même import, même
onMounted(session.reprendre)), seul le commentaire diffère ; son App.test.js,
lui, couvre un cas de plus, la rotation de l'accès sur 401.

209 tests vitest passent, la construction de production est propre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFGp2MvuHVuoJEss2G5nn1
Le cluster porte `enervision_prod` et `enervision_preprod`. L'entraînement
visait la première par la seule variable que l'ETL pose ; il peut désormais
viser l'une ou l'autre par `ENERVISION_MODEL_ENVIRONNEMENT`, ou par
`--environnement prod|preprod` qui la surcharge. Défaut `prod` : c'est la base
que l'API sert, donc la seule dont un modèle promu doit avoir appris les
habitudes.

Le lanceur compose l'URL, jamais le Python : même contrat que le collecteur et
l'ETL, aucun chemin de secret dans le paquet. Trois sources par priorité —
`ENERVISION_MODEL_DATABASE_URL` déjà posée, qui est l'échappatoire vers une
copie ou un tunnel ; `ENERVISION_ETL_DATABASE_URL` pour `prod`, celle que le
rôle Ansible `app` pose et que le chargement de la zone or emploie déjà, pour
ne pas écrire deux fois la même URL ; la composition locale depuis le mot de
passe du rôle applicatif, qui porte le nom de sa base et jamais `postgres`.

Un `case` explicite plutôt qu'un `eval` sur un nom de variable calculé : deux
environnements, et un `eval` sur une valeur venue de la ligne de commande n'a
aucune raison d'exister dans ce script. L'URL n'est ni journalisée ni passée en
argument visible de `ps` : elle est exportée, et le module ne publie que le NOM
de la base.

CE QUE LE CHOIX IMPLIQUE, ET QUI COMPTE AUTANT QUE LE CHOIX. Pouvoir viser deux
bases, c'est pouvoir apprendre ailleurs que là où le modèle servira. L'alias
`production` étant ce que le service du #37 charge au démarrage, un modèle
appris sur la préproduction qui le prendrait servirait des prévisions apprises
sur des données qui ne sont pas celles de la base servie — et rien, dans le
registre, ne le dirait.

Deux garde-fous, donc.

`promotion_autorisee` refuse la promotion hors `enervision_prod`, sauf
`--promouvoir-hors-prod`. Le modèle est publié dans les deux cas : c'est
l'alias qui ne bouge pas, pas l'exécution qui disparaît. Une fonction pure
plutôt qu'un `if` dans `executer`, parce que les règles de ce paquet se testent
sans base ni registre.

Et c'est `base_de_l_url` qui décide, pas le réglage. Le nom de la base est relu
dans l'URL, seul des deux qui ne puisse pas mentir : un
`ENERVISION_MODEL_ENVIRONNEMENT=prod` sur une URL pointée vers la préproduction
passerait tous les contrôles fondés sur le réglage. Ce nom entre aussi dans le
motif de l'étiquette `promu_motif` — la seule mémoire du geste, le registre
n'étant pas versionné dans git — parce qu'une MAE de 3 kW ne veut pas dire la
même chose selon la base qui l'a produite.

Contrôles joués : ruff et mypy strict propres, 692 tests au vert et 1 sauté,
neuf tests nouveaux sur l'environnement, la relecture d'URL et le garde-fou.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into marvin/24-ecran-parc
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 26s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m18s
225db5186e
Deux défauts relevés à la première exécution réelle, le 7 septembre, et que seul
un vrai modèle pouvait montrer.

`register_model("runs:/<id>/modele")` ne visait rien. MLflow 3 ne range plus un
modèle sous les artefacts de son exécution mais comme une entité à lui, d'URI
`models:/m-<id>` : le chemin n'existait pas, et l'enregistrement ne réussissait
que par le repli du serveur — « Run with id ... has no artifacts at artifact
path 'modele', registering model based on models:/m-... instead ». Un repli qui
avertit aujourd'hui est un échec demain. L'enregistrement passe désormais par
`registered_model_name` dans `log_model`, dont le retour porte la version ; le
repli sur `info.model_uri` couvre une version de MLflow qui ne la porterait pas,
et c'est l'URI que le serveur vient de créer, pas un chemin reconstruit.

La signature et l'exemple d'entrée décrivaient le même objet de deux façons.
Signature déduite d'une liste de listes, exemple donné en tableau NumPy : MLflow
refusait de valider l'exemple qu'il venait d'écrire — « Invalid input. Invalid
object type at position 0 ». Les deux viennent maintenant du même tableau. NumPy
est déclaré au manifeste pour cette ligne, même si scikit-learn l'installerait
de toute façon : on déclare ce qu'on importe, comme `pytz` dans l'ETL.

ÉPROUVÉ SUR LE SERVEUR, trois exécutions successives sur `enervision_preprod`,
bornes 2026-07-30 → 2026-09-04 : plus aucun avertissement, versions 1 puis 2
puis 3 enregistrées, alias `production` déplacé à chaque fois — et la lecture de
la version en place avant déplacement donne bien « 2 -> 3 », ce que la première
exécution ne pouvait pas montrer faute de version précédente.

MAE identique au millième aux trois exécutions, 2,018 kW contre 21,443 kW pour
la persistance : la rejouabilité du quatrième critère est constatée, pas
supposée.

Le chargement par alias est éprouvé pour la première fois du projet :
`models:/enervision-prevision-h1@production` rend un PyFuncModel et sa version.
C'est la seule commande de `docs/runbooks/mlflow.md` §5 qui n'avait jamais pu
être jouée — « elle le sera au premier modèle du #36 » — et c'est la couture que
le #37 empruntera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dashboard: la traçabilité s'atteint au clavier, et se distingue à l'œil (#24)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 59s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m58s
3bbc9d28b6
Relecture de la #140.

Accès clavier. La traçabilité se prenait au clic seul. Le conteneur portait
déjà « tabindex="0" » — l'intention était là — mais aucune touche n'était
écoutée : au clavier, la traçabilité était hors d'atteinte. Les flèches
parcourent les barres, Home et Fin mènent aux bouts, Entrée ouvre la
traçabilité de la barre désignée. Un anneau la marque sur le canevas, et une
ligne sous le graphique dit ce qu'elle vaut — l'anneau dit laquelle, jamais
combien.

Valeur reconstituée. Elle se distinguait par la même teinte à 40 %, que la
légende répétait à l'identique. Deux nuances d'une même teinte se distinguent
mal sous faible contraste, et l'aplat clair ne tenait pas les 3:1 attendus
d'un élément graphique. La barre porte maintenant des hachures et un contour :
la forme dit la différence, la couleur ne fait que la redoubler. La pastille
de légende reprend les mêmes hachures.

Traçabilité hors 24 h. L'API ne trace qu'un point de la fenêtre 24 h ; au-delà
le pas est journalier et une barre agrège vingt-quatre relevés. Le clic
partait quand même, et rendait 404 sur toutes les barres sauf la dernière —
son début de journée tombant encore dans les vingt-quatre dernières heures.
On ne le propose plus, et on dit pourquoi, au lieu de le faire découvrir par
une erreur. La condition porte sur le pas rendu par l'API, pas sur la valeur
du sélecteur.

Traductions. LIBELLE_METHODE et LIBELLE_QUALITE vivaient dans
TracabiliteDetail.vue, alors que glossaire.js dit porter la traduction des
valeurs d'énumération « et nulle part ailleurs dans le front ». Elles y
rejoignent les autres, avec le repli des voisines.

Unité. Le titre annonçait des kWh sur des valeur_kw. GraphiqueConsommation.vue
porte la même faute mais n'est pas touché ici : elle est déjà corrigée sur la
branche de la #139, et la corriger deux fois est exactement ce qui a produit
les conflits sur App.vue.

224 tests vitest passent, la construction est propre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFGp2MvuHVuoJEss2G5nn1
Merge pull request 'dashboard : écran Parc — synthèse, comparaison des sites, navigation (#24)' (#139) from marvin/24-ecran-parc into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 25s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m33s
2ed259ef87
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/139
Merge branch 'develop' into gabriel/42-alertes
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 28s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 54s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m24s
7f66c028f6
Merge branch 'develop' into marvin/24-ecran-site
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m33s
82de219c22
docs: l'ADR 0012 ferme la rejouabilité, l'antériorité et la trace de l'entrée (#36 #37)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m29s
0be9631802
Trois manques relevés par Justine en relecture de la #161, et qui étaient
réels : la fiche déclarait l'historique non déterministe sans dire comment le
CA4 du #36 est tenu, l'antériorité d'EF-08 ne vivait plus nulle part depuis
que le contrat a retiré emise_le < horodatage, et le CA3 du #37 n'avait aucun
emplacement écrit.

- rejouabilité : random_state fixé, fenêtre bornée par deux instants UTC
  explicites, et surtout le jeu figé et enregistré comme entrée de l'exécution.
  Sans le troisième les deux premiers ne prouvent rien, une réimputation de la
  zone argent suffit à changer le résultat.
- antériorité : elle appartient au job à l'émission en ligne, pas au contrat.
  Le contrat garde son choix, une règle qui interdit le rejeu se contourne en
  trichant sur l'horodatage.
- trace de l'entrée : les vingt quatre retards vont au journal du job et à
  l'exécution MLflow, pas dans public.prevision, qui est une hypertable
  comprimée à une ligne par site et par heure.

Et le choix type de site contre site_id devient un relevé plutôt qu'un débat :
le référentiel ne confond que deux paires, SITE001/SITE006 et SITE002/SITE007,
les deux plus proches du parc. Le #36 entraîne les deux variantes et garde
celle qui gagne à la MAE.
Merge pull request 'dashboard : écran Site — fiche, courbe, recommandations, traçabilité (#24)' (#140) from marvin/24-ecran-site into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 30s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m22s
ce58c07e36
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/140
Reviewed-by: justine <justine@noreply.10.105.200.41>
Merge branch 'develop' into florian/43-plan-migration-cloud
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 29s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m23s
c4e1e61fe5
infra: les trois retours de Florian sur la #159, avec le banc qui les garde
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m25s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m25s
fa69e14d53
1. Un verrou partagé entre les deux passes de la zone or.
   Elles étaient chaînées par dix minutes de crontab, et agreger_un_jour écrit
   deux partitions successives et NON atomiques. Une passe qui déborde, ou un
   rejeu manuel entre les deux, et le chargement lit une partition à moitié
   réécrite qu'il grave en base par son on conflict do update.
   flock -w 900 sur /var/log/enervision/.zone-or.lock, code 75 quand l'attente
   expire. 75 et pas 4 : les deux modules rendent déjà 2, 3 et 4, un code
   partagé rendrait « verrou non obtenu » indiscernable de « partition absente »
   dans gold.log.
   Éprouvé en conditions réelles : deux lanceurs concurrents, le second sort en
   75 avec son message, le premier en 0.

2. Le mot de passe est encodé dans l'URL.
   Le gabarit décrivait le piège et remettait le correctif au jour de la panne.
   urlencode seul ne suffit pas, Jinja garde « / » dans les caractères sûrs :
   le replace derrière ferme ce trou.
   Pas l'assertion sur le jeu de caractères que proposait Florian : le coffre
   est chiffré, personne ne relit sa valeur avant de déployer, et une assertion
   sur un mot de passe qu'on ne peut pas inspecter casse le déploiement le jour
   où elle a tort. L'encodage est vrai quelle que soit la valeur.
   Aller-retour prouvé sur « p@ss/w:rd?#100%x ».

3. Le banc du rôle app lit enfin les gabarits.
   Il ne lisait que tasks/main.yml : ENERVISION_ETL_DATABASE_URL pouvait
   disparaître de postgres.env.j2 sans qu'un contrôle bronche. Trois contrôles
   ajoutés, sur le gabarit, sur les tâches planifiées lues en structure, et sur
   le verrou partagé.

Le banc est éprouvé dans les deux sens : vert sur l'arbre sain, ROUGE sur les
cinq défauts réintroduits un par un, et chaque fois sur le contrôle visé. Le
contrôle du verrou est d'ailleurs passé au vert à ma première rédaction, parce
qu'il cherchait le mot « flock », qui survit dans les commentaires. Il lit
maintenant les lignes exécutées.
docs: traite la relecture de Justine sur le plan de migration
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m26s
93ebc76046
Deux corrections de fond. Le §6 chiffrait l'archive sur « 30 j de rétention »
alors que le §5 et l'ADR 0012 disent la politique de cycle de vie refusée :
l'hypothèse de calcul contredisait la conclusion de la même page. La ligne dit
désormais « croissance non purgée », 1,2 Gio par an, un centime de plus par mois
chaque année — le total ne bouge pas d'un ordre de grandeur. Et les 18 Gio/an de
la zone bronze sont extrapolés sur deux jours dont un partiel : ils se
présentaient comme mesurés dans un document bâti sur cette distinction. Ils sont
marqués, et la condition 2 devient un ordre de grandeur, pas une échéance.

Le reste lève des ambiguïtés. Le B2ms portait trois valeurs selon l'endroit —
59,20 € de calcul, 68,27 € avec son disque, et 118 € qui était en fait le B4ms :
le SKU et le périmètre sont nommés partout. Le tarif Blob est rejouable, le
catalogue portant trois compteurs « Cool LRS Data Stored » dont un qui n'est pas
du blob. « plafond_lu_sur_azure » ne coïncide par construction qu'après un
apply : sa description exige maintenant « plan -refresh-only » plutôt que de
laisser croire à un contrôle continu. Et prevent_destroy bloque aussi tout
renommage du conteneur, en erreur dure au plan — le runbook porte la manœuvre.

Le backlog suit enfin le PRD : #43 n'est plus « pas démarré ».
infra: le jeton du relais d'alerte entre au coffre (#42)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 29s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m23s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m31s
874fbff643
vault_forge_alerte_token, jeton du compte de service ci-alertes, portée
write:issue. Il manquait depuis l'ouverture de la #152 : le gabarit
supervision.env.j2 l'interpole, et sa garde default('') n'existait que pour
que le rôle app ne casse pas en son absence.

Le fichier reste chiffré en AES256, seule sa taille change.
infra: la clé de signature de l'API ne prend plus les quatre piles en otage (#113)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 58s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m31s
e6a6b77ead
vault_api_jwt_secret était dans l'assertion d'entrée du rôle app. Sans clé au
coffre, ce n'était pas l'API qui échouait mais le rôle ENTIER : PostgreSQL,
MinIO, MLflow, le certificat TLS, l'environnement Python, la crontab et les
migrations. Et comme deploy.yml joue --tags app,proxy, une fusion vers main
cassait tout le déploiement continu, sur une machine qui collecte depuis
quatre jours.

C'est le défaut que j'ai formellement bloqué chez Gabriel sur la #152, dans le
même fichier et à la même ligne. Je le corrige avant de le faire relire.

Le rôle savait déjà faire mieux, et la déclaration de la pile api le dit
noir sur blanc dans group_vars/all/vars.yml : « sans api.env, la pile est
SAUTÉE bruyamment au lieu de démarrer une API qui refuserait de servir ». Le
rendu de api.env est donc conditionné à une clé d'au moins 32 caractères, et
une tâche dit à voix haute pourquoi le fichier n'a pas été écrit.

Et surtout PAS un default('') comme pour le jeton d'alerte de la #42 : un
jeton d'alerte vide fait taire une notification, une clé de signature vide
ferait accepter des jetons forgés. Ici le bon comportement est de ne rien
écrire du tout.

Deux contrôles ajoutés au banc du rôle pour que ça ne revienne pas, l'un sur
l'assertion d'entrée, l'autre sur le rendu conditionné. Ils lisent les lignes
exécutées, commentaires retirés : le nom du secret figure dans l'explication
juste en dessous, et un contrôle qui lirait le bloc brut resterait vert.

Éprouvé en rouge sur les deux défauts réintroduits un par un. ansible-lint
profil production, yamllint propre, trois playbooks à la syntaxe valide, banc
12 ok.
Merge branch 'develop' into olivier/36-entrainement-modele (#36)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 32s
Intégration / Contrôles statiques du dépôt (pull_request) Failing after 3s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 2m9s
91a3d4951f
inference: cite l'ADR 0012 sans lien tant qu'elle n'est pas dans develop (#36)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 28s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 3m49s
46869ff5fd
`tests/ci/test-liens-markdown.sh` a rougi la chaîne sur la PR #166, et il avait
raison : `services/inference/README.md` renvoyait à
`../../docs/adr/0012-prevision-h1-avec-reference-publiee.md`, fiche qui vit sur
`lenaic/36-contrat-prevision` et n'est pas fusionnée. Un renvoi qui ne mène
nulle part se découvre en panne, c'est-à-dire au moment où quelqu'un cherche la
décision.

La fiche est donc citée par son numéro, sans lien, avec une note qui dit
pourquoi et quand le lien se posera. Même traitement dans la docstring de
`modele.py`, où le chemin relatif ne menait nulle part non plus — le contrôle ne
lit pas les fichiers Python, mais un chemin faux dans un commentaire trompe
autant.

Le contrôle des images de `verifier-images.sh` échoue sur un poste macOS pour
une raison sans rapport — « awk: newline in string », le BSD awk n'accepte pas
ce script — et la branche ne touche aucun fichier d'image. Les six autres
contrôles statiques passent en local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge pull request 'infra: publie le plan de migration cloud chiffré et le plafond' (#163) from florian/43-plan-migration-cloud into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 31s
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Aucun secret commité (push) Successful in 3s
Intégration / Python — qualité, tests et dépendances (push) Successful in 4m10s
56ff4c4952
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/163
Reviewed-by: justine <justine@noreply.10.105.200.41>
Les alertes de la source s'arrêtaient en zone bronze : 11 546 objets collectés
au fil de l'eau depuis le 3 septembre, que rien ne transformait. Ce commit pose
la quatrième table de la zone argent, `silver.alerte`, et rien d'autre : le
parseur d'enveloppe et l'écriture suivent.

`alerte` est la seule des quatre tables qui ne soit pas une série régulière —
un journal d'événements, sans grille ni imputation. Elle est en zone argent et
non directement en zone or parce que le collecteur lui délègue déjà un
traitement : une alerte à l'horodatage illisible est rangée sous « dt=inconnu »,
et `collector/alertes.py` écrit que la zone argent « la replacera d'après son
contenu ». Personne ne l'avait écrit.

Les noms de colonnes restent ceux de la source, comme le fait `silver.mesure` :
c'est la zone or qui traduit en `severite` et `valeur_kw`, à un seul endroit.
`alert_type` et non `type`, qui est un mot réservé de DuckDB. `site_type` et
`capacity_kw` sont recopiés du référentiel pour que la zone or obtienne le taux
de charge de `public.alerte` sans jointure. Ni `_raw` ni `_method` : une alerte
n'est ni validée ni imputée, elle est historisée telle que servie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
inference: une version de modèle sans exécution n'a pas de métrique (#36)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 31s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m9s
b4df56e9b9
La chaîne a rougi sur mypy à la PR #166, et c'est un vrai défaut, pas une
chicane de typage : `ModelVersion.run_id` est facultatif côté MLflow, et
`metrique_de_version` le passait tel quel à `get_run`.

Le cas existe — une version enregistrée depuis un chemin externe plutôt que
depuis une exécution n'a pas d'exécution à relire — et la panne serait un
`MlflowException` au milieu d'une comparaison de versions, là où le protocole
promet déjà de pouvoir répondre « on ne sait pas ». Il rend donc `None`.

CE QUE CET ÉCHEC APPREND SUR NOTRE FAÇON DE VÉRIFIER. Le venv de travail du
dépôt ne porte que l'outillage — ruff, mypy, pytest — et aucune dépendance
applicative. mypy y traite donc `mlflow` par `ignore_missing_imports` et ne voit
rien ; la chaîne, elle, installe les manifestes des services via
`.forgejo/scripts/deps-services.py`, obtient les vrais types et trouve ce que le
poste ne peut pas trouver. Un `ignore_missing_imports` n'est pas un blanc-seing,
c'est un angle mort qui se déplace d'une machine à l'autre.

Rejoué dans un environnement fidèle à la chaîne — Python 3.12.3, scikit-learn,
MLflow et NumPy installés : ruff propre, mypy « Success », et 96 tests au vert
au lieu de 95 plus un sauté. Le test conditionnel
`test_le_regresseur_desactive_l_arret_anticipe` est bien exécuté là où
scikit-learn existe, ce qui était son intention : il vérifie sur le vrai
régresseur que l'arrêt anticipé est désactivé et que la graine est celle qu'on
lui donne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suite du schéma posé au commit précédent. Le job `silver_daily` écrit désormais
quatre tables : la quatrième porte les alertes que la source sert et que rien ne
transformait depuis bronze.

`charger_alertes` lit DEUX partitions par site — celle du jour et « dt=inconnu »
— puis retient chaque alerte sur la date de SON horodatage, jamais sur le
segment « dt= » de sa clé bronze. C'est le replacement que `collector/alertes.py`
délègue à cette zone depuis le #107 et que personne n'avait écrit. Le corollaire
est qu'une alerte lue sous « dt=inconnu » mais datée d'un autre jour est écartée
ici : elle appartient à la passe de ce jour-là, qui la retrouvera au même
endroit. Rien ne se perd, rien ne se duplique.

`verifier_enumerations_alertes` refuse un type ou une sévérité que la contrainte
de `public.alerte` rejetterait, avant d'écrire — même parti pris que
`gold.agregation` : attrapée ici, la faute nomme l'alerte ; attrapée au
chargement, elle sort deux jobs plus loin sur un numéro de ligne.

La partition des alertes est réécrite même vide. Une journée dont les alertes
ont disparu de bronze doit voir sa partition se vider, sinon la zone or
chargerait indéfiniment celles d'un rejeu antérieur. Un test le garde.

Vérifié sur les alertes réelles du 2026-09-06, sept sites, en lecture seule :
2 984 alertes retenues, soit exactement le nombre d'objets que bronze porte pour
ce jour ; 2 984 `alert_id` distincts ; toutes datées du jour traité ; le schéma
déclaré et la ligne produite portent les mêmes colonnes dans le même ordre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
Trente cas sur `etl.silver.alertes`, qui passe à 100 % de couverture, et sur les
deux fonctions du job qui s'en servent.

Ce que les cas gardent, au delà du nominal :

- l'horodatage n'est PAS tronqué à la minute, contrairement à celui d'une
  mesure. `public.alerte` est unique sur (site_id, horodatage, type, source) :
  tronquer ferait de deux alertes du même type à douze secondes d'écart une
  seule alerte, et la seconde disparaîtrait sans que rien ne le dise ;
- une alerte rangée sous « dt=inconnu » est replacée sur la journée de son
  horodatage, et une alerte datée d'un autre jour est laissée à la passe de ce
  jour-là — rien ne se perd, rien ne se duplique ;
- une alerte sans identifiant, sans site ou sans date lisible est écartée et
  non inventée : elle reste en bronze, et un identifiant fabriqué casserait la
  déduplication que le collecteur garantit ;
- un objet corrompu, une enveloppe d'échec ou un élément non-objet dans un
  tableau ne font pas perdre le reste du lot ;
- la ligne produite porte exactement les colonnes du schéma déclaré ;
- la sentinelle « inconnu » est recopiée et non importée du paquet du
  collecteur : un cas garde l'identité des deux valeurs.

Le fichier s'appelle `test_alertes_silver.py` et non `test_alertes.py` :
`tests/unit/collector/test_alertes.py` porte déjà ce nom, les répertoires de
test n'ont pas d'`__init__.py`, et pytest refuse deux modules homonymes — la
collecte de la suite entière échoue, alors que le fichier passe isolément. Les
deux correctifs globaux, poser des `__init__.py` ou passer en
`--import-mode=importlib`, casseraient l'import des aides `_echantillons` et
`_doubles`, qui sont des modules de premier niveau. Le suffixe coûte moins, et
la raison est écrite en tête du fichier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
docs: la zone argent porte quatre tables, dont le journal des alertes (#165)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 29s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m35s
572a08125d
Le §4 définissait la zone argent comme une « série régulière ». Elle porte
désormais aussi un journal d'événements, et un lecteur qui s'arrête au §4 doit
le savoir.

`docs/data/etl-pipeline.md`
- §2 : deux lignes d'état séparées pour les alertes, bronze d'un côté, argent de
  l'autre, la seconde disant ce qui manque encore — la zone or ne les lit pas ;
- §4 : la zone argent n'est plus décrite comme une seule série régulière ;
- §10 : le schéma de `silver.alerte`, et les quatre décisions qui l'expliquent —
  pourquoi elle est en argent et pas en or, pourquoi sa clé n'est pas
  `(site_id, ts)`, pourquoi son horodatage n'est pas tronqué à la minute, et
  pourquoi une valeur d'énumération étrangère fait échouer la passe ;
- §11 : quatre chemins d'écriture, et la règle de la partition réécrite même
  vide ;
- §14 : le point « chemin des alertes au delà de bronze » est refermé, avec ce
  qu'il reste à faire ;
- §15 : l'EF-05 pointe désormais sur la zone argent et son fichier de test.

`docs/runbooks/etl.md` : quatre objets par journée et non trois, et le fait
qu'une partition d'alertes vide n'est pas une panne — c'est ce qu'un exploitant
verra en premier avec `mc ls`.

`services/etl/README.md` : la quatrième table et ce qui la distingue des trois
autres.

Un commentaire de `job.py` disait encore « trois tables vides ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
recommandations: deux pertes silencieuses, et le module enfin typé (#39)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m54s
5d230d546a
Trois défauts trouvés en relisant ma propre branche avant de la faire fusionner.

1. rapprocher() perdait une recommandation sans trace.
   `emises` était une compréhension de dictionnaire sur (site, règle) : deux
   observations du même site dans une passe, et la seconde écrasait la
   première. Ce n'est pas théorique, evaluer() boucle sur les observations et
   les trie par (site_id, instant), ce qui montre bien qu'on en attend
   plusieurs. La plus récente est retenue, l'autre est RENDUE à l'appelant dans
   Rapprochement.doublons au lieu de disparaître.

2. en_ligne() rendait une copie de surface.
   dict(self.valeurs) ne copie que le premier niveau, or les valeurs de R2
   portent deux listes. Un appelant qui triait ou vidait valeurs["heures"]
   réécrivait la justification d'une recommandation déclarée frozen, c'est à
   dire exactement ce qui permet de refaire le jugement six mois plus tard.
   Copie profonde ici et dans le moteur.

3. services/recommendations/ n'avait AUCUN pyproject.toml.
   La boucle mypy de la chaîne n'ouvre que ceux qui déclarent [tool.mypy] : le
   module n'était pas typé du tout, pendant que la demande annonçait
   « mypy --strict propre ». Il l'est maintenant, 11 fichiers, sans erreur. Le
   format suit les 100 colonnes des autres services.

Et un test qui affirmait plus qu'il ne vérifiait : il s'appelait « les valeurs
déclenchantes sont celles du ticket » et exigeait la clé `heures`, absente du
#153. Il dit maintenant que c'est un ajout, et pourquoi.

Les trois correctifs sont éprouvés dans les deux sens : rouge sur chaque défaut
réintroduit un par un. Le premier test de la copie profonde est d'ailleurs
passé au vert sur le défaut, parce qu'il comparait la liste à elle même ; il
compare maintenant à un littéral, et le piège est écrit dedans.

103 tests, 100 % de couverture sur 299 instructions.
Merge develop dans la branche du contrat de prévision (#36)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m44s
8dd45e3f9c
# Conflicts:
#	docs/adr/README.md
Merge develop dans la branche de la pile app (#113)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m21s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m48s
71dd68319b
Merge develop dans la refonte de la chaîne (#158)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 31s
Intégration / Contrôles statiques du dépôt (pull_request) Failing after 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 25s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m22s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m28s
2a432c6c5a
# Conflicts:
#	.forgejo/workflows/ci.yml
ci: le contrôle des secrets n'est plus rouge sur un test unitaire sain (#158)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 8s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 32s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 25s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m4s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m23s
e61f26e1fd
services/dashboard/tests/unit/authentification.test.js, arrivé avec develop,
affecte un littéral de neuf caractères au champ d'identification d'un
utilisateur d'essai. C'est la forme exacte que le motif cherche, et c'est du
code parfaitement correct : le job « Contrôles statiques du dépôt » est passé
rouge sur cette branche pour ça.

Les arbres de tests sont donc exclus, comme le sont déjà les fixtures et les
fichiers d'exemple. Un identifiant d'essai dans un test unitaire est normal ;
un secret qui fuit ne vit pas là. Le commentaire du contrôle disait déjà
pourquoi un contrôle rouge sur du code sain est désarmé dans la semaine.

Au passage : la première rédaction de ce commentaire citait le littéral en
toutes lettres et déclenchait le contrôle qu'elle explique. Elle le décrit
maintenant.
Merge pull request 'contracts : le contrat de prévision H+1 et sa référence publiée' (#161) from lenaic/36-contrat-prevision into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Aucun secret commité (push) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 31s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m41s
1d516ab55f
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/161
Reviewed-by: justine <justine@noreply.10.105.200.41>
La table de la 0012 a été posée avant que les écrans ne soient
contractualisés. Confrontée à `AlerteOut`, le modèle de sortie que le tableau de
bord consomme, il lui manquait de quoi répondre à trois questions que l'écran
Qualité pose : que dit l'alerte, est-elle encore ouverte, et d'où vient-elle.

- `libelle` : la seule phrase que la source produise. Sans elle, elle s'arrête à
  la zone argent et l'écran reconstruit un libellé générique depuis le type —
  plus pauvre, et faux dès que la source précise ce que le type ne dit pas.
- `etat`, `not null default 'ouverte'` : `/api/v1/alerts` rend les alertes
  COURANTES, pas un historique. Le défaut vaut donc pour les lignes déjà
  chargées comme pour celles à venir.
- `cle_bronze` : l'ENF-07, comme `public.mesure` la porte depuis la 0010. Rien
  ne justifiait qu'elle s'applique à la mesure et pas à l'alerte. La clé porte
  l'`alert_id` de la source dans son dernier segment, ce qui rend cet
  identifiant retrouvable sans lui donner sa colonne.

Plus un index partiel sur les seules alertes ouvertes : une fois l'historique
accumulé, elles sont la minorité, et c'est ce que l'écran Qualité et la synthèse
du parc interrogent.

CE QUI N'EST PAS AJOUTÉ. `titre`, `description` et `exigence` sont des mises en
forme, et la 0012 pose déjà la règle à propos du taux de charge : « le
pourcentage est une mise en forme, elle appartient à l'affichage ». Les stocker
ferait de chaque changement de formulation une migration. `resolue_a` non plus :
rien ne résout d'alerte aujourd'hui, la colonne viendra avec ce qui les
résoudra, comme au #164 pour les recommandations. Une colonne toujours nulle est
une promesse que personne ne tient. Deux cas gardent ces deux absences.

La contrainte `check` est posée dans un bloc séparé et non dans l'`add column` :
sur une base où la colonne existe déjà, la clause du `add column if not exists`
n'est pas rejouée et la contrainte manquerait en silence. Même motif que le bloc
de la 0010.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
Merge branch 'develop' into lenaic/136-zone-or-en-cron
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 2m20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m44s
e6e67f19c2
Le #165 fait entrer les alertes en zone argent. Ce commit termine le chemin :
`silver.alerte` -> `gold/table=alerte` -> `public.alerte`, la seule couche que
l'API et Grafana lisent.

`gold/alertes.py` ne recalcule rien, il projette. La zone argent a déjà lu
l'enveloppe bronze, replacé la journée d'après le contenu et écarté les alertes
inexploitables. Ici on traduit un vocabulaire — `alert_type` devient `type`,
`severity` devient `severite` — et c'est le seul endroit du dépôt où cette
traduction a lieu ; on divise une fois pour obtenir `taux_de_charge`, sans
jointure, la ligne argent portant déjà `capacity_kw` recopiée du référentiel
exprès pour ça ; et on pose `source = 'api_simulation'`, que la 0012 sépare de
ce que nos règles produiront.

Trois décisions que les cas gardent :

- `taux_de_charge` vaut NULL et non zéro quand la capacité manque ou vaut zéro.
  Un taux indéfini n'est pas un taux nul, et une division par zéro ferait
  échouer la journée entière pour une ligne de référentiel incomplète.
- Le contrôle des énumérations fait doublon avec celui de la zone argent,
  délibérément, comme `agregation._controler_enumerations` le fait pour la
  mesure que la zone argent produit pourtant. Une partition argent peut venir
  d'une version antérieure du job ou d'un rattrapage à la main.
- L'absence de partition d'alertes n'est pas une faute, ni à la projection ni
  au chargement : `silver.alerte` n'existe que depuis le #165, et rejouer une
  journée antérieure doit réussir plutôt que d'échouer sur une chaîne qui, elle,
  va bien. La série se charge quand même.

L'upsert porte sur `(site_id, horodatage, type, source)`, la clé unique de la
0012, et non sur `alerte_id`, qui est `generated always as identity` : la base
la produit, et un insert qui la fournirait serait refusé. Les alertes partent
dans la MEME transaction que la mesure et la qualité.

UNE LIMITE CONNUE, ÉCRITE PLUTÔT QUE DÉCOUVERTE PLUS TARD. `ecrire_partition`
passe par `COPY ... PARTITION_BY (dt)`, et sans valeur de `dt` DuckDB ne crée
pas de répertoire : une journée qui passerait de N alertes à zéro garderait sa
partition or précédente. Le cas est théorique — une alerte ne disparaît de
bronze qu'avec la rétention de 180 jours, qui emporte la journée entière — mais
il est réel, et la zone argent ne l'a pas, elle écrit à un chemin nommé.

Vérifié bout en bout sur bronze réel, en local, sans rien écrire dans MinIO :
2 984 alertes du 2026-09-06 traversent les trois zones sans perte ni doublon,
`source` et `etat` uniformes, `libelle` et `cle_bronze` renseignés sur les
2 984 lignes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
docs: la zone or porte les alertes, et la 0017 n'est plus libre (#168)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 32s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m50s
77b4a34fae
`docs/data/etl-pipeline.md` §12 : une section pour les alertes en zone or — les
trois choses qui s'y décident et une seule fois (le vocabulaire, le taux de
charge sans jointure, l'origine `api_simulation`), la clé de l'upsert et
pourquoi elle n'est pas `alerte_id`, le fait qu'une partition d'alertes absente
n'empêche pas la série de se charger, et la limite de `COPY ... PARTITION_BY`
sur zéro ligne.

Les deux entrées de commande annoncent leurs sorties réelles : `agregation`
écrit aussi `alerte`, `chargement` porte aussi `public.alerte`.

§15 : l'EF-05 va désormais jusqu'en base, avec la clé de l'upsert.

Une correction au passage. Le §12 annonçait « ce serait une migration 0017 »
à propos d'un éventuel drapeau `journee_complete` en base. Le numéro est pris
par les alertes depuis ce lot : la phrase le dit plutôt que de laisser un
lecteur chercher une migration qui parle d'autre chose.

`services/etl/README.md` : la troisième ligne du tableau des objets de la zone
or.

Le manuel `docs/runbooks/etl.md` n'est PAS étendu à la zone or ici : la #159 le
fait déjà, et deux PR qui réécrivent la même section se battraient pour rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
Merge branch 'develop' into lenaic/ci-refonte-anglais
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 19s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m14s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m46s
ffcd6de54e
Merge pull request 'infra : la zone or tourne en cron, et la documentation dit vrai' (#159) from lenaic/136-zone-or-en-cron into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 33s
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Aucun secret commité (push) Successful in 3s
Infra Ansible / Playbooks Ansible valides (push) Successful in 1m16s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m45s
66c14fbfb0
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/159
Merge branch 'develop' into lenaic/ci-refonte-anglais
All checks were successful
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m9s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m49s
50a0e0f014
Merge branch 'develop' into marvin/165-alertes-zone-argent (#165)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m41s
70dbfc0761
La #159 a réécrit `docs/runbooks/etl.md` pendant la relecture. Les deux
intentions sont gardées, aucune n'écrase l'autre :

- le tableau de la #159 couvre les trois passes, on le prend, en y ajoutant
  `endpoint=alerts` que la zone argent lit maintenant aussi ;
- le compte d'objets de la zone argent passe à quatre là où la #159 l'avait
  déplacé, dans le commentaire de l'étape 1 ;
- les deux paragraphes disent deux choses différentes — le piège du chargement
  et la partition d'alertes vide — donc les deux restent.

Résolution écrite par Olivier en relecture de la #167.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
db: la migration des alertes devient la 0018, la 0017 est prise (#168)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 31s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m44s
591ba13781
`develop` a reçu `0017_zone_or_prevision_reference.sql` avec le contrat de
prévision (#36, PR #161) pendant que cette branche écrivait sa propre 0017. Deux
fichiers de même numéro s'appliqueraient dans l'ordre de leur nom et non dans
celui de leurs dépendances : c'est le défaut que
`test_la_serie_des_migrations_ne_porte_ni_doublon_ni_desordre` interdit depuis
le #105.

Le garde-fou a fait son travail — les deux cas ont rougi au merge, celui du
dépôt et celui que ce lot avait ajouté. Le renommage suit partout : le fichier,
son test, le tableau du README des migrations, le §12 du pipeline, le README de
l'ETL et le docstring de `gold/alertes.py`.

Au passage, le tableau du README des migrations ne citait pas la 0017 de la
prévision : elle y est ajoutée. Un tableau incomplet est pire qu'absent, on
croit y lire la série entière.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7YA7QikrPg6wU3U1ur9M
Merge pull request 'ci : refonte complète de la chaîne — tâches qui s'expliquent, chaîne qui se contrôle elle-même' (#158) from lenaic/ci-refonte-anglais into develop
Some checks failed
Intégration / Contrôles statiques du dépôt (push) Successful in 8s
Intégration / Workflows — lint et audit de sécurité (push) Has been cancelled
Intégration / Python — qualité, tests et dépendances (push) Has been cancelled
Intégration / Tableau de bord — dépendances, tests et construction (push) Has been cancelled
Infra Ansible / Playbooks Ansible valides (push) Successful in 1m28s
24d6dbcf15
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/158
Reviewed-by: gabriel <gabriel@noreply.10.105.200.41>
Merge pull request '[EF-05] Les alertes de la source entrent en zone argent (#165)' (#167) from marvin/165-alertes-zone-argent into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 30s
Intégration / Contrôles statiques du dépôt (push) Successful in 5s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 17s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m41s
d9d6d1e27b
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/167
Reviewed-by: olivier <olivier@noreply.10.105.200.41>
db: le numéro de la 0018 se garde par sa dépendance, pas par la fin de série (#168)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m57s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m49s
c5ff5bca91
`assert numeros[-1] == 18` aurait fait rougir la chaîne à la prochaine
migration du dépôt, sur un autre ticket, sans que rien ne soit cassé — et
le runner est unique pour tout le groupe. Le cas garde désormais la
dépendance réelle : la 0018 complète la table de la 0012, son numéro doit
lui être supérieur. Les doublons de numéro restent gardés par
`test_migrations_zone_or`, qui exprime déjà sa borne de la même façon.

Au passage, le docstring du fichier annonçait « La 0017 », numéro que le
dernier commit de cette demande a déjà rendu à la #36.
Merge remote-tracking branch 'origin/develop' into lenaic/113-pile-app
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 39s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m27s
139ba2597a
# Conflicts:
#	tests/ci/test-role-app.yml
fix: retire les noqa E402 inutiles dans creer-compte.py (ruff)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 35s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 48s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 23s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m32s
1cdb01c337
E402 n'est pas activé dans la configuration ruff applicable à db/ et
tests/unit/db/, le noqa est donc superflu et bloquait la chaîne (RUF100).
Merge pull request '[EF-05] Les alertes traversent la zone or jusqu'à public.alerte (#168)' (#169) from marvin/168-alertes-zone-or into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 7s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 23s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 34s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m54s
920d5a84b8
Merge branch 'develop' into lenaic/113-pile-app
Some checks failed
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 36s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 32s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 23s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m13s
5112e33d1b
fix: applique ruff format sur creer-compte.py et son test
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 8s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 32s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 23s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m23s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m55s
329f6b9be9
Merge pull request 'infra : la pile app — l'API et le tableau de bord servis par le serveur' (#160) from lenaic/113-pile-app into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 8s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 23s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 34s
Infra Ansible / Playbooks Ansible valides (push) Successful in 1m23s
Intégration / Python — qualité, tests et dépendances (push) Successful in 3m44s
0f60b0b894
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/160
Reviewed-by: gabriel <gabriel@noreply.10.105.200.41>
# Conflicts:
#	infra/ansible/roles/app/defaults/main.yml
#	infra/ansible/roles/app/tasks/main.yml
supervision: relais.py passe le lint, que ma refonte de chaîne lui a étendu (#42)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 8s
Intégration / Workflows — lint et audit de sécurité (pull_request) Failing after 20s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 31s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 2m6s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m52s
1c10219106
La #158 a étendu ruff à `infra`, donc à infra/compose/supervision/. relais.py
n'était couvert par personne quand il a été écrit, et l'étape Ruff échouait
maintenant sur trois points :

- EXE001, shebang sans bit exécutable. Le conteneur lance
  `python -u /app/relais.py`, le shebang était décoratif ; le fichier est
  maintenant exécutable, ce qu'il prétendait déjà être.
- BLE001 deux fois, sur les deux `except Exception`. Les deux sont délibérés
  et le code le disait déjà en commentaire : une alerte en erreur n'emporte pas
  les autres, et le relais répond 200 même si la forge est injoignable, sinon
  Grafana réémet en boucle. Les commentaires deviennent des `noqa: BLE001`
  motivés, même forme que dans moteur.py.
- Deux appels repliés par ruff format.

Aucun changement de comportement. tests/ci/test-supervision.sh reste vert, les
dix-huit cas passent.
Merge remote-tracking branch 'origin/develop' into lenaic/39-regles-recommandations
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 4m16s
952d9228bb
# Conflicts:
#	pytest.ini
supervision: le banc passe shellcheck, que ma refonte de chaîne lui a étendu (#42)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 2m14s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m54s
734b940019
La #158 a ajouté une étape `shellcheck .forgejo/scripts/*.sh tests/ci/*.sh`.
test-supervision.sh y échoue six fois sur SC2015, « A && B || C n'est pas un
if-then-else ».

Et shellcheck a raison sur le fond, ce n'est pas une chicane de linter : si
`ok` sortait non nul, `echec` tournerait DERRIÈRE lui et le banc annoncerait
un défaut sur un cas qui vient de passer. Les six contrôles passent en
if/else, la raison est écrite au-dessus du premier.

Aucun changement de verdict : les dix-huit cas du banc passent avant comme
après, et shellcheck est propre sur les quatorze scripts du périmètre.
Merge pull request '[39] Recommandations : les trois règles du #153' (#154) from lenaic/39-regles-recommandations into develop
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 31s
Intégration / Contrôles statiques du dépôt (push) Successful in 7s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 17s
Intégration / Python — qualité, tests et dépendances (push) Has been cancelled
631765e737
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/154
Review par grabriel
Merge branch 'develop' into gabriel/42-alertes
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 35s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 25s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m59s
54f894ba31
Un seul conflit, `pytest.ini`, annoncé dans la description de la PR #166 : la
#154 ajoutait `services/recommendations` et la #161 `packages/contracts` en
bout de la même ligne que celle où cette branche ajoute `services/inference`.
Les trois ajouts se gardent — les chemins sont cumulatifs, en retirer un
rendrait un paquet invisible aux tests. Le commentaire venu de `develop` est
conservé tel quel, et `services/inference` prend sa place dans l'ordre
alphabétique des services, `packages/contracts` restant en fin de ligne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge pull request '[42] Cinq alertes d'exploitation, notifiées dans la forge' (#152) from gabriel/42-alertes into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 31s
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 18s
Infra Ansible / Playbooks Ansible valides (push) Successful in 2m28s
Intégration / Python — qualité, tests et dépendances (push) Successful in 4m9s
e3ee70a67e
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/152
Reviewed-by: lenaic <lenaic@noreply.10.105.200.41>
inference: la fiche de décision est la 0013, pas la 0012 (#36)
Some checks failed
Infra Ansible / Playbooks Ansible valides (pull_request) Has been cancelled
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 17s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m37s
b89175e0db
Conflit sémantique que la fusion de `develop` ne signale pas. Cette branche
citait « l'ADR 0012 » en treize endroits pour la décision de prévision H+1.
La fiche est entrée dans `develop` par la #161 sous le numéro **0013**
(`0013-prevision-h1-avec-reference-publiee.md`), et le numéro 0012 y désigne
désormais une décision sans rapport — le plafond de dépense déclaré hors
Terraform, #43. Laissées en l'état, ces treize citations envoyaient le
relecteur sur la mauvaise fiche, ce qui est pire qu'un lien mort : ça se lit
sans erreur.

Les quatre citations vérifiées mot pour mot dans la 0013 : « le T4 n'est pas
requis, ni pour l'entraînement ni pour l'inférence » (§Décision), « entrées
t−1 à t−24 », le critère de promotion « strictement inférieure à la MAE de la
persistance », et la colonne `reference_kw` que la migration 0017 pose bien.

Le lien se repose aussi, dans le README et la docstring de `modele.py` : la
note du 46869ff annonçait « le lien se posera quand la fiche entrera dans
`develop` », c'est fait, donc la note disparaît avec elle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into olivier/36-entrainement-modele
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 7s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
81cff17986
collector: une fenêtre conservée n'est pas une fenêtre à reprendre (#107)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m23s
7f83b8ced7
Le garde-fou rendait la fenêtre avec `ecrit=False`, ce que `a_reprendre`
lit comme « pas d'objet en bronze ». Conséquence : une fenêtre gardée
parce que bronze la portait déjà, et mieux, retournait dans la liste
« à rejouer » et `code_de_sortie` valait 1.

Une seconde exécution de la même commande sortait donc en 1 alors
qu'elle n'avait plus rien à faire — mesuré, `[0, 1]` sur deux passes
identiques. Le manuel annonce 0 pour « tout ce qui était collectable est
écrit » et conseille de « relancer jusqu'à ce que la liste se vide » :
cette liste ne se vidait jamais, et chaque cron nocturne retombant sur
un bronze complet aurait échoué.

`Lot` gagne donc `conserve`, qui dit « bronze l'a, et mieux » là où
`ecrit` faux dit seulement « rien n'a été écrit ». Les deux divergent, et
c'est la distinction que `a_reprendre` demandait. Le résumé les compte à
part : « 0/420 fenêtres écrites, 420 déjà en place » ne se lit plus comme
un échec.

Deux points de solidité au passage. La lecture des métadonnées était le
seul appel au dépôt hors `try` : `collecter_fenetre` promet de ne jamais
lever et `rattraper` l'appelle sans filet, donc une exception y perdait
toutes les fenêtres restantes, pas une. Et `int()` ne rattrapait que
`ValueError` là où une métadonnée non textuelle donne `TypeError`.

`pytest tests/unit` : 604 tests, 0 échec. `ruff`, `ruff format`, `mypy
--strict` sur les trois paquets typés : propres.
Merge pull request '[36] Entraînement du modèle de prévision H+1 et promotion' (#166) from olivier/36-entrainement-modele into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 35s
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 18s
Intégration / Python — qualité, tests et dépendances (push) Successful in 5m35s
0fd09f41c0
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/166
Reviewed-by: justine <justine@noreply.10.105.200.41>
Merge branch 'develop' into lenaic/107-rattrapage-ne-degrade-pas
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 17s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m21s
d9484771a0
Merge branch 'develop' into marvin/24-liste-alertes (#24)
Some checks failed
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m17s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Has been cancelled
Intégration / Contrôles statiques du dépôt (pull_request) Has been cancelled
Intégration / Aucun secret commité (pull_request) Has been cancelled
3426ad1e02
Huit conflits, dont trois qui demandaient une décision et non une union.

`glossaire.js` — les deux branches ont sorti la table des méthodes de
`TracabiliteDetail.vue`, mais pas de la même façon : develop en a fait une
table plate, cette branche deux registres (`libelleMethode` court pour une
colonne, `descriptionMethode` long pour la fiche de traçabilité). Les deux
registres sont conservés, et la justification que develop portait dans son
commentaire — pourquoi la table vit ici et pas dans le composant — est
reprise plutôt que perdue.

`GraphiquesView.vue` — develop affichait « Session expirée » quand
`erreurSites.statut` valait 401. Ce cas est mort depuis `estExpiration` : un
401 ne pose plus d'erreur, la modale de reprise prend la main. Le message est
retiré, et c'est celui de develop — « Référentiel des sites indisponible »
avec son bouton Réessayer — qui reste pour les vraies pannes, plutôt que le
« Se reconnecter » de cette branche, qui aurait fait de nouveau passer une
panne pour une déconnexion.

`GraphiquesView.test.js` — le test de develop qui ancrait ce message 401 est
retiré avec lui. Les deux autres, le référentiel en panne et le parc vide,
restent vrais et sont gardés.

Les cinq autres conflits étaient de l'adjacence : deux ajouts en fin de même
fichier. `graduationsY` et la palette de séries de develop cohabitent avec
`formaterPct` et `formaterJour` de cette branche.

npm run test:unit : 35 fichiers, 371 tests, tous verts.
npm run build : passe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017EqcTJ5AHzfddf5tChFKNh
Merge branch 'develop' into marvin/24-liste-alertes
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 37s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
ffd5249b86
Merge pull request 'collector : une passe de rattrapage pauvre n'efface plus une passe riche' (#162) from lenaic/107-rattrapage-ne-degrade-pas into develop
All checks were successful
Intégration / Contrôles statiques du dépôt (push) Successful in 7s
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 34s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 21s
Intégration / Python — qualité, tests et dépendances (push) Successful in 5m9s
25840e251d
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/162
Reviewed-by: olivier <olivier@noreply.10.105.200.41>
Merge branch 'develop' into marvin/24-liste-alertes
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Has been cancelled
Intégration / Contrôles statiques du dépôt (pull_request) Has been cancelled
Intégration / Workflows — lint et audit de sécurité (pull_request) Has been cancelled
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
96c7bffeb2
Merge pull request 'dashboard : alertes et journal de collecte de l'écran Qualité (#24)' (#155) from marvin/24-liste-alertes into develop
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (push) Successful in 40s
Intégration / Contrôles statiques du dépôt (push) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (push) Successful in 17s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 37s
Intégration / Python — qualité, tests et dépendances (push) Successful in 5m23s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 7s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m26s
843bdeca95
Reviewed-on: https://10.105.200.41/g2/enervision/pulls/155
Reviewed-by: florian <florian@noreply.10.105.200.41>
gabriel approved these changes 2026-09-07 13:50:31 +00:00
lenaic merged commit dd7373070f into main 2026-09-07 13:50:38 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!170
No description provided.