Aller au contenu

ADR-016 — Unification du modèle opt-in : concept unique, cycle install/enable à 4 verbes

Statut

Acceptée

Décision-cadre. Les tickets d'exécution la référencent (« charte appliquée : ADR-016 »).


Contexte

Forge distribue six briques optionnelles (forge-mvc-mfa, forge-mvc-rbac, forge-mvc-workflow, forge-mvc-stats, forge-mvc-media, forge-mvc-iot) en plus du noyau minimal. L'expérience de ces briques souffre aujourd'hui de trois incohérences accumulées au fil des versions :

1. Trois mots pour un même référent

Une même chose — une brique optionnelle — est nommée de trois façons selon l'endroit :

  • module : « modules officiels » (CLAUDE.md §1, ADR-005) ;
  • package : la distribution PyPI (forge-mvc-iot) ;
  • opt-in : « opt-ins officiels » (docs d'installation).

Pire, deux de ces mots sont doublement chargés : « module » désigne aussi le système local modules/ (module:install / module:remove, module.json) et « opt-in » désigne aussi la couche de câblage optins/ (register_optins).

2. Trois mécanismes de branchement hétérogènes

Les briques n'ont pas la même surface de contact avec une application :

  • possède des routes (iot) → expose register_iot_routes(router), se branche via la couche optins/ ;
  • bibliothèque pure (workflow, stats, media) → on importe et on appelle ses fonctions ; rien à brancher ;
  • transversale (mfa, rbac) → se greffe dans un flux existant (mfa_available() dans le squelette, décorateurs pour RBAC).

Conséquence visible : optin:enable ne supporte que iot, et forge_mvc_rbac s'auto-enregistre à l'import (un provider de contexte Jinja), ce qui contredit le principe §3 « refuser la magie cachée ».

3. Install / désinstall asymétriques

  • le système modules/ local a module:install et module:remove (symétriques, retrait de bloc par marqueurs) ;
  • le système optins/ a optin:enable mais pas optin:disable ;
  • starter:build injecte des routes mais n'a pas de retrait.

Le retrait propre existe donc côté « module » mais pas côté « opt-in ».

4. Squelette non neutre

mvc/routes.py livre par défaut des routes pré-câblées (auth, MFA conditionnel, starter welcome). Un développeur qui clone le dépôt ou installe via pipx n'obtient pas un squelette vierge : des capacités sont déjà branchées sans geste explicite de sa part.

Fenêtre temporelle

Forge est en bêta publique pré-1.0. La convention (CLAUDE.md §2) autorise les ruptures internes sans alias dépréciés avant le tag 1.0 stable. C'est la fenêtre idéale pour unifier sans dette de compatibilité.


Décision

D1 — « opt-in » est le concept unique

« opt-in » devient le seul terme désignant une brique optionnelle. Le mot « module officiel » est retiré de la documentation, des ADR et des messages CLI. « package » est conservé uniquement comme véhicule de distribution (« un opt-in officiel est livré comme un package PyPI »).

Le mot « module » reste disponible pour le seul système local (code que le développeur écrit lui-même), désormais traité comme un opt-in de source locale — sans recouvrement avec les briques officielles.

D2 — Deux axes orthogonaux, quatre verbes

Le cycle de vie d'un opt-in se décompose en deux axes indépendants :

  • présence (le code est-il sur la machine ?) → install / remove ;
  • activation (est-il branché dans l'app ?) → enable / disable.
                 install              enable
   absent  ───────────────▶  installé  ───────────────▶  activé
           ◀───────────────           ◀───────────────
                 remove               disable
Commande Axe Source officielle Source locale
forge opt-in:install <x> présence + pip install / pipx inject scaffold optins/<x>/
forge opt-in:enable <x> activation + câblage + register_optins injection de marqueurs dans mvc/routes.py
forge opt-in:disable <x> activation − retrait des marqueurs (laisse installé) idem
forge opt-in:remove <x> présence − pip uninstall / pipx uninject suppression de optins/<x>/ (gardée — voir D6)
forge opt-in:list lecture source + état (absent / installé / activé) idem

D3 — Garanties par construction

  • install n'active jamais automatiquement (§3 « refuser la magie cachée » garanti par le découpage) ;
  • disable ne désinstalle jamais (débranchement réversible sans perte de code).

D4 — Emplacement unique des opt-ins activés

Tout opt-in activé vit sous optins/<name>/, quelle que soit sa source. Le dossier contient deux natures de fichiers selon la source :

  • officiel → câblage mince généré (régénérable, write-if-new) ;
  • local → code utilisateur (protégé §9, hook forge-write-if-new.sh).

D5 — Fusion des moteurs et rupture CLI

Les deux moteurs module:* et optin:* fusionnent en une seule famille opt-in:*. Les anciennes commandes (module:install, module:remove, module:files, module:routes, optin:enable, optin:list) sont supprimées franchement, sans alias déprécié (fenêtre pré-1.0). Le moteur de retrait par marqueurs existant (module:remove, route_ops) est réutilisé pour opt-in:disable.

D6 — remove sur source locale est une opération gardée

opt-in:remove sur une source officielle se borne à désinstaller le package : aucun fichier utilisateur touché. Sur une source locale, il supprimerait du code écrit par le développeur ; il est donc :

  • non destructif par défaut : affiche ce qui serait supprimé (--dry-run implicite, convention Forge) ;
  • destructif seulement avec --force + confirmation explicite ;
  • aligné sur le hook forge-write-if-new.sh (le CLI ne le contourne pas).

D7 — Squelette neutre

mvc/routes.py livré par défaut ne contient qu'une route :

from core.http.router import Router
from mvc.controllers.home_controller import HomeController

router = Router()

with router.group("", public=True) as pub:
    pub.add("GET", "/", HomeController.index, name="home")

Auth, MFA et le starter welcome ne sont plus pré-câblés : ils s'ajoutent par opt-in:* (auth/MFA) ou starter:build (welcome).

D8 — Adaptateur trois-formes

Derrière l'interface uniforme opt-in:*, un adaptateur résout la forme de chaque opt-in (monter des routes / greffer des contrôleurs / pure bibliothèque / copie de fichiers locaux). L'uniformité vit au niveau du contrat, pas de l'implémentation ; l'utilisateur ne voit jamais la différence.

D9 — Les starters restent hors périmètre

Un starter est une démo pédagogique, pas une capacité optionnelle. Les commandes starter:* restent distinctes de opt-in:*.


Conséquences

  • Rupture CLI assumée : les commandes module:* et optin:* disparaissent au profit de opt-in:*. Acceptable en pré-1.0 (CLAUDE.md §2).
  • Renommage transverse : « module officiel » → « opt-in » dans CLAUDE.md, ADR-005, docs d'installation, textes d'aide.
  • Migration des tests : les meta-tests qui épinglent l'ancien vocabulaire, les anciennes commandes ou les blocs du squelette (test_app_default_no_mfa_001, test_module_remove_001, etc.) doivent être ajustés ou redéployés.
  • Correctif §3 : l'auto-enregistrement Jinja de forge_mvc_rbac à l'import doit redevenir explicite (sinon une brique se branche dans le dos de l'utilisateur, en contradiction avec D3).
  • Réversibilité acquise : tout opt-in devient désactivable proprement, ce qui n'était vrai que pour les modules locaux.
  • Découpage en tickets : vocabulaire → squelette neutre → CLI unifié → adaptateur + source locale → migration des tests (un ticket = une responsabilité, §5).

Alternatives considérées

Garder « module » comme mot canonique. Plus familier, mais « module » est déjà pris par le système local et par « module officiel » ; le mot ne porte pas la philosophie du choix explicite. Écarté au profit d'« opt-in », terme déjà présent dans la doc et porteur du principe §8.

Conserver les deux moteurs séparés (module:* et optin:*). Maintient la duplication et l'asymétrie de retrait. Écarté : la valeur recherchée est précisément une surface unique pour l'utilisateur.

Un seul verbe remove --purge plutôt que quatre verbes. Mélange les axes présence et activation dans une seule commande paramétrée. Écarté : séparer install/remove (présence) de enable/disable (activation) rend les états explicites et garantit « install n'active pas » / « disable ne désinstalle pas » par construction.

Uniformiser aussi l'implémentation du branchement. Forcerait une cérémonie register_optins vide sur les bibliothèques pures (stats, workflow, media). Écarté : l'uniformité doit vivre au niveau du contrat (D8), pas des mécanismes internes.

Inclure les starters dans le modèle opt-in. Les starters injectent aussi des routes, mais ce sont des démos pédagogiques, pas des capacités. Les fusionner brouillerait les deux intentions. Écarté (D9).


Amendements

A1 — Câblage opt-in : pattern registre, pas marqueurs (palier 3b)

D2/D4 décrivaient opt-in:enable comme « injection marqueurs + register_optins ». L'implémentation (palier 3b) a révélé que la couche optins/ existante utilise uniquement le pattern registre : un seul point d'injection dans mvc/routes.py (register_optins(router)) qui délègue à optins/registry.py, puis à chaque opt-in. C'est un design plus propre que des marqueurs par opt-in (un seul appel dans routes.py, aucun bloc par brique) et optin:enable --apply l'auto-injecte déjà.

Décision retenue : conserver le pattern registre pour le câblage des opt-ins. Les marqueurs # forge-starter:<id> restent réservés aux starters et aux modules locaux (qui injectent un bloc de routes). opt-in:enable n'utilise pas de marqueurs ; opt-in:disable est l'inverse exact d'enable (retire la couche optins/<name>/ et l'appel register_optins si plus aucun opt-in actif).

opt-in:enable/disable restent limités à iot jusqu'à l'adaptateur 3-formes (ticket 4), qui généralise le câblage aux six opt-ins.

A2 — Le système module:* local reste distinct (ticket 4b)

D5 prévoyait de fusionner les moteurs module:* et optin:*. L'implémentation (palier 4b) a révélé que le système de module local a un cycle de vie d'auteur fondamentalement différent du modèle opt-in à 4 verbes :

  • module:installdéclare le module dans forge_modules.json ;
  • module:filescopie les fichiers dans l'application ;
  • module:routesgénère un fichier mvc/routes_<module>.py séparé à copier (« Forge affiche », ne touche jamais mvc/routes.py) ;
  • module:remove — retire ce que Forge peut prouver avoir installé.

Ce déclarer → copier → générer-des-routes (on fabrique sa brique) ne mappe pas 1:1 sur install affiche / enable câble / disable débranche (on consomme une brique officielle). Forcer la fusion reviendrait à redéfinir un sous-système testé et à migrer ~40 tests pour un seul nom de commande unifié.

Décision retenue : ne pas fusionner. Le système module:* reste l'outil du workflow d'auteur de module local ; la famille opt-in:* couvre les opt-ins officiels (catalogue forge_cli/optins/catalog.py). Conceptuellement, un module local est un « opt-in de source locale » (vocabulaire unifié, glossaire), mais il garde ses commandes propres car son cycle de vie diffère. Quand un nom inconnu est passé à opt-in:*, la commande oriente vers forge module:install.

Cet amendement clôt la trajectoire ADR-016 : la surface opt-in officielle est unifiée (install/remove/enable/disable/list), kind-aware, sans commandes legacy.


Liens

  • Charte : principes §3 (magie cachée), §8 (noyau minimal, briques opt-in), §9 (pas d'écriture invisible), §11 (une seule façon officielle).
  • ADR-004 — périmètre du core minimal.
  • ADR-005 — packaging monorepo + distributions PyPI.
  • ADR-012 — politique de dépréciation (cadre pour la rupture pré-1.0 sans alias).