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) → exposeregister_iot_routes(router), se branche via la coucheoptins/; - 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 amodule:installetmodule:remove(symétriques, retrait de bloc par marqueurs) ; - le système
optins/aoptin:enablemais pasoptin:disable; starter:buildinjecte 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¶
installn'active jamais automatiquement (§3 « refuser la magie cachée » garanti par le découpage) ;disablene 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-runimplicite, 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:*etoptin:*disparaissent au profit deopt-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:install— déclare le module dansforge_modules.json;module:files— copie les fichiers dans l'application ;module:routes— génère un fichiermvc/routes_<module>.pyséparé à copier (« Forge affiche », ne touche jamaismvc/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).