diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index 8ad1302..34837cd 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -140,3 +140,29 @@ jobs:
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
+
+ sast:
+ name: Analyse statique de sécurité
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/backend
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v4
+
+ # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
+ # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
+ - name: Installe uv
+ uses: astral-sh/setup-uv@v5
+
+ # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
+ # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
+ - name: Analyse le code livré (bloquant à partir de MEDIUM)
+ run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
+
+ # Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
+ - name: Rapport complet, tous niveaux
+ continue-on-error: true
+ run: uvx bandit==1.9.4 --recursive app
diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml
index b85fec7..00189e2 100644
--- a/.github/workflows/ml.yml
+++ b/.github/workflows/ml.yml
@@ -57,3 +57,26 @@ jobs:
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
- name: Tests
run: uv run pytest
+
+ sast:
+ name: Analyse statique de sécurité
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ml
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v4
+
+ # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
+ # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
+ - name: Installe uv
+ uses: astral-sh/setup-uv@v5
+
+ - name: Analyse le code livré (bloquant à partir de MEDIUM)
+ run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
+
+ - name: Rapport complet, tous niveaux
+ continue-on-error: true
+ run: uvx bandit==1.9.4 --recursive enervision_ml
diff --git a/docs/ML-START.md b/docs/ML-START.md
new file mode 100644
index 0000000..68518ac
--- /dev/null
+++ b/docs/ML-START.md
@@ -0,0 +1,177 @@
+# ML-START : accès aux données, scoring, frontière API et ML
+
+Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`,
+`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md)
+et par les vues d'architecture. Il répond à trois questions, et à elles seules :
+
+1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ;
+2. **ce que fait un run de scoring**, étape par étape ;
+3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là.
+
+Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md).
+Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
+
+---
+
+## 1. Mécanisme d'accès aux données
+
+### Deux sources, un seul schéma de sortie
+
+`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes**
+(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`,
+`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) :
+
+| Fonction | Source | Usage |
+|---|---|---|
+| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
+| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
+| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring |
+
+L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
+CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
+divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production
+sous forme de prédictions silencieusement fausses.
+
+### Connexion directe à PostgreSQL, pas l'API
+
+Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois
+raisons, à défendre telles quelles :
+
+- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire
+ passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer
+ trois fois pour un `SELECT`.
+- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du
+ schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la
+ disponibilité du service, pour lire des données dont il connaît déjà la forme.
+- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte
+ applicatif porteur d'un rôle métier.
+
+### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL`
+
+La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est
+pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du
+schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner
+l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()`
+lève donc plutôt que de retomber sur une valeur par défaut.
+
+**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`,
+restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement,
+`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente
+avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md).
+
+### Le seul endroit qui construit les features
+
+`enervision_ml.features.build_features` est **l'unique** constructeur de features, à
+l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une
+fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à
+ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**.
+Ne jamais réécrire cette logique ailleurs : importer le module.
+
+Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais
+un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des
+lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait
+un surapprentissage.
+
+---
+
+## 2. Les étapes d'un run de scoring
+
+`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la
+consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans
+`prediction`.
+
+| # | Étape | Point de vigilance |
+|---|---|---|
+| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` |
+| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 |
+| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire |
+| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | |
+
+### Ce que le run écrit, et ce qu'il n'écrase pas
+
+La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
+insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra
+possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui
+n'existe pas encore.
+
+Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
+`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
+`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
+`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`.
+
+### `model_reference` est un hachage, pas un nom de fichier
+
+`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque
+entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction`
+porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une
+prédiction douteuse, de savoir quel modèle l'a produite.
+
+### Mode CSV : rien n'est écrit en base
+
+En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe
+dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode
+sert à valider le pipeline sans base joignable.
+
+### Limite assumée
+
+La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture
+réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans
+le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule,
+sur une feature parmi une dizaine, pour une prévision à un seul pas.
+
+---
+
+## 3. La frontière entre l'API et le ML
+
+```mermaid
+flowchart LR
+ subgraph ml["ml/ · projet Python indépendant"]
+ train["enervision_ml.train
LightGBM + MLflow"]
+ score["enervision_ml.score
prévision à un pas"]
+ end
+ subgraph db["PostgreSQL + TimescaleDB"]
+ reading[("reading, site")]
+ prediction[("prediction")]
+ end
+ subgraph api["apps/backend · FastAPI"]
+ route["GET /api/v1/predictions"]
+ end
+
+ reading -- "SQL direct, ML_DATABASE_URL" --> train
+ reading -- "fenêtre récente" --> score
+ train -- "models/*.txt + run MLflow" --> score
+ score -- "INSERT" --> prediction
+ prediction -- "lecture seule" --> route
+```
+
+**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
+`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul
+à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury :
+
+- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée
+ (`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde
+ ou une minute.
+- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python
+ séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni
+ leur surface de vulnérabilités, pour un script lancé hors du chemin de requête.
+- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API
+ continue de servir la dernière prévision connue, avec son `created_at` et son
+ `model_reference`, au lieu de rendre une erreur.
+- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de
+ `prediction` et leurs contraintes de cohérence, vérifiables en SQL.
+
+Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
+celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
+(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
+l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la
+surveillance de dérive, portée par les issues #44 et #45.
+
+---
+
+## Voir aussi
+
+- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests
+- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés
+- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
+- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
+- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md
index 7f8aba1..7bdd1da 100644
--- a/docs/architecture/00-vue-ensemble.md
+++ b/docs/architecture/00-vue-ensemble.md
@@ -85,17 +85,18 @@ collecteur ne vient le lire.
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
-| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
+| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
-| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
+| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
## Flux bout en bout
-Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les
-deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas
-l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous.
+Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
+d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`,
+issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
+l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
```mermaid
sequenceDiagram
@@ -168,7 +169,9 @@ consolidée.
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
est livré et documenté mais pas exercé.
-- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
+- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
+ (`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
+ [50-cicd.md](50-cicd.md).
## Décisions structurantes
diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md
index 9557c5c..c499dca 100644
--- a/docs/architecture/20-backend.md
+++ b/docs/architecture/20-backend.md
@@ -193,7 +193,7 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
-[ML-START.md](../../ML-START.md) section 3.
+[ML-START.md](../ML-START.md) section 3.
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md
new file mode 100644
index 0000000..79313a3
--- /dev/null
+++ b/docs/architecture/50-cicd.md
@@ -0,0 +1,199 @@
+# Intégration et livraison continues
+
+Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est
+vérifié, ce qui bloque, et ce qui ne l'est pas.
+
+| Étage | Sert à | Statut |
+|---|---|---|
+| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
+| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` |
+
+Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image
+publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet
+étage, et elle est nommée ici plutôt que découverte en soutenance.
+
+## Vue d'ensemble
+
+```mermaid
+flowchart TB
+ push["push ou pull_request"]
+
+ subgraph back["Backend · .github/workflows/backend.yml"]
+ bv["verification
ruff, mypy, pytest --cov-fail-under=85"]
+ bi["integration
TimescaleDB réel + alembic upgrade head"]
+ bd["security-audit
uv export | pip-audit"]
+ bs["sast
bandit"]
+ end
+
+ subgraph front["Frontend · frontend.yml"]
+ fb["build
npm ci, npm run build"]
+ ft["test
couverture lcov"]
+ fd["security-audit
npm audit --audit-level=high"]
+ end
+
+ subgraph mlw["ML · ml.yml"]
+ mv["verification
ruff, mypy, pytest"]
+ ms["sast
bandit"]
+ end
+
+ subgraph afw["Airflow · airflow.yml"]
+ av["verification
ruff, intégrité des DAGs"]
+ ab["image
construction de l'image"]
+ end
+
+ subgraph sq["SonarQube · sonarqube.yml"]
+ sb1["build-front / test-front"]
+ sb2["build-back / test-back"]
+ sscan["sonarqube
quality gate SonarCloud"]
+ end
+
+ push --> bv & bi & bd & bs
+ push --> fb --> ft
+ push --> fd
+ push --> mv & ms
+ push --> av & ab
+ push --> sb1 & sb2 --> sscan
+ sscan -.-> cd["deploy
issue #21"]
+```
+
+## Déclenchement
+
+Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** :
+`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`,
+`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun
+incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline
+déclenche le pipeline.
+
+Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
+`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
+`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
+modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes`
+y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
+Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre
+le voit.
+
+**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la
+CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
+délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
+rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
+
+`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
+git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
+
+**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10
+ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
+Docker et son propre environnement.
+
+## Ce qui bloque un merge
+
+| Gate | Où | Seuil | Effet d'un échec |
+|---|---|---|---|
+| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque |
+| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque |
+| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
+| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
+| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
+| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
+| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
+| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
+| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque |
+| Build `npm run build` | frontend | compilation | Bloque |
+| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
+| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
+
+Deux seuils portent une décision qu'il faut savoir défendre :
+
+- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une
+ dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les
+ `moderate` sont invisibles en CI, et qu'elles se regardent à la main.
+- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW
+ sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
+ revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
+ même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
+ 21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes
+ analysées.
+- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
+ une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
+ le rejeu à l'identique promis plus bas n'existerait pas.
+
+## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres`
+
+`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que
+`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête
+volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste
+rendrait ce job vert sur une base qui n'est pas la nôtre.
+
+Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas
+monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb`
+avant `alembic upgrade head`.
+
+La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
+partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
+
+## SonarCloud, et l'incident qui a immobilisé trois PR
+
+Le workflow `sonarqube.yml` exécute quatre jobs de préparation (`build-front`, `test-front`,
+`build-back`, `test-back`) qui produisent chacun un rapport de couverture en artefact, puis un
+cinquième job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` avec le secret
+`SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
+
+**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
+restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
+la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** :
+`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que
+`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient
+comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par
+ailleurs que la racine.
+
+Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test,
+`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur
+toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas
+contournée** en désactivant la gate ou en excluant les fichiers gênants.
+
+## Dependabot
+
+`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** :
+`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les
+deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc
+elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
+les tests ne se merge pas.
+
+## Stratégie de branche et conventions
+
+| Règle | Détail |
+|---|---|
+| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
+| Messages de commit | Conventional Commits |
+| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
+| Revue | Toute PR passe par une revue écrite avant merge |
+| ADR | Toute décision structurante porte son ADR dans la même PR |
+| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
+
+## Secrets
+
+Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions.
+Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
+workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
+run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de
+déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
+l'issue #22.
+
+## Ce qui manque, et pourquoi
+
+| Manque | Issue | Conséquence assumée |
+|---|---|---|
+| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine |
+| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
+| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
+| Tests de charge | #47 | Aucun garde-fou de performance |
+| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
+
+## Reproduire la CI en local
+
+`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
+`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration
+demandent une base : `make db-up` puis `uv run pytest -m integration`.
+
+Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
+--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
+`ml`.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 1e3a260..0c41da2 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -15,10 +15,12 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
+| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque |
-L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
-ci-dessus, tant que `monitoring/` ne contient que des `.gitkeep`. Elles en sortiront le jour où
-elles auront de la matière. Un fichier vide de plus n'aide personne.
+La CI/CD a désormais son document : cinq workflows et seize jobs, c'est assez de matière pour
+qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
+toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura
+de la matière. Un fichier vide de plus n'aide personne.
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
diff --git a/ml/README.md b/ml/README.md
index c7814fe..21ddd85 100644
--- a/ml/README.md
+++ b/ml/README.md
@@ -2,7 +2,7 @@
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
-[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees).
+[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees).
| Element | Choix |
|--------------|-----------------------------------------------|