# Le cadre déterministe des issues

> Institué le 19/08 (décision Enguerran, jonction BACK#518 « garde-fous
> déterministes » et BACK#521 « processus produit outillé »). Installé à
> l'identique sur les **deux** dépôts, `EFEKTIVACADEMIE-BACK` et
> `EFEKTIVACADEMIE-FRONT`.

## Le problème qu'il résout

La qualité des issues tenait à la discipline : le
[gabarit](../../.github/ISSUE_TEMPLATE/) vivait dans le kit d'orchestration, un
fil pouvait l'appliquer ou l'oublier, et personne ne s'en apercevait avant que
l'issue soit reprise. Une issue hors gabarit n'est pas un constat transmis :
c'est une note qu'il faudra ré-instruire — le coût est payé plus tard, par
quelqu'un d'autre.

Mesure du 19/08 : le vérificateur passé sur **120 issues existantes** des deux
dépôts (60 + 60, les plus récentes) en refuse **120**. Il manque une source et
une date à toutes, et un test de non-régression attendu à toutes.

## Deux étages

| | Où | Ce que ça garantit |
|---|---|---|
| **1. Le formulaire** | `.github/ISSUE_TEMPLATE/*.yml` | Il **guide**. Champs obligatoires, listes déroulantes là où l'ambiguïté coûte cher. `blank_issues_enabled: false` ferme l'issue vierge — **côté web uniquement**. |
| **2. La porte** | `.github/workflows/conformite-issues.yml` | Elle **vérifie**. À l'ouverture et à chaque modification, quelle que soit l'origine de l'issue : web, API, `gh issue create`, script. |

Le deuxième étage existe parce que le premier ne garantit rien : l'API ignore
les formulaires, et c'est par l'API que passe l'essentiel des issues d'un fil de
chantier.

## Ce que la porte vérifie — et rien d'autre

Bloquant (étiquette `gabarit:non-conforme`) :

1. **Constat** présent et rempli ;
2. **Source** — ce qu'un tiers peut rouvrir sans son auteur ;
3. **Date** au format `JJ/MM` ;
4. **Cause** : *établie* **ou** *à établir*, **jamais les deux** — et si elle est
   établie, sa preuve ;
5. **Criticité** : exactement **un** niveau de la grille du cahier de recette
   (`Vital` · `Majeur` · `Mineur` · `Cosmétique`) — citer les quatre n'est pas
   une réponse ;
6. **Liens** : au moins un `#numéro`, ou le mot « aucun » écrit explicitement ;
7. **Critères d'acceptation** : au moins un, sous forme de case à cocher remplie ;
8. **Non-régression (R5)** : le test attendu, nommé.

Recommandé, jamais bloquant : le *motif* de la criticité, la *contre-épreuve*
attendue, le *hors périmètre*.

Et ce qu'elle ne fait pas :

- **elle ne juge pas le fond** — ni la pertinence du constat, ni la justesse de
  la criticité proposée. La criticité finale appartient au produit ;
- **elle n'appelle aucun modèle.** Même texte, même verdict, à chaque fois. Un
  auteur peut le rejouer chez lui : `node .github/scripts/verifier-gabarit.mjs
  corps.md` ;
- **elle ne bloque rien** : aucun merge empêché, aucune issue fermée. Elle
  étiquette et elle explique ;
- **elle ne balaie pas l'existant.** Le déclencheur `issues` ne voit que les
  issues ouvertes ou modifiées après sa mise en service. Un balayage rétroactif
  poserait 120 étiquettes rouges sur du travail déjà fait, et la première chose
  que l'équipe apprendrait de cette porte serait à ne pas la lire.

## Rejouer le verdict hors ligne

```bash
node .github/scripts/verifier-gabarit.mjs corps.md   # verdict JSON
gh issue view 123 --json body --jq .body | node .github/scripts/verifier-gabarit.mjs -
node .github/scripts/verifier-gabarit.mjs --auto-test # le corpus d'exemples
```

Le corpus `.github/scripts/exemples/` porte les verdicts attendus dans les noms
de fichiers, et chaque exemple non conforme déclare le manque qu'il doit
provoquer. C'est la non-régression du vérificateur lui-même — R5 appliqué à
l'outil qui réclame R5. Il tourne en CI sur toute PR touchant `.github/`.

## ⚠️ Mise en service — le point à ne pas rater

**Le déclencheur `issues` lit toujours la version du workflow présente sur la
branche par défaut du dépôt.** Sur les deux dépôts, la branche par défaut est
`main` (vérifié le 19/08). Conséquence : tant que ce workflow n'est mergé que
sur `development`, **il ne s'exécute pas** — aucune issue n'est étiquetée, et
l'absence d'étiquette ne veut alors rien dire.

La porte entre en service **au premier train `development` → `main`**.

### ⚠️ Ce qui s'est réellement passé — mesuré le 23/08

**Cet avertissement était écrit, et il n'a protégé personne.** La porte, écrite
le 19/08, n'est arrivée sur la branche par défaut que le **22/08 20:50** côté
FRONT et le **23/08 12:56** côté BACK — par un merge `development → main` qui ne
la visait pas. Entre-temps : **88 issues créées** (58 BACK, 30 FRONT), **0
verdict, 0 refus**. Le plan de vérification ci-dessous n'a pas été joué, et rien
ne l'a réclamé.

Le dispositif a donc été **faussement actif pendant quatre jours**, et il a guéri
par accident. C'est le sixième cas de cette famille dans ce dépôt — après la
protection de branche sans `enforce_admins`, le filtre de chemins, le renommage
de job, les PR antérieures à la porte et le contrôle de santé trop étroit.
**Leur point commun : aucun n'avait jamais rougi.**

D'où `mise-en-service.yml` : la sonde compare, à chaque poussée sur
`development`, les portes de `development` à celles de la branche par défaut, et
**rougit** quand l'une n'est pas en service. Éprouvée sur l'état réel du 20/08 :
elle nomme `conformite-issues.yml [issues] — absent` et sort en erreur. Sur
l'état d'aujourd'hui : verte. Les deux états sont mesurés — une vérification qui
ne constate qu'un seul état ne prouve rien.

⚠️ Elle se déclenche sur `push`, **jamais** sur `schedule` ni
`workflow_dispatch` : ces deux-là sont eux aussi lus depuis la branche par
défaut, et une sonde programmée qui n'y serait pas encore tomberait dans le
piège qu'elle surveille.

### Plan de vérification, à jouer par le pilote après ce train

1. `gh api repos/AAZTEKDEV/<dépôt>/actions/permissions` → `enabled: true`
   (vérifié le 19/08 sur les deux dépôts, aucun réglage manuel attendu) ;
2. ouvrir une issue de test titrée `[TEST-GABARIT] conforme`, corps recopié de
   `.github/scripts/exemples/conforme-constat-formulaire.md` → attendu :
   étiquette `gabarit:ok`, aucun commentaire ;
3. ouvrir `[TEST-GABARIT] non conforme`, corps recopié de
   `non-conforme-gabarit-vierge.md` → attendu : `gabarit:non-conforme` + un
   commentaire listant les manques ;
4. **modifier** le corps de la seconde en y recopiant l'exemple conforme →
   attendu : l'étiquette bascule, et le commentaire est **mis à jour, pas
   doublé** ;
5. supprimer les deux issues de test, et comparer le compte d'issues avant/après.

Le premier passage crée aussi les étiquettes `gabarit:ok` et
`gabarit:non-conforme` si elles n'existent pas — rien à préparer à la main.

## Le volet lié — la dépendance entre dépôts

**Le défaut fondateur.** FRONT#154 : correctif écrit le 16/08, jamais poussé,
issue fermée le jour même parce qu'on fermait son volet BACK#396. Défaut vivant
sept jours. Personne n'a fauté — **rien, dans GitHub, ne savait que ces deux
issues allaient ensemble.**

**La mesure du 23/08** (143 issues FRONT, 200 échantillonnées sur les deux
dépôts) : lien dans le titre **12** · dans le corps seulement **60** · **absent
71** · sous-issues natives **0 sur 200** · champ `parent` **absent** · étiquette
`train-couple` posée **7 fois**, et elle ne dit pas AVEC QUOI. Quatre formes,
**aucune lisible par une machine**.

### Ce qui a été retenu, et pourquoi

Arbitrage d'Enguerran du 23/08 : **la dépendance `blocked_by`**, pas la
sous-issue. Une sous-issue dit « composant de » et n'admet qu'un seul parent ;
FRONT#154 n'était pas un morceau de BACK#396, elle l'**attendait**.

Capacités vérifiées le 23/08 sur une paire d'issues réelles, créées puis
supprimées :

| | |
|---|---|
| dépendance `blocked_by` entre les deux dépôts | ✅ fonctionne |
| miroir `blocking` sur l'issue d'en face | ✅ **automatique**, rien à saisir deux fois |
| sous-issue entre les deux dépôts | ✅ fonctionne aussi (non retenue) |
| *issue types* | ❌ réservés aux organisations — ce compte est un **User** |
| fermer une issue dont le bloquant est ouvert | ⚠️ **GitHub l'autorise** |

⚠️ **Le lien rend visible, il ne contraint pas.** Rien n'empêche de fermer une
issue bloquée — c'est mesuré. Ce que le dispositif garantit, c'est que la liste
existe et qu'elle est interrogeable.

### ⚠️ Le piège de mesure — `is:blocked` ment en silence

```bash
gh api -X GET search/issues -f advanced_search=true \
  -f q='repo:AAZTEKDEV/EFEKTIVACADEMIE-FRONT is:issue is:open is:blocked'
```

**`advanced_search=true` n'est pas facultatif.** Sans lui — et **en GraphQL,
quelle que soit l'option** — `is:blocked` renvoie `0`, exactement comme
`is:zzzbidon`, un qualificatif inventé pour l'occasion. Un contrôle écrit en
GraphQL serait **vert pour toujours**. C'est ce contrôle avec un qualificatif
bidon qui a démasqué le faux positif ; sans lui, la mesure initiale concluait
l'inverse.

### Comment on déclare un volet

**Aucun champ nouveau.** Le formulaire portait déjà « Liens — dans les deux
sens », obligatoire. Sa ligne ambiguë `- Dépend de / bloque : #` a seulement été
**scindée**, parce que `blocked_by` a un sens :

```
- Dépend de : BACK#396      ← ce qui BLOQUE cette issue. La dépendance est posée.
- Bloque : aucun            ← l'inverse. Ne pas remplir en double : le miroir suffit.
```

`liaison-volets.yml` lit cette ligne et pose la dépendance.
`liaison-volets.mjs` est **déterministe et hors ligne** : `node
.github/scripts/liaison-volets.mjs corps.md` rend le même verdict chez soi.
19 cas au corpus, dont 7 pièges.

**Ce qu'il ne fait pas, délibérément :**
- il ne devine rien — « voir aussi BACK#458 » en prose **ne bloque rien** ; seule
  la ligne de déclaration compte. Un garde-fou qui crie à tort est un garde-fou
  qu'on désactive ;
- il ne touche pas au **stock** : arbitrage du 23/08, la règle ne vaut que pour
  les issues nouvelles ;
- laissée au pré-remplissage (`- Dépend de : #`), la ligne produit une **erreur**,
  jamais un silence.

### ⚠️ Il faut un secret `TOKEN_LIAISON`

Les deux dépôts sont privés et le `GITHUB_TOKEN` d'un dépôt **n'a aucun droit sur
l'autre** : lire l'issue citée est impossible sans jeton cross-dépôt. Aucun n'est
posé à ce jour (secrets présents le 23/08 : `DEPLOY_HOST`, `DEPLOY_SSH_KEY`,
`DEPLOY_USER`, `FRONT_ENV_STAGING`).

Sans lui, **la porte échoue bruyamment** — elle ne se tait pas, et elle ne passe
pas pour verte. C'est délibéré : les six dispositifs faussement actifs de ce
dépôt avaient tous en commun de n'avoir jamais rougi.

#### La procédure exacte — à jouer par Enguerran, sans autre contexte

1. **Créer le jeton** — https://github.com/settings/personal-access-tokens/new
   - type : **Fine-grained personal access token**
   - *Resource owner* : **AAZTEKDEV**
   - *Repository access* : **Only select repositories** → cocher **les DEUX** :
     `EFEKTIVACADEMIE-BACK` **et** `EFEKTIVACADEMIE-FRONT`
   - *Repository permissions* : **Issues → Read and write** (seule permission à
     changer ; *Metadata → Read* s'ajoute toute seule et est obligatoire)
   - *Expiration* : au choix — voir l'avertissement ci-dessous
2. **Le poser dans les DEUX dépôts**, sous le nom exact `TOKEN_LIAISON` :
   `Settings → Secrets and variables → Actions → New repository secret`
   — ou, en ligne de commande, la valeur étant demandée à la saisie :
   ```bash
   gh secret set TOKEN_LIAISON --repo AAZTEKDEV/EFEKTIVACADEMIE-BACK
   gh secret set TOKEN_LIAISON --repo AAZTEKDEV/EFEKTIVACADEMIE-FRONT
   ```
3. **Vérifier** (le nom seul, jamais la valeur) :
   ```bash
   gh secret list --repo AAZTEKDEV/EFEKTIVACADEMIE-BACK  | grep TOKEN_LIAISON
   gh secret list --repo AAZTEKDEV/EFEKTIVACADEMIE-FRONT | grep TOKEN_LIAISON
   ```

⚠️ **Les DEUX dépôts, pas un seul** : une issue BACK peut attendre un volet FRONT
autant que l'inverse, et chaque dépôt exécute sa propre porte.

⚠️ **La portée est déduite des appels de la porte** — `issues.get` sur l'autre
dépôt, lecture et écriture de `dependencies/blocked_by` sur le dépôt courant —
**elle n'a pas été éprouvée**, faute de jeton à ce jour. Si elle se révélait
insuffisante, la porte le dira : elle échoue bruyamment, elle ne se tait pas.

⚠️ **À l'expiration du jeton, la porte échouera** — c'est voulu, et c'est le seul
comportement acceptable ici. Une porte qui se tairait faute de jeton serait le
septième dispositif faussement actif de ce dépôt.

#### ⚠️ Quand le poser — l'ordre compte

**Le secret n'est PAS nécessaire avant la fusion vers `development`.** Le job qui
le lit ne se déclenche que sur `issues`, donc **uniquement depuis la branche par
défaut** : tant que `main` n'a pas reçu le train, il ne peut pas s'exécuter.

**Il doit exister AVANT le train `development → main`.** Sinon, dès que la porte
entre en service, **chaque issue ouverte** produit un run rouge — pas dangereux,
mais bruyant, et ce dépôt crée beaucoup d'issues.

⚠️ **Entre les deux, la sonde de mise en service est ROUGE** — mesuré : elle
nomme `liaison-volets.yml [issues] — absent`. C'est exact, ce n'est pas un faux
positif : la porte n'est effectivement pas en service. Mais **un rouge qui dure
banalise le rouge** : ne pas laisser traîner entre la fusion vers `development`
et le train vers `main`.

## ⚠️ Aucune issue nouvelle sans jalon — règle du 25/08

**Décision d'Enguerran, impérative, toutes conversations** : toute issue créée est
rattachée **À LA CRÉATION** à l'un des deux jalons — **« Avant bascule »** (due 13/09,
indicatif) ou **« Post-bascule »**. Sans exception, quel que soit l'auteur ou le canal
de création (formulaire web, API, `gh issue create`).

**Pourquoi une règle, et pas une bonne pratique** : un jalon absent rend tout comptage
par lot faux — 130 issues sans jalon rendaient le registre inexploitable au 23/08.
Le gardien (le lotissement) signale les manquantes ; **chacun jalonne ses propres
créations, personne ne corrige à la place d'un autre.**

⚠️ **Aujourd'hui, cette règle repose sur la vigilance** — et ce document dit ailleurs
ce que ça vaut. Le verrou déterministe est **instruit et faisable** : la porte
`conformite-issues.yml` se déclenche déjà à l'ouverture et à chaque modification, et
son événement porte `github.event.issue.milestone`. Le contrôle est donc un **test de
nullité dans le workflow** — *pas* une règle du vérificateur hors ligne, puisque le
jalon ne vit pas dans le corps de l'issue : `verifier-gabarit.mjs` et son corpus
restent inchangés. Refus → `gabarit:non-conforme` + commentaire nommant les deux
jalons admis. **À éprouver en le faisant échouer** (une issue sans jalon doit rougir,
une avec jalon doit passer — les deux états, jamais un seul). Réf. #649.

Un contrôle jumeau est prévu pour la typologie (« exactement un `type:` ») —
`docs/context/STRUCTURATION_GITHUB_2026-08-24.md` §9.

## Toucher au cadre

- Les libellés des champs des formulaires sont **lus** par
  `verifier-gabarit.mjs` : les renommer sans toucher au vérificateur casse la
  porte en silence. Le corpus d'exemples est là pour que ça se voie en CI.
- Le vocabulaire de la criticité vient du
  [cahier de recette](../design/refonte-2026-08/CAHIER_RECETTE.md), pas du
  workflow. Il est isolé en tête du vérificateur (`NIVEAUX_CRITICITE`).
- Une règle nouvelle se code **une fois**, dans le vérificateur, avec son
  exemple au corpus.
