# Cartographie des gardes fondées sur le rôle

> **Phase 1 du chantier socle [#514](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/514)**
> — go d'Enguerran le 19/08. Chantier de SPÉCIFICATION : ce document ne modifie
> aucune ligne de code et ne crée aucune issue. Il établit **l'état de fait,
> mesuré**, que le modèle cible (phase 2) devra remplacer.
>
> Périmètre : `EFEKTIVACADEMIE-BACK` @ `development` (`4cc54c9`, après le merge
> de PR#531) et `EFEKTIVACADEMIE-FRONT` @ `development`.
> Date du recensement : **19/08/2026**.

---

## 1. Ce qu'on cherchait, et la grille

La séparation entre **rôle** (une habilitation) et **contexte** (un univers, une
inscription, la main sur un cours) n'existe aujourd'hui que **correctif par
correctif** : #447, #450, #512, #528, FRONT#300, FRONT#302. La frontière n'est
écrite nulle part — c'est l'arbitrage relevé par le fil #300 et la raison d'être
de cette cartographie.

Trois classes, appliquées à chaque occurrence :

| Classe | Définition | Le test |
|---|---|---|
| **LÉGITIME** | « écran / route d'un RÔLE » — le studio d'administration, le cockpit d'encadrant, la latérale par univers, un invariant d'escalade. | Le rôle **est** la bonne clé : la question posée est bien « qui es-tu dans l'organisation ? ». |
| **SUSPECT** (famille #447) | « écran / route d'un INSCRIT ou d'un CONTEXTE » — consommation de formation (univers Apprentissage), payloads pédagogiques, progression. | Le rôle est la **mauvaise** clé : la bonne est l'**inscription** (#447) ou le **contexte** (#528). |
| **À TRANCHER** | La frontière n'est pas évidente. | Une **question** est posée. Aucune réponse n'est inventée ici. |

Deux catégories s'ajoutent, qui ne sont pas des gardes et qu'il fallait sortir du
compte pour ne pas gonfler le chiffre :

- **RENDU** — le rôle est **sérialisé comme donnée** (`'role' => …`) et ne décide
  de rien. `MyProfileController:104` porte la distinction en toutes lettres :
  *« Le rôle est RENDU […], il n'est jamais LU pour décider quoi que ce soit. »*
- **TRACE** — un commentaire ou du code mort qui **cite** une ancienne garde
  (typiquement celle que PR#531 a retirée). Effet nul, valeur documentaire.

### Les précédents qui font doctrine

| Correctif | Ce qu'il établit |
|---|---|
| **#447** (`CourseResource`) | *« C'est l'inscription qui décide, et elle seule. »* Le rôle `learner` cachait sa progression à **16 encadrants inscrits** sur staging (38 inscrits, dont 16 sans le rôle). |
| **#450 / #512 → PR#515** (`PersonVisibility`) | Le périmètre se pose par une **garde portée par la méthode**, jamais par une énumération de routes — *« la liste est le contrat »* est précisément ce qui a laissé passer la cinquième porte. |
| **#528 → PR#531** (`AccesCorrige`) | Le motif #447 **retourné** : là-bas le rôle cachait une donnée due, ici il **livrait** une donnée interdite. Et trois défauts dans une seule ligne : le contexte ignoré, **seul le premier rôle lu**, un compte sans rôle traité en encadrant. |
| **FRONT#300 → PR#303** | *« Le correctif ne rallonge pas la liste : c'est l'énumération elle-même qui est le défaut. »* Le client ne rejoue pas une autorisation qu'il ne détient pas. |
| **FRONT#302** (ouverte) | Le jumeau non corrigé : l'en-tête de gamification sous `if (!role.includes("learner"))`. **Ce document répond à sa question ouverte** — voir S1. |

---

## 2. Synthèse chiffrée

### BACK — 98 occurrences

Recensement sur `app/`, en **trois passes** (§ 8). La première reprend les
motifs du pilote (`hasRole` · `getRoleNames` · `hasAnyRole` · `->role(`) ; les
deux suivantes ferment deux **angles morts** de ce motif, qui à eux seuls
apportent **9 des 17 suspects**.

| Passe | Motif | Gardes | LÉGITIME | SUSPECT | À TRANCHER | Rendus | Traces | **Total** |
|---|---|---|---|---|---|---|---|---|
| **P1** | `hasRole` · `getRoleNames` · `hasAnyRole` · `->role(` | 56 | 33 | 8 | 15 | 22 | 4 | **82** |
| **P2** | `User::role(…)` — scope **statique** Spatie | 10 | 3 | 7 | 0 | 0 | 0 | **10** |
| **P3** | `whereHas('roles', …)` — jointure directe | 6 | 4 | 2 | 0 | 0 | 0 | **6** |
| | **TOTAL** | **72** | **40** | **17** | **15** | **22** | **4** | **98** |

Répartis par couche :

| | Gardes | LÉGITIME | SUSPECT | À TRANCHER | Rendus | Traces | **Total** |
|---|---|---|---|---|---|---|---|
| `app/Http/Controllers/` | 47 | 20 | 15 | 12 | 18 | 0 | **65** |
| `app/Http/Resources/` | 1 | 1 | 0 | 0 | 3 | 1 | **5** |
| Reste de `app/` | 24 | 19 | 2 | 3 | 1 | 3 | **28** |
| **TOTAL** | **72** | **40** | **17** | **15** | **22** | **4** | **98** |

> **Occurrences ≠ sites.** Les **17** occurrences suspectes se répartissent sur
> **13 sites** de correction (S1→S13) : `FrontCourseController:423`/`:487` sont
> la même garde écrite deux fois, `DashBoardController:63`/`:95` de même. Le
> tableau compte des **lignes de code** ; la liste priorisée du § 5 compte des
> **correctifs**.

### ⚠ Le compte du pilote était un plancher, pas un total

Les **21 contrôleurs** et **3 Resources** relevés le 19/08 sont retrouvés à
l'identique — le motif de grep était juste, mais il **ne voit pas tout** :

- `User::role('learner')` (scope **statique**) échappe au motif `->role(`, qui
  ne capte que l'appel sur instance. **10 occurrences**, dont **7 suspectes** —
  et l'une d'elles (**S9**) est la garde que le correctif #450 a laissée en
  place en la commentant.
- `whereHas('roles', fn ($q) => $q->where('name', 'learner'))` n'emploie **aucun**
  des motifs cherchés. **6 occurrences**, dont **2 suspectes** — et ce sont
  celles qui définissent la population de **tous** les tableaux de bord (**S13**).

C'est la mécanique de #450 → #512 appliquée à l'outil de mesure lui-même :
**« la liste est le contrat »**, y compris la liste des motifs de grep. Toute
mesure ultérieure doit jouer les **trois** passes du § 8.

### FRONT — 179 occurrences

Recensement sur `src/` (hors `*.test.*`), motifs `includes("<rôle>")` ·
`lireRoles()` · `hasRole()` local · prop `roles={[…]}` · listes blanches nommées.
Unité : **une ligne de code qui décide à partir du rôle de l'utilisateur
courant**.

| Classement | Occurrences | Part |
|---|---:|---:|
| **LÉGITIME** | 58 | 32 % |
| **SUSPECT** (famille #447) | 51 | 29 % |
| **À TRANCHER** | 46 | 26 % |
| **INERTE** (code mort ou réglage éteint) | 24 | 13 % |
| **Total** | **179** | 100 % |

**Le compte de ~107 du pilote était lui aussi un plancher**, et pour la même
raison qu'au BACK : trois gisements échappent à `includes("<rôle>")` —
les **30** gardes `<ProtectedRoute roles={[…]}>` d'`App.jsx`, les **8**
`hasRole()` définis localement dans `Menu.jsx:23`, et le comptage ligne-à-ligne
des conditions multi-lignes (`NavbarPanel.jsx` : 18 lignes pour 3 conditions).

**Concentration** : cinq fichiers portent 60 % des occurrences —
`NavbarPanel.jsx` (36), `App.jsx` (34), `Sidebar.jsx` (31), `MyCourse.jsx` (9),
`Menu.jsx` (8). Les trois premiers sont montés sur **tous** les écrans ou
décrivent **toutes** les routes.

**Le socle refait est propre** : `Components/Layout/*` (les latérales par
univers), `Utils/universRoutes.js`, `StudentCourseDetail.jsx` (2 306 lignes)
portent **zéro** garde de rôle. La dette est entièrement dans les écrans
hérités.

### Les deux périmètres réunis — 277 occurrences

| | LÉGITIME | SUSPECT | À TRANCHER | Rendus | Inertes | **Total** |
|---|---|---|---|---|---|---|
| BACK | 40 | 17 | 15 | 22 | 4 | **98** |
| FRONT | 58 | 51 | 46 | — | 24 | **179** |
| **TOTAL** | **98** | **68** | **61** | **22** | **28** | **277** |

### Les trois chiffres qui comptent

- **40 gardes légitimes** — le rôle **est** souvent la bonne clé. Il n'est pas
  question de purger l'ensemble : la cible n'est pas « zéro rôle », c'est « le
  rôle là où il répond à la bonne question ».
- **17 occurrences suspectes BACK sur 13 sites** — la vague de purge #447.
  **Deux sont des trous de sécurité** (S2, S3), et **deux faussent tous les
  chiffres de pilotage** (S8, S13) : ce ne sont pas des défauts d'affichage.
- **15 à trancher BACK** — dont **10 relèvent d'une seule et même question** :
  que fait le produit d'un utilisateur qui porte **plusieurs rôles** ? (Q1)

---

## 3. Les deux défauts transverses

### 3.1 BACK — « le premier rôle gagne »

Avant le détail, un motif qui traverse tout le reste et qu'aucune classe ne
capture seule.

**52 occurrences** de `getRoleNames()->first()` ou `getRoleNames()[0]` dans
`app/`. Chacune réduit un utilisateur à **un seul rôle** — le premier que Spatie
rend, dans un ordre qui **n'est le contrat de personne**. C'est le défaut n° 2
nommé par #528 :

> *« seul le PREMIER rôle était lu : un porteur de `['manager_general','learner']`
> était traité en non-apprenant — l'ordre des rôles n'est le contrat de personne »*

```bash
grep -rn "getRoleNames()->first()\|getRoleNames()\[0\]" app/ --include='*.php' | wc -l
# 52
```

PR#531 l'a corrigé **en un point**. Les 51 autres subsistent. Trois familles de
conséquence :

1. **Décision faussée** — `User::user_detail()` (`app/Models/User.php:106`),
   `UpdateUserRequest` (`:69`, `:204`), les sept branches de profil
   d'`Admin/UserController` (`:418`→`:580`) : le mauvais profil est lu, écrit ou
   validé.
2. **Périmètre faussé** — `DashboardScope`, `PersonVisibility`, `ListQuery`
   posent leur question au **premier** rôle. Une personne à deux rôles peut
   recevoir le périmètre du mauvais.
3. **Affichage faussé** — les 22 rendus n'annoncent qu'un rôle sur N.

**Ce n'est pas un suspect de la famille #447** — le rôle y est parfois la bonne
clé. C'est une **question de modèle** : le référentiel cible doit dire si un
utilisateur a UN rôle ou N, et, si N, comment ils se composent. Elle est reprise
en Q1 § 7.

### 3.2 FRONT — le même test, deux réponses opposées

`Login.jsx:86` écrit `localStorage.setItem("userRole", JSON.stringify(type))`.
Deux familles de lecteurs en découlent, et **elles ne donnent pas le même
résultat** :

| Lecteur | Code | Sémantique de `.includes("manager")` |
|---|---|---|
| **Tableau** | `lireRoles()` (`Common/useMyRole.js:46`) | appartenance **stricte** |
| **Chaîne brute** | `localStorage.getItem("userRole") \|\| ""` | test de **sous-chaîne** |

Vérifié à l'exécution :

```js
JSON.stringify(["sub_manager"])            // '["sub_manager"]'
'["sub_manager"]'.includes("manager")      // true   ← chaîne brute
["sub_manager"].includes("manager")        // false  ← lireRoles()
```

**Un Manager d'équipe et un Manager Général passent silencieusement pour un
Manager RH** dans les onze lecteurs de chaîne brute : `App.jsx:405` ·
`Menu.jsx:21` · `HeaderNavigation.jsx:25` · `MyCourse.jsx:370` ·
`Catelog.jsx:13` · `NavbarPanel.jsx:627` · `:1032` · `:1137` · `:1261` ·
`CreateCourse.jsx:111` · `:497`.

**Le cas modèle est dans un seul fichier.** `CreateCourse.jsx:125` lit un
tableau (strict → un `sub_manager` est **éjecté**) ; `CreateCourse.jsx:500` lit
la chaîne brute (sous-chaîne → le même `sub_manager` est **accepté**). *Le même
test `includes("manager")`, dans le même fichier, à 375 lignes d'écart, répond
l'inverse.*

**Conséquence pour la vague de purge** : toute correction qui ne traite qu'une
des deux lignes laisse l'écran à moitié réparé. C'est **Sf6**.

---

## 4. BACK — le tableau exhaustif

### 4.1 Gardes LÉGITIMES (40)

Regroupées par motif. Le compte reste exhaustif : chaque `fichier:ligne` est cité.

> **Convention de comptage.** Seule la colonne **« Occurrences »** est le
> recensement : sa somme fait **40**, le total du § 2. Les `fichier:ligne` cités
> dans les colonnes *« Ce que la garde décide »* et *« Justification »* sont des
> **renvois** (la ligne voisine qui explique, la garde de contexte appliquée
> juste avant) — ils ne sont **jamais** comptés. Même convention en §§ 4.2 à 4.5.

| # | Motif | Occurrences | Ce que la garde décide | Justification |
|---|---|---|---|---|
| L1 | **Le référentiel interrogé, pas énuméré** | `Support/Hierarchy/PersonVisibility.php:58` · `Support/Dashboard/DashboardScope.php:49` · `DashboardScope.php:92` · `Support/Features/FeatureVisibility.php:101` · `Rules/ManagerialParent.php:51` · `Http/Controllers/DashBoardController.php:37` · `Http/Controllers/InvitationByEmailController.php:312` · `Http/Controllers/Tracking/TrackingStatsController.php:32` · `Http/Controllers/Manager/CollaboratorController.php:120` | Le périmètre de consultation, les compteurs, la visibilité d'une fonctionnalité, le droit d'être manager de rattachement. | **Le motif à généraliser.** La question passe par `RoleHierarchy` (`hasGlobalScope` / `hasHierarchicalScope` / `seesEveryone`) : c'est déjà un modèle déclaratif, pas une énumération. |
| L2 | **La main sur le contenu** | `Support/Courses/AccesStructureCours.php:97` · `Support/Courses/AccesFormation.php:124` · `AccesFormation.php:188` | Qui édite / voit la structure d'un cours : admin, ou propriétaire (`teacher_id` / `manager_id`). | Le rôle n'y sert que pour l'admin ; le reste est du **contexte** (propriété). C'est la garde sur laquelle `AccesCorrige` (PR#531) s'est appuyée. |
| L3 | **L'accès au chat d'un cours** | `Support/Content/CourseChatAccess.php:29` · `CourseChatAccess.php:47` | Qui écrit dans le fil d'un cours. | L'inscription est testée **avant** le rôle (`:38`) ; le rôle n'intervient qu'en dernier recours, pour l'encadrant dont un rattaché est inscrit. Le bon ordre. ⚠ énumération en `:47` — voir F2. |
| L4 | **L'invariant d'escalade de privilèges** | `Http/Controllers/Admin/UserController.php:69` · `:321` · `:332` | Nul n'attribue un rôle de niveau ≥ au sien ; un rôle qui perd sa portée hiérarchique perd ses rattachés. | Administration pure. Le rôle **est** l'objet même de l'opération. |
| L5 | **Les réglages administrés** | `Http/Controllers/Admin/DelaySettingsController.php:47` · `:79` · `:156` | Qui modifie les seuils globaux (admin) / les surcharges par utilisateur (admin, Manager RH) ; et `can_edit_global`, servi à l'écran. | Écran d'administration. `:156` est **le motif exemplaire** : le serveur décide, l'écran lit — l'inverse exact des 107 décisions prises côté client. |
| L6 | **Les référentiels de population** | `Http/Controllers/Manager/MyLearnerController.php:133` (`->role('teacher')`) | La liste des formateurs proposée à l'affectation d'un cours. | On cherche littéralement « les personnes qui portent le rôle formateur ». Le rôle **est** le critère. |
| L7 | **Le périmètre des écrans de pilotage** | `Http/Controllers/FrontCourseController.php:272` · `:356` · `Http/Controllers/Learner/EnrollmentController.php:277` | Liste des rattachés, lot de certificats, liste des demandes d'inscription : borné aux rattachés pour les rôles hiérarchiques, ouvert pour l'admin et le Manager RH. | Univers **Pilotage** : le périmètre d'encadrement est bien une affaire de rôle. Le `$role` lu en `:356` est ensuite consommé par le référentiel en `:419`/`:421` — bon motif. ⚠ écrit en énumération en `:274`/`:277` — voir F1. |
| L8 | **La progression, après #447** | `Http/Resources/CourseResource.php:80` | `progress` servi si `hasRole('learner')` **OU** inscrit. | **Le correctif modèle.** La condition **élargit** au lieu de remplacer : la branche rôle est conservée sciemment pour que le catalogue garde la donnée sur les cours non suivis. Documenté sur 15 lignes au-dessus. |
| L9 | **Colonnes d'export par rôle** | `Support/Listing/ListQuery.php:66` (→ `ListSchema::exportColumnsFor()`) | Quelles colonnes un rôle peut exporter. | Habilitation de lecture sur des colonnes de pilotage. La sélection cliente ne peut que **restreindre** (`ListSchema:176`). |
| L10 | **Compteurs propres au formateur** | `Helpers/CommonHelper.php:108` · `:128` · `Repositories/PointSystemRepository.php:407` · `:452` | Certificats et apprenants d'un formateur ; points d'accomplissement de formateur. | Métriques **définies par le rôle** (« les cours dont je suis le formateur »). Rien à voir avec la consommation de formation. |
| L11 | **L'horizon d'historique du cockpit** | `Http/Controllers/Manager/CockpitController.php:57` | 24 mois pour un encadrant, illimité pour l'admin (`CockpitHorizon`). | Le périmètre de consultation **est** une habilitation. Le contrôle de personne, lui, est déjà fait par `PersonVisibility` deux lignes plus haut (`:53`). |
| L12 | **Contexte de supervision technique** | `Support/Sentry/SentryUserContext.php:60` · `:64` | Le rôle attaché au signalement Sentry. | Diagnostic. N'ouvre ni ne ferme aucun accès. |
| L13 | **Destinataires d'une notification** *(P2)* | `Learner/EnrollmentController.php:181` · `:184` · `Manager/MyLearnerController.php:117` | À qui part la notification d'une demande d'inscription sans manager référent (admins + Managers RH) ; la liste des managers d'équipe. | On cherche littéralement « les personnes qui portent ce rôle » — comme L6. Le rôle **est** l'annuaire. |
| L14 | **Filtres d'écran choisis par l'utilisateur** *(P3)* | `Admin/UserController.php:167` · `Manager/CollaboratorController.php:238` · `Admin/UnassignedUserController.php:57` | La colonne « Rôle » filtrable d'une liste. | **Un filtre n'est pas un périmètre** — la distinction est écrite dans `docs/api/contrat_listes.md` et rappelée en `InvitationByEmailController:310`. L'utilisateur restreint ce qu'il voit déjà ; il n'élargit rien. |
| L15 | **Accomplissements de formateur** *(P3)* | `Repositories/PointSystemRepository.php:418` | Compte les abonnés d'un formateur qui sont des apprenants. | Métrique **définie par le rôle**, comme L10. |

### 4.2 Gardes SUSPECTES (17 occurrences · 13 sites) — la vague de purge #447

Détaillées § 5 avec scénario et criticité.

| # | Fichier:ligne | Ce que la garde décide | Pourquoi le rôle est la mauvaise clé |
|---|---|---|---|
| **S1** | `Http/Controllers/DashBoardController.php:481` | `switch` sur le rôle : l'en-tête « Mes formations » (cours, badges, points) n'est servi qu'au `case 'learner'`. | Univers Apprentissage. **C'est le volet BACK de FRONT#302** — la question ouverte de cette issue trouve ici sa réponse. |
| **S2** | `Http/Controllers/Learner/EnrollmentController.php:555` (garde `:562`) | `in_array($userRole, ['admin','teacher','manager'])` → succès **immédiat** sur `POST /learner/quiz/complete/{quiz}`, sans inscription, sans trace. | Contexte de **passation**. Le motif exact de #528, sur la porte voisine que PR#531 n'a pas franchie. |
| **S3** | `Http/Controllers/CourseResourceController.php:168` | Le contrôle d'inscription du téléchargement d'une ressource ne s'applique **qu'aux** porteurs de `learner`. | Motif #528 **retourné** : le rôle ne cache pas, il **ouvre**. Le commentaire l'assume (« traité par les écrans de pilotage ») — la route, elle, n'est derrière aucun écran. |
| **S4** | `Http/Controllers/FrontCourseController.php:423` · `:487` | `->role('learner')` sur les rattachés : le lot de certificats d'un encadrant ne contient que ceux des rattachés portant le rôle apprenant. | La population « qui s'est formé » est définie par l'**inscription**, pas par le rôle. Population #447 exacte. |
| **S5** | `Http/Controllers/Manager/MyLearnerController.php:41` (+ `:43`) | `User::role(['learner','manager_general','director','sub_manager'])` : la liste « mes apprenants ». | Même défaut, autre écran : `teacher`, `admin` et `manager` sont exclus de la population par **énumération de rôles**. |
| **S6** | `Repositories/PointSystemRepository.php:298` | `if (! in_array($role, ['learner'])) return false;` — l'attribution de points d'engagement. | La **racine donnée** de S1 : même corrigé à l'affichage, un encadrant inscrit n'aurait aucun point à afficher. |
| **S7** | `Http/Controllers/CourseController.php:53` | `if ($role == 'sub_manager')` : la liste des cours filtrée aux cours suivis par l'équipe. | Un seul rôle hiérarchique sur trois est filtré, et le filtre porte sur les inscriptions **de l'équipe** — un cours auquel l'encadrant est lui-même inscrit disparaît s'il est seul à le suivre. Voir aussi Q3. |
| **S8** *(P2)* | `Http/Controllers/DashBoardController.php:63` · `:95` | `User::role('learner')->count()` : « Total apprenants » et « apprenants actifs » du tableau de bord, en portée globale. | La population qui **se forme** est définie par l'inscription. Les 16 encadrants inscrits (#447) ne sont comptés nulle part. |
| **S9** *(P2)* | `Http/Controllers/Manager/MyLearnerController.php:83` | `User::role('learner')->findOrFail($id)` : l'avancement d'un rattaché. | **La garde que #450 a laissée en place.** Le commentaire ajouté juste dessous dit que le rôle de la cible n'était pas le sujet — et le `findOrFail` sur le rôle est resté : un encadrant inscrit rend **404**. |
| **S10** *(P2)* | `Http/Controllers/QuizController.php:281` | `User::role('learner')` : les destinataires de la notification d'un quiz autonome. | Le filtre d'**inscription** est appliqué juste après (`:284-288`) — il suffisait. Le filtre de rôle est redondant **et** exclut l'encadrant inscrit. |
| **S11** *(P2)* | `Http/Controllers/FrontCourseController.php:280` | `User::role(['learner','manager_general','sub_manager','director'])` : la liste des rattachés servie à l'admin et au Manager RH. | Même défaut que S5 : `teacher`, `admin` et `manager` sont exclus de la population **par énumération de rôles**, quel que soit leur rattachement. |
| **S12** *(P2)* | `Http/Controllers/FrontCourseController.php:327` | `User::role('learner')->whereNull('parent_id')` : le sélecteur des personnes à rattacher. | Un **encadrant non rattaché** n'apparaît jamais dans le sélecteur : il ne peut pas être rattaché par cet écran. |
| **S13** *(P3)* | `Support/Dashboard/DashboardScope.php:52` · `Manager/PilotageDashboardController.php:108` | `whereHas('roles', … 'learner')` : **la population de tous les tableaux de bord de pilotage** et des blocs de relance « en retard » / « abandons ». | La racine commune de S8. Un encadrant inscrit et en retard n'apparaît sur **aucune** liste de relance de son propre manager. |

### 4.3 À TRANCHER (15)

| # | Fichier:ligne | Ce qui est en jeu | La question, posée sans réponse |
|---|---|---|---|
| **T1** | `Http/Controllers/Admin/UserController.php:244` · `:248` · `:254` · `:260` (4) | Quatre branches `hasRole()` **strictement identiques** (`parent_id = auth()->id`) bornant la liste Admin › Utilisateurs. | Le référentiel donne `sees_everyone => true` au Manager RH (#63, arbitrage 09/08), et cette garde l'en prive. **Contradiction à corriger, ou règle voulue pour l'univers Administration ?** |
| **T2** | `Http/Controllers/Admin/UserController.php:418` · `:447` · `:473` · `:500` · `:528` · `:554` · `:580` (7) | `getRoleNames()[0] === 'manager'` (etc.) choisit **quel profil métier** mettre à jour. | Un porteur de deux rôles a-t-il deux profils ? Lequel l'écran édite-t-il ? → **cas particulier de Q1.** |
| **T3** | `Models/User.php:106` | `user_detail()` : `switch` sur le premier rôle pour choisir la relation de profil. | Idem T2, mais au **modèle** — donc pour tous les appelants à la fois. |
| **T4** | `Http/Requests/UpdateUserRequest.php:69` · `:204` (2) | Les **règles de validation** et leurs messages, choisis sur `getRoleNames()[0]`. | Un porteur de deux rôles est validé contre les règles de l'un des deux, au hasard de l'ordre Spatie. Quel jeu de règles fait foi ? |
| **T5** | `Traits/Response/CommonTrait.php:14` (+ `getUserDocumentDataByRole`) | `getUserRole()` puis comparaisons à `'Assureur'` / `'Broker'` — des rôles **qui n'existent pas** au référentiel `RoleHierarchy::ROLES`. | Vestige d'un autre produit. **Code mort à supprimer, ou dépendance oubliée ?** Le voisin `CommonHelper.php:72` porte l'avertissement : *« Du code mort qui accorde une portée globale finit toujours par être rebranché par quelqu'un qui se fie à son nom. »* |
> Compte : T1 (4) + T2 (7) + T3 (1) + T4 (2) + T5 (1) = **15 occurrences**.
> **Dix d'entre elles** — T2, T3, T4 — ne sont qu'une seule et même question
> posée en dix endroits : **Q1**, le multi-rôle. T1 relève de **Q2** (le
> périmètre du Manager RH) et T5 est une question de code mort.
>
> `CourseController.php:53` **n'est pas** compté ici : il est classé SUSPECT
> (S7) pour son effet mesurable, et la part « pourquoi seul `sub_manager` ? »
> est instruite en **Q3** — un même emplacement, jamais compté deux fois.

### 4.4 Rendus (22) — le rôle comme donnée, pas comme décision

Aucune décision d'accès. Le seul défaut qu'ils portent est celui du § 3 :
`->first()` n'annonce qu'un rôle sur N.

| Motif | Occurrences |
|---|---|
| Le rôle dans une ligne de liste ou une fiche | `Manager/CollaboratorController.php:280` · `:301` · `:302` · `:322` · `Manager/UserEnrollmentsController.php:92` · `:93` · `Admin/UnassignedUserController.php:93` · `Admin/ReferentialController.php:176` · `Admin/UserController.php:184` · `:655` · `:1170` · `Http/Resources/Admin/UserResource.php:24` · `:34` · `:63` |
| Le rôle dans un message de chat / une notification | `CourseChatController.php:88` · `:212` |
| Le rôle dans la réponse d'authentification (`type`, nom du jeton) | `Auth/SessionController.php:230` · `Auth/ForgotPasswordController.php:153` · `OnboardingController.php:93` |
| Le rôle rendu pour affichage, jamais lu | `MyProfileController.php:104` · `RoleReferentialController.php:43` |
| Le libellé du rôle dans un e-mail | `Mail/OnboardingMail.php:26` |

> ⚠ **`SessionController:230` est la source du problème FRONT.** C'est ce
> `'type' => $userRole` que le client stocke sous `userRole` dans
> `localStorage` — la donnée sur laquelle reposent les ~107 décisions
> recensées côté front. Le back **rend une liste de rôles** ; le front en
> **déduit des droits**. La doctrine de `useMyRole.js`, citée par PR#303, dit
> l'inverse : *« le serveur refuse ce qui n'est pas dû […] le client n'a pas à
> rejouer une autorisation qu'il ne détient pas. »*

### 4.5 Traces (4) — effet nul

| Fichier:ligne | Nature |
|---|---|
| `Http/Resources/OptionResource.php:37` | Commentaire citant la garde retirée par PR#531. **Trace documentaire à conserver.** |
| `Support/Quiz/AccesCorrige.php:16` | Idem, dans la docstring de la garde de remplacement. |
| `Support/Sentry/SentryUserContext.php:52` | Commentaire expliquant le `method_exists`. |
| `Helpers/CommonHelper.php:80` | Garde **commentée** (`// if (!$authUser->hasRole('sub_manager'))`), avec l'avertissement du § T5 juste au-dessus. |

### 4.6 Fragilités d'énumération (famille #450/#512) — transversal

Ces occurrences sont classées **LÉGITIMES** (le rôle y est la bonne clé) mais
portent le défaut **structurel** que #514 vise : elles énumèrent au lieu
d'interroger le référentiel. Un rôle ajouté demain ne serait pris en compte
nulle part — c'est le mécanisme exact de #450 puis #512, « deux fois en une
semaine ».

| # | Occurrences | L'énumération écrite à la main | Ce que le référentiel dirait |
|---|---|---|---|
| **F1** | `FrontCourseController.php:274` · `Learner/EnrollmentController.php:277` | `['sub_manager','manager_general','director']` | `hasHierarchicalScope()` rendrait `true` **aussi pour `manager`** — donc pas un remplacement direct : ces listes encodent en réalité `! seesEveryone()`. À expliciter dans le modèle cible. |
| **F2** | `Support/Content/CourseChatAccess.php:47` | `hasRole('sub_manager') \|\| hasRole('manager_general') \|\| hasRole('director')` | Même liste, même remarque. |
| **F3** | `Admin/UserController.php:244`→`:260` | Quatre `elseif` identiques | `hasHierarchicalScope()`, une branche — sous réserve de T1. |
| **F4** | `Manager/MyLearnerController.php:43` | `User::role(['learner','manager_general','director','sub_manager'])` | Aucune : la population devrait venir du **rattachement**, pas d'une liste de rôles (→ S5). |
| **F5** | `routes/api.php:179` · `:658` | `role:manager\|admin\|sub_manager\|director\|manager_general` et `role:admin\|manager\|sub_manager\|director\|manager_general\|teacher` | Deux listes **différentes** de « les encadrants », à deux endroits, sans que rien ne dise laquelle fait foi. |

---

## 5. Les suspects BACK, par criticité décroissante

> Ordonnés par **criticité**, non par numéro : les identifiants S1→S13 sont ceux
> du tableau § 4.2 et restent stables pour la revue.

Criticité selon la grille du cahier de recette (`Vital` · `Majeur` · `Mineur` ·
`Cosmétique`), telle qu'imposée aux issues par `docs/process/CADRE_ISSUES.md`.
**Elle est proposée** — la criticité finale appartient au produit.

---

### S2 — Un encadrant « termine » un quiz sans jamais le passer · **Majeur**

`app/Http/Controllers/Learner/EnrollmentController.php:555` (garde `:562`)

```php
$userRole = Auth::user()->getRoleNames()->first();
…
if (in_array($userRole, ['admin', 'teacher', 'manager'])) {
    return $this->jsonResponseSuccess(__('common.quiz.completed'));
}
```

**Le contexte de route aggrave le constat.** Le groupe `learner.`
(`routes/api.php:347-349`) porte `auth:sanctum` **et aucun middleware de rôle** —
exactement comme `GET /learner/quiz` dans #528. `POST /learner/quiz/complete/{quiz}`
(`:361`) est donc atteignable par **tout utilisateur authentifié**.

**Scénario d'exposition.** Un **Manager RH inscrit** à une formation obligatoire
ouvre un quiz et le valide. Le serveur lui répond « quiz terminé » **avant même
de chercher son inscription** (elle est chargée `:558`, puis ignorée). Aucun
`QuizAttemptRecorder`, aucune progression, aucun certificat. À l'écran : réussite.
En base : rien.

**Ce que ça coûte.** Trois effets distincts :
1. **Preuve QUALIOPI inexistante** — la formation est « faite » sans trace. C'est
   le symétrique de #528 : là-bas la preuve était *contestable*, ici elle est
   *absente*.
2. **Les trois autres rôles hiérarchiques** (`sub_manager`, `director`,
   `manager_general`) **ne** sont **pas** dans la liste : eux tombent sur le
   contrôle d'inscription normal. Deux encadrants inscrits à la même formation
   ont donc deux comportements différents selon leur rôle.
3. **Le premier rôle gagne** (§ 3) : un porteur de `['manager','learner']` passe
   ou ne passe pas selon l'ordre Spatie.

**Population.** Les 16 encadrants inscrits mesurés par #447, dont ceux portant
`manager`, `teacher` ou `admin`. Le compte exact appartient à une mesure staging.

---

### S3 — Tout encadrant télécharge les ressources de n'importe quel cours · **Majeur**

`app/Http/Controllers/CourseResourceController.php:168`

```php
// Un apprenant ne telecharge que les ressources des cours auxquels il
// est inscrit. Les roles d'encadrement ne sont pas restreints ici : leur
// perimetre est traite par les ecrans de pilotage.
if ($user->hasRole('learner')) { … 403 si non inscrit … }
```

**Le commentaire dit la règle, la route la dément.**
`GET /api/course/file/{id}/download` (`routes/api.php:718`) vit dans un groupe
`auth:sanctum` **sans middleware de rôle** (`:700`) : elle n'est derrière aucun
« écran de pilotage ». La méthode `download()` ne fait **aucune** autre
vérification (relue en entier, `:155-197`) — ni `AccesFormation`, ni
`AccesStructureCours`, ni `PersonVisibility`.

**Scénario d'exposition.** Un **formateur** (ou tout encadrant, ou tout compte
sans rôle — `hasRole('learner')` est alors faux) incrémente l'identifiant dans
`/api/course/file/{id}/download` et récupère **toute ressource de toute
formation** de la plateforme, y compris les cours non publiés dont il n'est ni
formateur ni manager. C'est le motif #528 **retourné** — le rôle n'interdit pas,
il **dispense du contrôle** — sur une porte de la même famille que #450/#512.

**Ce que ça coûte.** Fuite de contenu pédagogique inter-organisations. Sur un
socle **multi-tenant SaaS à venir** (#514), la même route franchira les sociétés.
La garde de remplacement existe déjà : `AccesFormation::accorde()` ou
`AccesStructureCours::autorise()`, celles-là mêmes sur lesquelles PR#531 s'est
appuyée.

> ⚠ **Garde-fou #447** : corriger en appliquant le contrôle d'inscription à tout
> le monde fermerait la porte au formateur du cours et au manager référent, qui
> ont un motif légitime de télécharger sans être inscrits. C'est **deux**
> conditions, comme dans `AccesCorrige` — la main sur le cours **ou**
> l'inscription.

---

### S1 — L'encadrant inscrit n'a ni points, ni badges, ni compteur de formations · **Majeur**

`app/Http/Controllers/DashBoardController.php:481` — et sa racine donnée
`app/Repositories/PointSystemRepository.php:298` (**S6**)

```php
$role = $user->getRoleNames()->first();
switch ($role) {
    case 'learner':  $data = ['courses' => …, 'badges' => …, 'points' => …]; break;
    case 'teacher':  … break;
    case 'admin':    $data = []; break;
    default:         // portée hiérarchique
        $data = ['users_in_the_team' => …, 'active_users' => …];
}
```

**Ce constat répond à la question ouverte de FRONT#302.** Cette issue demandait :

> *« `sidebar_data()` rend-elle quelque chose d'exploitable à un inscrit
> non-`learner`, ou le back applique-t-il le même filtre par rôle ? (si oui :
> volet BACK à ouvrir, comme #447) »*

**Réponse : le back applique le même filtre.** `GET /sidebar/statics`
(`routes/api.php:684`) sert à un encadrant inscrit `users_in_the_team` et
`active_users` — **jamais** `courses`, `badges` ni `points`. Corriger FRONT#302
seul afficherait un en-tête vide : **le volet BACK doit être ouvert**, exactement
comme l'issue le prévoyait.

**Scénario d'exposition.** Un directeur inscrit à trois formations ouvre « Mes
formations ». Le front (FRONT#302) ne demande pas l'en-tête ; s'il le demandait,
le back lui rendrait les compteurs de son **équipe** à la place de sa
gamification. Dans les deux cas il ne voit jamais où il en est.

**Et S6 en est la racine** : `PointSystemRepository:298` refuse d'attribuer le
moindre point à qui ne porte pas `learner`. Même les deux couches d'affichage
corrigées, le compteur afficherait **zéro** — et zéro rétroactivement, pour toute
la période écoulée. **Trois couches à corriger ensemble** (attribution → payload
→ affichage), sans quoi le correctif est cosmétique.

---

### S5 — Un inscrit portant `teacher` ou `admin` est invisible de son encadrant · **Majeur**

`app/Http/Controllers/Manager/MyLearnerController.php:41` (+ `:43`)

```php
$studentsQuery = User::role(['learner', 'manager_general', 'director', 'sub_manager'])…
if ($role == 'manager') { … }
```

**Scénario d'exposition.** Un manager d'équipe ouvre « Mes apprenants »
(`GET /api/manager/my-learners`, `routes/api.php:316`). Un rattaché portant
`teacher` — un formateur interne qui suit lui aussi les formations obligatoires —
**n'apparaît jamais dans la liste**, quelles que soient ses inscriptions. Son
encadrant ne peut ni suivre son avancement, ni relancer, ni produire sa preuve.
`admin` et `manager` sont dans le même cas.

**Ce que ça coûte.** Une population entière **invisible du pilotage**, sans
message ni compteur qui le signale. Pour un rattaché en formation obligatoire,
l'encadrant croit de bonne foi n'avoir personne à relancer. La bonne clé est le
**rattachement** (`parent_id`), pas une liste de rôles — c'est ce que fait déjà
`HierarchyResolver`.

---

### S4 — Les certificats des encadrants inscrits manquent au lot téléchargé · **Majeur**

`app/Http/Controllers/FrontCourseController.php:423` · `:487`

```php
$assignedLearners = User::where('parent_id', $user->id)
    ->role('learner')          // ← la population est définie par le rôle
    ->pluck('id')->toArray();
```

**Scénario d'exposition.** Un directeur télécharge le lot de certificats de son
équipe (`POST /api/manager/download-all-certificates`, `routes/api.php:325`).
Ses rattachés **encadrants** — managers d'équipe, formateurs — ayant terminé la
même formation obligatoire n'y sont pas. Pire (`:487`, le cas « tous cours ») :
si **aucun** de ses rattachés ne porte `learner`, `$completedCourses` est vide et
la réponse est un `404 « Aucun certificat n'a été trouvé »` — un message
**faux**, qui affirme l'absence de certificats qui existent.

**Ce que ça coûte.** Le lot de preuves QUALIOPI est **incomplet sans le dire**.
Un dossier de contrôle constitué depuis cet export omet silencieusement une
population — les 16 personnes de #447.

---

### S13 — Aucun tableau de bord de pilotage ne compte les encadrants inscrits · **Majeur**

`app/Support/Dashboard/DashboardScope.php:52` ·
`app/Http/Controllers/Manager/PilotageDashboardController.php:108`

```php
$query = User::query()
    ->whereHas('roles', fn ($q) => $q->where('name', 'learner'))
    ->where('excluded_from_stats', false);
```

**C'est la population de référence de TOUT le pilotage.** `DashboardScope`
alimente les compteurs de l'écran de pilotage ; `PilotageDashboardController`
construit les **blocs de relance** « en retard » et « abandons » — décrits en
commentaire comme *« des listes de personnes à contacter »*.

**Scénario d'exposition.** Un manager d'équipe ouvre son tableau de bord. Un de
ses rattachés, **directeur inscrit** à une formation obligatoire, est en retard
depuis six semaines. Il n'apparaît **sur aucune liste de relance**, n'est
compté dans **aucun** indicateur, et ne figure dans **aucun** export. Son
manager n'a aucun moyen de savoir qu'il doit le relancer.

**Ce que ça coûte.** C'est la racine commune de **S8** et le pendant « pilotage »
de S1 : la personne inscrite qui ne porte pas `learner` est **statistiquement
inexistante** pour toute la couche de pilotage. Sur une obligation de formation,
c'est un défaut de suivi, pas un défaut d'affichage. Noter que
`excluded_from_stats` existe déjà juste à côté : le produit sait exclure
volontairement quelqu'un des mesures — ici l'exclusion n'est **pas** voulue.

---

### S9 — L'avancement d'un encadrant inscrit rend 404 · **Majeur**

`app/Http/Controllers/Manager/MyLearnerController.php:83`

```php
$learner = User::role('learner')->findOrFail($id);

// #450 — `findOrFail` sur `role('learner')` ne vérifiait que le rôle de la
// CIBLE, jamais le droit de l'APPELANT : […]
if (! $this->visibility->peutConsulter(auth()->user(), $learner)) { … 403 … }
```

**La garde que #450 a laissée en place en la commentant.** Le correctif a ajouté
`PersonVisibility` — la bonne garde, sur l'appelant — **juste en dessous**, et a
laissé le `role('learner')` sur la cible. Le commentaire dit que le rôle de la
cible « n'était pas le sujet » ; il est pourtant toujours appliqué, et
`findOrFail` lève **avant** que la garde de périmètre ne soit atteinte.

**Scénario d'exposition.** Un manager d'équipe clique sur l'avancement d'un
rattaché **formateur inscrit** : `findOrFail` ne le trouve pas (il ne porte pas
`learner`) → **404**. L'écran annonce que la personne n'existe pas, alors
qu'elle est rattachée, inscrite, et visible dans d'autres listes.

**Ce que ça coûte.** Un **404 mensonger** sur une personne réelle. Et c'est la
démonstration que le correctif d'une faille de périmètre (#450) peut laisser
intacte une garde de rôle voisine dans la même méthode — l'argument central
de #514.

---

### S8 — Les compteurs du tableau de bord ignorent les encadrants inscrits · **Majeur**

`app/Http/Controllers/DashBoardController.php:63` · `:95`

```php
return User::role('learner')->where('is_active', true)->count();   // :63
$totalStudents = $assignedLearners ? … : User::role('learner')->count();  // :95
```

**Scénario d'exposition.** Un administrateur lit « Total apprenants » et
« Apprenants actifs » en tête de son tableau de bord. Les **16 personnes
inscrites sans le rôle `learner`** (#447) n'y sont pas. Le chiffre affiché
n'est ni « les apprenants », ni « les personnes qui se forment » — c'est « les
porteurs du rôle `learner` », une notion qui n'a pas de sens métier.

**Ironie documentée.** Le docblock immédiatement au-dessus (`:55-57`) prévient :
*« Deux chiffres qui se contredisent décrédibilisent tout le tableau de bord,
pas seulement la tuile fautive. »* C'est exactement ce qui se produit — le
compteur global et la liste des inscrits d'une formation ne peuvent pas
s'accorder.

---

### S10 — L'encadrant inscrit n'est jamais notifié d'un quiz autonome · **Mineur**

`app/Http/Controllers/QuizController.php:281`

```php
$learners = User::role('learner')
    ->whereNotNull('email')
    ->when($course !== null, fn ($q) => $q->whereIn('id',
        Enrollment::where('course_id', $course->id)->select('learner_id')), …)
```

**Le filtre d'inscription est appliqué juste après — il suffisait.** Le
commentaire au-dessus explique que le périmètre a déjà été corrigé une fois
(*« la notification partait à TOUS les apprenants de la plateforme »*) en
ajoutant le `whereIn` sur les inscriptions. Le `role('learner')` amont est
devenu **redondant**, et il exclut l'encadrant inscrit.

**Scénario.** Un quiz autonome est publié sur une formation ; les encadrants
inscrits ne reçoivent pas le courriel. **Mineur** : ils peuvent trouver le quiz
dans la formation ; seule la notification manque.

---

### S11 — La liste des rattachés servie à l'admin exclut les encadrants · **Mineur**

`app/Http/Controllers/FrontCourseController.php:280`

`User::role(['learner','manager_general','sub_manager','director'])` : `teacher`,
`admin` et `manager` sont exclus de la population **par énumération**, quel que
soit leur rattachement. Même défaut que **S5**, sur la branche servie à l'admin
et au Manager RH.

---

### S12 — Un encadrant non rattaché ne peut pas être rattaché · **Mineur**

`app/Http/Controllers/FrontCourseController.php:327`

`User::role('learner')->whereNull('parent_id')` : le sélecteur des personnes à
rattacher ne propose que des porteurs de `learner`. Un **manager d'équipe sans
manager** n'y apparaît jamais et ne peut donc pas être rattaché par cet écran.
**Mineur** : `Admin/UnassignedUserController` offre un autre chemin — qui, lui,
raisonne correctement (`whereDoesntHave` sur les rôles à portée globale, § L14).

---

### S6 — Aucun point de gamification pour un encadrant inscrit · **Mineur**

`app/Repositories/PointSystemRepository.php:298` — *traité avec S1, dont il est
la racine.* Coté **Mineur** pris isolément (la gamification n'est ni une preuve
ni un accès), **mais il conditionne la valeur du correctif de S1** : à corriger
dans la même passe, avec la question du **rattrapage rétroactif** des points non
attribués (→ Q5).

---

### S7 — Le manager d'équipe ne voit pas les cours que personne de son équipe ne suit · **Mineur**

`app/Http/Controllers/CourseController.php:53`

```php
if ($role == 'sub_manager') {
    $data = $data->whereIn('id', /* cours suivis par les rattachés */);
}
```

**Scénario d'exposition.** Un manager d'équipe ouvre la liste des cours
(`GET /api/manager/courses`, `routes/api.php:262`). Il ne voit que les cours
auxquels **son équipe** est inscrite — pas ceux auxquels **lui seul** est
inscrit, ni le reste du catalogue. Ses pairs `director` et `manager_general`,
même portée hiérarchique, reçoivent la liste **entière** : trois rôles de même
niveau, deux comportements. Voir Q3.

**Criticité Mineur** : gêne de navigation, aucune donnée d'autrui exposée
(le filtre **restreint**), aucune preuve perdue.

---

## 6. FRONT — le tableau exhaustif

Regroupé par motif (M1→M12). Chaque `fichier:ligne` est cité ; le compte reste
exhaustif. Tous les chemins sont relatifs à la racine d'`EFEKTIVACADEMIE-FRONT`.

### 6.1 Gardes LÉGITIMES (58)

| # | Motif | Occurrences | Ce que la garde décide | Justification |
|---|---|---|---|---|
| **M1** | **Gardes de route `<ProtectedRoute roles={[…]}>`** | `src/App.jsx` : `96` · `156` · `163` · `185` · `195` · `199` · `206` · `221` · `228` · `232` · `236` · `240` · `244` · `248` · `252` · `274` · `287` · `294` · `302` · `310` · `316` · `322` · `331` · `337` · `344` · `350` · `363` · `367` · `383` · `391` (30) | Quelles routes s'ouvrent : `/admin/*` → `["admin"]`, `/pilotage/*` et `/manager/*` → les 5 rôles d'encadrement, `/teacher/*` → `["admin","teacher"]`. | **« La route d'un rôle » au sens strict.** Et surtout : **aucune route de l'univers Apprentissage ne porte `roles=`** — la doctrine #447 est tenue là où elle compte. `ProtectedRoute.jsx:75` n'éjecte pas tant que le serveur n'a pas confirmé. |
| **M2** | **Redirection d'atterrissage après connexion** | `src/Components/Login/Login.jsx:103` · `105` · `107` · `109` · `111` · `src/App.jsx:414` · `416` · `418` · `420` (9) | La page d'accueil après authentification. | Ordonner « admin → pilotage → formateur → apprenant » est un choix de **destination**, pas un droit. `Login.jsx:99-102` documente le correctif FRONT#54. |
| **M3** | **Sélecteur d'univers (FRONT#269)** | `src/Utils/universRoutes.js:102` · `:142` (2) | Quels boutons Administration / Pilotage / Apprentissage s'affichent. | Le commentaire porte la règle : *« ce que le sélecteur ajoute est un CHEMIN, jamais un droit »*. Énumération **isolée, exportée et testée à part** — le bon motif. |
| **M5** | **Gardes d'écran d'administration** | `AdminCours.jsx:88` · `AdminCoursBalises.jsx:31` · `AdminCoursParcoursEditeur.jsx:90` · `AdminCoursParcoursListe.jsx:88` · `AdminUtilisateurs.jsx:114` · `AdminFicheUtilisateur.jsx:87` · `AdminParametres.jsx:41` · `Category.jsx:43` · `:44` · `SubCategory.jsx:47` (10) | Charger les données, ou `navigate("/")`. | **Redondantes** avec `roles={["admin"]}` de la route : n'exposent ni ne privent personne. La mention `manager` y est morte (→ Q6 front). |
| **M6** | **Gardes d'écran formateur** | `TeacherCourse.jsx:70` · `TeacherApprenants.jsx:57` (2) | Idem, sous `roles={["admin","teacher"]}`. | Redondantes, sans effet. |
| **M7** | **Boutons et onglets d'action réservés** | `TeacherCourse.jsx:75` · `useMyScope.js:55` · `StudioCours.jsx:26` · `:87` (4) | Le bouton « Modifier » réservé à l'admin ; l'onglet « Tous les collaborateurs » du Manager RH (#63) ; l'accès au studio de cours. | Actions réservées à un rôle. `useMyScope.js:19-20` rappelle explicitement que **le serveur renvoie 403** — le client ne fait que masquer. |
| **M12a** | **Une garde d'écran correcte** | `AddLesion.jsx:52` (1) | Charge la leçon pour `manager\|admin\|sub_manager`. | Le jumeau correct d'`AddQuiz.jsx:74` (S9 front), qui oublie `sub_manager`. |

### 6.2 Gardes SUSPECTES (51) — famille #447

| # | Motif | Occurrences | Ce que la garde décide | Criticité |
|---|---|---|---|---|
| **M12b** | **Écrans de pilotage hérités** | `CreateCourse.jsx:125` · `:500` · `CourseCompletionCard.jsx:31` · `:32` · `:33` · `:34` · `ManagerLearnerDetails.jsx:37` · `AddQuiz.jsx:74` · `AddUsersForm.jsx:110` · `:112` · `:114` · `DeleteUserForm.jsx:24` · `:26` · `:28` (14) | L'accès à l'écran Cours du pilotage, l'anneau de complétion, le service d'API de création / suppression d'utilisateur. | **Vital** (S1) à **mineur** |
| **M11** | **Menu « Parcourir » — trois copies identiques** | `NavbarPanel.jsx:741` · `742` · `743` · `744` · `745` · `746` · `781` · `782` · `783` · `784` · `785` · `786` · `845` · `846` · `847` · `848` · `849` · `850` (18) | Les colonnes catégories / sous-catégories / parcours du menu de la barre du haut. | **Mineur (latent)** — `manager_general` absent des trois listes ; inerte tant que `vitrineDansEnTete: false`. |
| **M10** | **Consommation de formation — univers Apprentissage** | `StudentCourses.jsx:90` · `MyCourse.jsx:625` · `:754` · `:854` · `:889` · `:376` · `NavbarPanel.jsx:1032` · `Paginations.jsx:38` · `:52` · `:54` · `:60` · `:68` (12) | L'en-tête de gamification, la grille de cartes à jauge de progression, le bouton « Reprendre », le lien « Certificat obtenu », les raccourcis « Mes formations ». | **Majeur** (S2, S3) à **cosmétique** |
| **M8** | **Carte de profil latérale héritée** | `Sidebar.jsx:343` · `:357` · `:404` · `:426` · `:455` · `:407` · `:429` (7) | Les blocs « Abonnés », « Points », « Utilisateurs / Actifs », « Cours », « Badges » de la carte de profil. | **Cosmétique** — `Sidebar` n'est monté que sur 4 écrans. |

### 6.3 À TRANCHER (46)

| # | Motif | Occurrences | La question |
|---|---|---|---|
| **M4** | **Aiguillage de l'appel « qui suis-je » par rôle** | `Sidebar.jsx:40` · `43` · `46` · `49` · `52` · `55` · `58` · `72` · `78` · `84` · `90` · `95` · `102` · `109` · `NavbarPanel.jsx:119` · `124` · `129` · `134` · `139` · `144` · `149` · `165` · `169` · `173` · `177` · `181` · `185` · `189` (28) | Sept endpoints distincts servent **la même chose** : le nom et l'avatar du porteur du jeton. Garde légitime, ou énumération à remplacer par un `/me` unique ? → **Qf1** |
| **M8b** | **Les sept chemins `/…/profile/edit`** | `Sidebar.jsx:298` · `299` · `300` · `301` · `302` · `303` · `304` (7) | FRONT#154 a unifié l'écran de profil : les 7 routes redirigent toutes vers `/profil`. Supprimer la cascade, ou la garder en compatibilité de liens ? → **Qf2** |
| **M10b** | **La bonne clé de la « vue apprenant »** | `MyCourse.jsx` — arbitrages liés à `:625` (9) | Inscription, URL (`/courses/enrolled`), ou rôle complété ? → **Qf3** |
| **M12c** | **La liste de rôles proposée au formulaire** | `AddUsersForm.jsx:267` · `:269` (2) | `managerformList` sert de **repli** à tous les non-`manager_general`/`director`. Repli voulu, ou référentiel à servir par l'API ? → **Qf4** |

### 6.4 INERTES (24) — code mort ou réglage éteint

| Fichier:lignes | Occ. | Pourquoi sans effet |
|---|---|---|
| `Menu.jsx:27` · `49` · `71` · `94` · `105` · `129` · `152` · `174` | 8 | `<Menu />` n'apparaît qu'à `Sidebar.jsx:511`, **à l'intérieur du bloc commenté** `{/* … */}` (l. 471-514). Les routes visées n'existent plus dans `App.jsx`. |
| `Footer.jsx:66` · `71` · `76` · `81` · `86` · `91` | 6 | `Footer.jsx:40` rend `null` tant que `parametres.js:140` fixe `piedDePage: false` — c'est-à-dire toujours. |
| `HeaderNavigation.jsx:29` · `31` · `34` · `36` | 4 | Même bloc commenté ; l'autre importateur est injoignable depuis `main.jsx`. |
| `AdminViewQuiz.jsx:21` · `AdminViewLesson.jsx:25` · `AddLesson.jsx:33` · `ViewQuiz.jsx:20` | 4 | Quatre composants **injoignables** depuis `main.jsx` (graphe d'imports vérifié). |
| `Catelog.jsx:56` · `NavbarPanel.jsx:670` | 2 | `isStudentView` **calculé et jamais lu** — reliquat de FRONT#193. |

> **Ces 24 occurrences ne se corrigent pas : elles se suppriment.** Les compter
> parmi les gardes gonflerait le chiffre de 13 % sans qu'aucune décision produit
> ne soit en jeu. Leur sort est **Qf5**.

---

### 6.5 Les suspects FRONT, par criticité décroissante

---

### Sf1 — Trois rôles sur cinq n'atteignent jamais l'écran « Cours » du Pilotage · **Vital**

`src/Pages/Manager/CreateCourse/CreateCourse.jsx:125`

```js
const userRole = lireRoles();
if (token && userRole.includes("manager")) { …charge l'écran… }
else { navigate("/"); }
```

**Scénario d'exposition, vérifié ligne à ligne.** Un **Manager d'équipe**
(`sub_manager`), un **Directeur** ou un **Manager Général** ouvre
`/pilotage/dashboard` et clique sur **Gestion › Cours** — une entrée déclarée
**sans aucune condition** dans sa propre barre latérale
(`Components/Layout/PilotageLayout.jsx:91`). La route `/manager/dashboard` les
autorise (`App.jsx:350`). Puis `CreateCourse.jsx:125` évalue
`lireRoles().includes("manager")` → **`false`** → `navigate("/")` (`:138`), et
`AuthRedirect` les renvoie au tableau de bord.

**Ce que ça coûte.** Il n'existe **aucun autre écran Cours** pour ces trois
rôles : la fonction leur est **totalement inaccessible**, alors que l'entrée
reste visible dans leur menu — un bouton qui ramène à son point de départ. C'est
le motif de **FRONT#300 à l'identique** (`handleTabSelect` énumérant les rôles),
mais sur l'écran entier au lieu d'un onglet. L'`admin` subit la même éjection ;
lui dispose de `/admin/cours`.

> **Vital** : trois rôles perdent une fonction entière du produit, sans message
> ni contournement. C'est le seul suspect des deux périmètres à ce niveau.

---

### Sf2 — L'encadrant inscrit perd progression, bouton et certificat · **Majeur**

`src/Components/MyCourse/MyCourse.jsx:625` · `:754` · `:376` (+ `:854`, `:889`)

```js
const isStudentView = role.includes("learner") || role.includes("sub_manager")
  || role.includes("manager_general") || role.includes("director") || role.includes("teacher")
// ← `admin` et `manager` (Manager RH) absents
```

**Scénario d'exposition.** Un **Manager RH** ou un **admin** inscrit à une
formation ouvre l'univers Apprentissage → « Mes formations ». `isStudentView`
est **faux** : il reçoit la liste-rangée héritée au lieu de la grille de cartes
à **jauge de progression**. Le bouton « Commencer / Reprendre / Revoir »
(`:754`) lui est refusé, l'entrée « Voir plus » (`:889`) aussi. Et `:376`
bloque l'appel `certificate_list()` : le lien **« Certificat obtenu »**
(`:799-825`) reste invisible **même sur un cours terminé**.

**Ce que ça coûte.** C'est le grief BACK#447 transposé au client, sur l'écran
principal de l'univers Apprentissage. Contournement partiel involontaire : le
bouton « play » de la vignette (`:709-725`) est inconditionnel — l'utilisateur
peut lire, mais ne saura jamais où il en est ni récupérer sa preuve.
**La bonne clé est l'inscription, pas le rôle.**

---

### Sf3 — L'en-tête de gamification ne se charge pas · **Majeur** *(= FRONT#302, déjà ouverte)*

`src/Pages/Student/StudentCourse/StudentCourses.jsx:90`

Le jumeau front de **S1** (BACK). Les deux couches doivent être corrigées
ensemble : le front ne demande pas la donnée, **et** le back ne la servirait pas
(voir S1 § 5). Avec **S6** (aucun point jamais attribué), cela fait **trois
couches** pour un seul en-tête.

---

### Sf4 — Création et suppression d'utilisateur en échec **silencieux** · **Majeur**

`src/Pages/GeneralManager/GeneralManagerUsers/AddUsersForm.jsx:110` · `:112` ·
`:114` — et `DeleteUserForm.jsx:24` · `:26` · `:28`

**Scénario d'exposition.** Un **admin** (ou un `sub_manager`) ouvre le
formulaire de création d'utilisateur. Les trois branches choisissent un service
d'API par rôle (`GeneralManagerService` / `DirectorService` / `CourseService`) ;
**aucune ne s'applique**, `response` reste `null`, `if (response?.data?.status)`
est faux — et **rien ne se passe** : aucun message, aucun `catch`, aucune trace.
Sur la suppression, `DeleteUserForm.jsx:31-33` ferme la modale après un simple
`console.warn` : **l'utilisateur croit avoir supprimé le compte.**

**Ce que ça coûte.** Un échec muet sur une opération d'administration est pire
qu'un refus : l'admin n'a aucun moyen de savoir que son action n'a pas eu lieu.
L'anti-pattern est double — liste blanche de rôles **et** aiguillage d'endpoint
par rôle, **sans branche par défaut**.

---

### Sf5 — Anneau de complétion vide pour l'admin · **Majeur**

`src/Components/CourseCompletionCard/CourseCompletionCard.jsx:31`→`:34`

Un **admin** atteint l'écran Cours du pilotage : `ShowStudentCoursesData()`
n'est pas appelé, l'anneau « Complété / En cours / Pas commencé » (`:40-55`)
reste `undefined`. Donnée pédagogique masquée par le rôle. *(Se compose avec
Sf1 : l'admin est de toute façon éjecté en amont par `CreateCourse.jsx:125`.)*

---

### Sf6 — La même question, deux réponses · **Majeur (dette de correction)**

`CreateCourse.jsx:125` (tableau, strict) contre `:500` (chaîne brute,
sous-chaîne) — détaillé en **§ 3.2**. Ce n'est pas un défaut fonctionnel
supplémentaire : c'est un **piège pour qui corrigera Sf1**, et il vaut pour les
onze lecteurs de chaîne brute recensés.

---

### Sf7 — Manager Général sans catégories · **Mineur (latent)**

`NavbarPanel.jsx:741-746` · `781-786` · `845-850` — **trois copies strictement
identiques** d'une liste blanche de six rôles où `manager_general` est absent.
Inerte tant que `parametres.js:136` fixe `vitrineDansEnTete: false` — **mais
c'est un réglage** : le jour où un client l'active, un Manager Général obtient
trois colonnes vides. Le motif de copier-coller que la règle **R3** vise
explicitement.

---

### Sf8 — `ManagerLearnerDetails.jsx:37` · **Mineur**

Un **admin** ou un **Manager Général** ouvre `/manager/learners/:id` — que
`App.jsx:367` autorise — et le composant le renvoie à `/`. Impact contenu : le
seul lien vers cette route vit dans un fichier injoignable depuis `main.jsx`.

---

### Sf9 — `AddQuiz.jsx:74` · **Mineur**

Un **Manager d'équipe** ouvre `/add-quiz/:slug` (route **sans** `roles=`) :
`setIsLoading(false)` sans chargement → **écran vide, sans message**.
`AddLesion.jsx:52`, l'écran jumeau, inclut pourtant `sub_manager` : deux écrans
frères, deux listes différentes.

---

### Sf10 — Libellé de pagination faux · **Cosmétique**

`Paginations.jsx:38` · `:52` · `:54` · `:60` · `:68` — le **nom** des objets
comptés (« cours » / « utilisateurs » / « données ») est déduit du rôle alors
que la prop `selectedType`, déjà passée, dit le type. Un formateur peut lire
« utilisateurs » là où il y a des cours.

---

### Sf11 — Carte de profil latérale · **Cosmétique**

`Sidebar.jsx:343` · `:357` · `:404` · `:426` · `:455` — un **admin** ne voit pas
le bloc « Utilisateurs / Actifs » (`:357` l'omet) ; un encadrant inscrit sans le
rôle `learner` ne voit ni ses cours, ni ses badges, ni ses points. Cosmétique
car `Sidebar` n'est monté que sur **4 écrans** (`AddQuiz`, `AddLesion`,
`ManagerLearnerDetails`, `CreateCourse`).

---

## 7. Les questions de frontière — pour la décision produit

Posées sans réponse, conformément au mandat. Elles conditionnent le modèle cible
(phase 2 de #514).

---

### Q1 — Un utilisateur a-t-il UN rôle, ou N ? *(la question qui en déverrouille 12)*

**Le fait.** Le code répond « un » **52 fois** (§ 3) et « N » ailleurs : Spatie
gère une relation *many-to-many*, `SessionController:230` rend une **liste** au
front, et #528 a été causé par cette contradiction. Les 12 emplacements « à
trancher » de T1→T4 en découlent tous.

**Ce qui doit être tranché.**
- **Un seul rôle** — alors il faut une contrainte d'unicité en base, une
  migration des comptes multi-rôles existants (combien ? à mesurer sur prod), et
  les 52 `->first()` deviennent corrects par construction.
- **N rôles** — alors il faut une **règle de composition** : union des droits ?
  Le plus élevé ? Un rôle « actif » choisi par l'utilisateur, cohérent avec le
  sélecteur d'univers de FRONT#269 ?

**Sans cette réponse, aucun des 12 « à trancher » ne peut être instruit.**

---

### Q2 — Le Manager RH voit-il tout le monde, ou pas ?

**Le fait.** `RoleHierarchy::ROLES` lui donne `sees_everyone => true` (arbitrage
du 09/08, #63). Trois gardes le respectent (`PersonVisibility:60`,
`CollaboratorController:122`, `DashboardScope:94`). **Quatre le contredisent** :

| Emplacement | Ce qu'il fait du Manager RH |
|---|---|
| `Admin/UserController:244` | le **borne à ses rattachés** (T1) |
| `TrackingStatsController:32` | le borne (commentaire explicite : *« il figurait ici à côté d'admin »*) |
| `InvitationByEmailController:312` | le borne (commentaire explicite) |
| `FrontCourseController:274` · `EnrollmentController:277` | ne le borne **pas** (il tombe dans le `else`) |

**La question.** `sees_everyone` s'applique-t-il **partout**, ou seulement à la
**consultation de personnes** — l'onglet dédié de #63 — l'accès aux **données**
(statistiques, invitations, utilisateurs) restant borné à sa hiérarchie ? Le
commentaire de `RoleHierarchy` dit *« l'accès à l'ensemble des utilisateurs passe
par son onglet dédié, et non par une portée plus large sur les écrans
courants »* — mais quatre gardes ont chacune interprété cette phrase
différemment. **Il faut une règle, pas une phrase.**

---

### Q3 — Trois rôles de même portée doivent-ils avoir le même périmètre ?

**Le fait.** `sub_manager`, `director` et `manager_general` ont tous
`scope => hierarchical` au référentiel, et `director`/`manager_general` ont
**le même niveau (60)** — le référentiel dit lui-même que *« la distinction est
de libellé seulement »* (F7). Pourtant `CourseController:53` ne filtre **que**
`sub_manager` (S7), et `MyLearnerController:46` ne traite spécialement que
`manager`.

**La question.** Ces écarts sont-ils des **règles voulues** (un manager d'équipe
a un périmètre plus étroit qu'un directeur, y compris sur le catalogue) ou des
**oublis d'énumération** de la famille #450 ? Si le référentiel dit « même
portée », **le code doit-il pouvoir en décider autrement, écran par écran ?**

---

### Q4 — Le client a-t-il le droit de décider d'un droit ?

**Le fait.** La doctrine écrite dans `useMyRole.js` et citée par PR#303 est
sans ambiguïté : *« le serveur refuse ce qui n'est pas dû […] le client n'a pas
à rejouer une autorisation qu'il ne détient pas. »* Et
`DelaySettingsController:156` montre le motif inverse — `can_edit_global` est
**calculé côté serveur et lu par l'écran**. Face à cela, le front porte ~107
décisions prises sur `localStorage.userRole`, alimenté par
`SessionController:230`.

**La question.** Où passe la ligne ? Trois positions possibles, à trancher :
1. **Aucune** décision de droit côté client — le serveur sert une **carte de
   capacités** (le motif `FeatureVisibility::mapFor()` existe déjà) et l'écran ne
   fait que la lire.
2. Le client peut décider de l'**affichage** (masquer un bouton pour éviter un
   403 découvert après saisie — l'argument de `can_edit_global`) mais jamais de
   l'**accès**.
3. Statu quo, avec une règle écrite disant lesquelles sont admises.

La position 1 **supprimerait la quasi-totalité des occurrences FRONT** ; la 2 en
garderait une partie, mais alimentées par le serveur au lieu du rôle. Le coût
diffère d'un ordre de grandeur : **c'est un arbitrage produit, pas technique.**

**Les 46 « à trancher » du FRONT en dépendent tous.** Ils sont listés ici pour
mémoire, subordonnés à Q4 :

| | Question | Occurrences |
|---|---|---|
| **Qf1** | Sept endpoints de profil servent la **même** chose — le nom et l'avatar du porteur du jeton. Garde légitime (chaque rôle a son API), ou énumération à remplacer par un `/me` unique ? Sous-question : un porteur de deux rôles n'appelle que le premier de la cascade, et **l'ordre diffère entre les deux `useEffect` du même fichier** (`Sidebar.jsx` : apprenant d'abord au montage `:40`, encadrant d'abord à la mise à jour `:72`) — quel profil doit-il voir ? | 28 |
| **Qf2** | FRONT#154 a unifié l'écran de profil : les sept chemins `/…/profile/edit` redirigent tous vers `/profil`. Supprimer la cascade, ou la conserver en compatibilité de liens ? | 7 |
| **Qf3** | Quelle est la bonne clé de la « vue apprenant » de `MyCourse.jsx` : l'**inscription**, l'**URL** (`/courses/enrolled` = univers Apprentissage, donc toujours vue apprenant), ou un test de rôle complété ? | 9 |
| **Qf4** | `managerformList` sert de **repli** à tous les rôles non énumérés du formulaire utilisateur. Repli voulu, ou référentiel de rôles à servir par l'API (comme `roleReferential` le fait déjà dans `AdminUtilisateurs.jsx:275-283`) ? | 2 |
| **Qf5** | Les **24 occurrences inertes** (§ 6.4) font-elles partie de ce chantier — les supprimer en retire 24 d'un coup — ou d'un lot de nettoyage distinct ? La réponse change le compte final de **13 %**. | (24) |

---

### Q5 — Que fait-on des données qu'un rôle a empêché d'écrire ?

**Le fait.** #447 posait déjà cette question — *« la progression est-elle
enregistrée pour eux, et seul l'affichage bridé ? Ou le suivi ne s'écrit-il pas
non plus ? »*. Deux suspects de ce document sont dans le **second** cas, celui de
la perte de données :

- **S6** — aucun point n'a **jamais** été attribué aux encadrants inscrits ;
- **S2** — aucune tentative de quiz n'a **jamais** été enregistrée pour les
  porteurs de `admin`/`teacher`/`manager`.

**La question.** Le correctif est-il **prospectif** (à partir de la mise en
production) ou **rétroactif** (rejouer l'historique : recalculer les points depuis
les événements existants, marquer les quiz « validés sans trace ») ? Et si une
tentative de quiz n'a laissé **aucune** trace exploitable, **que dit-on à
l'encadrant** qui croyait avoir validé sa formation obligatoire ?

C'est la seule question de ce document qui ait un **volet de communication**,
et elle porte sur une preuve QUALIOPI.

---

## 8. Méthode de recensement — rejouable

Chantier **documentaire** : aucun code modifié, **aucun test applicatif joué**,
aucune issue créée. La preuve d'un tel chantier est que **le compte se
reproduit**. Tout ce qui suit se rejoue depuis la racine du dépôt.

### BACK

```bash
# ══ PASSE 1 — les motifs du pilote ════════════════════════════════ 82
# 1a. Contrôleurs — attendu : 51 lignes, 21 fichiers
grep -rn  "hasRole\|getRoleNames\|hasAnyRole\|->role(" app/Http/Controllers/ | wc -l  # 51
grep -rln "hasRole\|getRoleNames\|hasAnyRole\|->role(" app/Http/Controllers/ | wc -l  # 21

# 1b. Resources — attendu : 5 lignes, 3 fichiers
grep -rn  "hasRole\|getRoleNames\|->roles\|role(" app/Http/Resources/ | wc -l         # 5
grep -rln "hasRole\|getRoleNames" app/Http/Resources/ | wc -l                         # 3

# 1c. Le reste de app/ — attendu : 26 lignes
grep -rn "hasRole\|getRoleNames\|hasAnyRole" app/ --include='*.php' \
  | grep -v "app/Http/Controllers/\|app/Http/Resources/" | wc -l                      # 26

# ══ PASSE 2 — angle mort n° 1 : le scope STATIQUE Spatie ══════════ 10
# `User::role(…)` n'est PAS capté par `->role(` : ce sont 7 suspects sur 17.
grep -rn "::role(" app/ --include='*.php' | wc -l                                     # 10

# ══ PASSE 3 — angle mort n° 2 : la jointure directe ═══════════════  6
# `whereHas('roles', …)` n'emploie AUCUN des motifs ci-dessus.
grep -rn "whereHas('roles'\|whereHas(\"roles\"" app/ --include='*.php' | wc -l        # 6

# ══ Le défaut transverse « premier rôle gagne » (§ 3.1) ═══════════ 52
grep -rn "getRoleNames()->first()\|getRoleNames()\[0\]" app/ --include='*.php' | wc -l # 52

# ══ Les énumérations de rôles au routage ══════════════════════════
grep -rho "role:[a-zA-Z_|]*" routes/ | sort | uniq -c | sort -rn

# ══ Constat structurel : ZÉRO permission, ZÉRO policy ═════════════
grep -rn "givePermissionTo\|Permission::\|syncPermissions\|hasPermission" app/ database/ \
  --include='*.php' | wc -l                                                            # 0
ls app/Policies 2>/dev/null || echo "aucun répertoire app/Policies"                     # aucun
```

**Total BACK : (51 + 5 + 26) + 10 + 6 = 98.** Les périmètres sont disjoints :
le `grep -v` de 1c exclut explicitement 1a et 1b, et les motifs `::role(` et
`whereHas('roles'` n'apparaissent dans **aucune** ligne captée par la passe 1
(vérifié : les intersections sont vides — `MyLearnerController:43` est bien
absent du résultat de 1a, `->role(` ne matchant que l'appel sur instance).

**Déduplication.** Une occurrence = **une ligne de code**. Une ligne portant deux
motifs (par ex. `CollaboratorController:302`, qui appelle `getRoleNames()` dans
`RoleHierarchy::labelOf()`) compte **une fois**. Les regroupements « par motif »
des tableaux §4 sont un confort de lecture : chaque `fichier:ligne` y est cité
individuellement, et la somme des lignes citées égale le total du tableau §2.

### FRONT

Depuis la racine d'`EFEKTIVACADEMIE-FRONT`, sur `src/`.

```bash
# Les sept rôles, guillemets doubles ET simples
for r in learner manager admin teacher sub_manager director manager_general; do
  printf '%-16s %s\n' "$r" "$(grep -rn -F "includes(\"$r\")" src/ | wc -l)"
done
# learner 30 · manager 27 · admin 45 · teacher 23 · sub_manager 17
# director 18 · manager_general 16   → union dédupliquée : 145 lignes

# Les équivalents et les angles morts
grep -rn -F 'lireRoles('              src/ | wc -l   # 37 (1 définition + 36 appels)
grep -rn -F 'useMyRole'               src/ | wc -l   # 36
grep -rn -F 'localStorage.getItem("userRole")' src/ | wc -l   # 14
grep -rn -F 'hasRole'                 src/ | wc -l   # 13  ← défini LOCALEMENT (Menu.jsx:23)
grep -rn -F 'roles={['                src/ | wc -l   # 34  ← invisible aux greps includes()
grep -rn -E 'ROLES\.'                 src/ | wc -l   # 1
grep -rn -F 'switch (role'            src/ | wc -l   # 0   ← aucun switch sur le rôle
grep -rn -F 'getRoleNames'            src/ | wc -l   # 0   ← n'existe pas côté client
```

**Total FRONT : 195 lignes appariées − 14 commentaires − 2 lignes de plomberie
d'import/ré-export = 179.**

**Déduplication.** Une occurrence = **une ligne de code qui décide à partir du
rôle de l'utilisateur COURANT**. Une condition multi-lignes compte pour autant
d'occurrences que de lignes (`NavbarPanel.jsx` : 18 lignes pour 3 conditions),
puis le tableau les regroupe en un motif unique.

**Exclusions, et pourquoi.**

| Exclu | Volume | Raison |
|---|---:|---|
| Attributs ARIA `role="alert"` / `"status"` / `"group"` | 76 | Ne décident d'aucune habilitation. **Aucune** prop métier `role=` dans le dépôt. |
| Fichiers `*.test.*` | — | Miroirs des occurrences de production. |
| Commentaires et code commenté | 14 | Recensés séparément, effet nul. |
| Le rôle comme **donnée** (rôle de la personne éditée ou filtrée, pas de l'utilisateur) | ~40 | `AddUserForm.jsx:278-303` (options d'un select), `AdminUtilisateurs.jsx:264-270` (facette de filtre) : équivalent des « Rendus » du BACK. |
| Plomberie d'import / ré-export | 2 | `NavbarPanel.jsx:6` et `:33`. |

### Ce que ce document ne prouve pas

- **Aucune mesure en ligne.** Les populations touchées sont citées d'après la
  mesure staging de #447 (38 inscrits, 16 sans le rôle `learner`, 16/08). Le
  compte **par suspect** — combien de personnes portent `admin`/`teacher`/`manager`
  ET sont inscrites (S2), combien d'encadrants ont un certificat non exporté
  (S4) — **n'a pas été mesuré**. Chaque suspect promu en issue devra porter sa
  propre mesure, comme #447 et #450 l'ont fait.
- **Aucun test applicatif.** Recensement statique. La classification d'un
  suspect est une **lecture de code étayée par le contexte de route**, vérifié
  dans `routes/api.php` — pas une reproduction.
- **Aucune exploitation.** Les scénarios d'exposition de S2 et S3 sont décrits
  d'après le code et le routage ; ils n'ont **pas** été rejoués sur staging.
  C'est le point improuvable hors ligne de ce chantier, et il **appartient au
  pilote**.

---

## 9. Ce que la phase 2 hérite

Ce document est le **socle factuel** du modèle cible. Trois acquis :

1. **`RoleHierarchy` est déjà un embryon de modèle déclaratif** — `level`,
   `label`, `scope`, `sees_everyone`, et les prédicats qui vont avec. Les 9
   gardes du motif L1 montrent que **le motif fonctionne** : elles interrogent le
   référentiel au lieu d'énumérer. La cible n'est pas à inventer, elle est à
   **généraliser**.
2. **Les gardes de contexte existent aussi** — `PersonVisibility` (#450/#512),
   `AccesStructureCours` (#368), `AccesFormation`, `AccesCorrige` (#528),
   `CourseChatAccess`. Chacune est née d'un incident, chacune est portée par une
   **méthode** et non par une route. C'est le second pilier.
3. **Zéro permission, zéro policy — le socle est vierge.**
   `spatie/laravel-permission` est installé, les tables `permissions` existent
   (migration `2025_06_27_110047_create_permission_tables.php`), et **aucune
   permission n'est définie ni vérifiée nulle part** ; il n'y a **aucun**
   `app/Policies`, **aucun** `Gate::`, **aucun** `->can()`. Toute l'autorisation
   du produit repose sur la **comparaison de chaînes de noms de rôles**.

   C'est une **bonne nouvelle pour la phase 2** : il n'y a pas de modèle
   concurrent à démanteler. Le passage à des policies Laravel + un jeu de
   permissions déclaratives est un **ajout**, pas une migration — la stratégie
   « sans big-bang » demandée au livrable 3 de #514 est structurellement
   possible.

4. **Ce qui manque est la RÈGLE DE DÉCLINAISON** — rien ne dit aujourd'hui
   *quand* on interroge le rôle et *quand* on interroge le contexte. Les 7
   sites sites suspects et les 5 fragilités d'énumération sont tous des endroits où cette
   règle absente a été improvisée. C'est l'objet du livrable 2 de #514.

**Ce document ne crée aucune issue.** La vague de purge #447 est une **décision
de revue** : les 7 sites suspects BACK (et leurs jumeaux FRONT) vivent ici jusqu'à ce
qu'elle soit prise.

---

*Cartographie établie le 19/08/2026 · [#514](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/514)*
*🤖 Generated with [Claude Code](https://claude.com/claude-code)*

---

## 8. Principes CIBLES du modèle d'habilitation (Enguerran, 19/08 — cadre du chantier)

Complément aux règles d'invitation (FRONT#299) et aux arbitrages A1/A3. Ces
principes cadrent la refonte : toute proposition du chantier s'y conforme.

### P1 — Niveaux hiérarchiques ABSTRAITS, agnostiques du nom
La logique d'autorisation raisonne sur des **rangs** (niveau 1, 2, 3…), pas sur
des noms de rôles. « Collaborateur », « Manager d'équipe », « Manager général »,
« Directeur » sont des **étiquettes de présentation** posées sur des niveaux —
renommer un rôle ne touche pas une règle.

### P2 — Héritage ascendant systématique
Le niveau N+1 **hérite de tous les accès du niveau N**, plus ses accès propres
(règles additionnelles à définir par niveau). On ne redéclare jamais à N+1 ce
que N possède déjà : un accès s'écrit UNE fois, au niveau le plus bas qui y a
droit.

### P3 — Accès RÉSERVÉS (hors héritage)
Une liste transverse d'accès **interdits en dehors de rôles désignés**
(type admin) — elle **prime sur l'héritage** : un accès réservé n'est jamais
acquis par rang, si haut soit-il. (Exemple déjà tranché : inviter un
administrateur est réservé à l'admin — même le Manager RH ne le peut pas.)

### P4 — Extensibilité sans casse
**Ajouter un niveau hiérarchique** (intercaler un rang) ne casse ni ne
reconstruit rien : le nouveau niveau s'insère dans la chaîne d'héritage et ne
définit QUE ses accès propres. Aucune règle existante n'est réécrite.

### P5 — Périmètre : l'API, pas l'écran
Ces règles portent sur les **autorisations côté back (API)** — le seul étage
qui fait foi (A3 : « serveur toujours, front cosmétique »). Ce que l'écran
affiche ou masque n'est PAS une habilitation.

### P6 — La présentation est un sujet PRODUIT séparé
Ce qui est effectivement **utilisé**, le **niveau d'agrégat** des données
exposées et la **présentation** peuvent différer par type de profil (gérant,
profil métier…). C'est une couche produit distincte, définie séparément, qui
consomme le modèle d'autorisation sans jamais le définir.

### Conséquences immédiates pour le chantier
- Les 277 gardes énumératives (`role === 'x' || role === 'y'`) migrent vers
  des prédicats de **rang** (`rank >= N`) + la liste des **réservations** —
  les deux familles couvrent la quasi-totalité des cas relevés en §2.
- La règle d'invitation (FRONT#299 règle 1) devient l'expression naturelle du
  modèle : `rang(invité) < rang(inviteur)`, exceptions = réservations (P3).
- Le piège de vocabulaire (`manager` technique = Manager RH ≠ Manager
  d'équipe) disparaît de la COUCHE DE RÈGLES : les noms ne portent plus de
  sémantique d'autorisation (P1).
