# ADR 0017 — Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé - Status: **Accepted** - Date: 2026-07-20 - Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc) - Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md) - S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur) ## Contexte Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 liée à la machine (ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition. Le recadrage du périmètre des abonnements par Max fixe la matrice suivante : | Module | Gratuite | Base | Premium | |---|---|---|---| | Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ | | Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ | | Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ | | Profil (un seul) | ✓ | ✓ | ✓ | | Budget | — | ✓ | ✓ | | Ajustements | — | ✓ | ✓ | | Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ | | Profils multiples | — | ✓ | ✓ | | Mises à jour automatiques | — | ✓ | ✓ | | Bilan complet (patrimoine, détail par titre, cours) | — | — | ✓ | Pas de quatrième édition « admin » : Max s'auto-émet une licence Premium. Trois contraintes structurent la solution : 1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first). 2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863). 3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC. ## Décision ### 1. Matrice statique côté UI + override signé `features[]`, fail-closed en Free La source de vérité front est `src/shared/entitlements.ts` : un type `FeatureKey` fermé (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance` — kebab-case car le namespace est partagé avec le `features[]` du JWT et avec Rust) et une table `ENTITLEMENTS: Record`. Les modules Free n'ont **pas** de clé : ce qui n'est pas dans la matrice n'est jamais gaté. `isEntitled(feature, edition, licenseFeatures)` combine la matrice et l'override signé par-licence, avec un court-circuit **fail-closed en Free** (CWE-863) : ```ts if (edition === "free") return false; // l'override ne ressuscite JAMAIS une clé déclassée return tiers.includes(edition) || licenseFeatures.includes(feature); ``` L'override `features[]` sert à débloquer une fonctionnalité au-dessus du tier d'une licence payante (ex. une licence Base portant `balance`), jamais à secourir une édition `free` — qu'elle soit native ou issue d'un déclassement machine-binding. Le même court-circuit existe côté Rust (`entitlements::is_entitled`), en défense en profondeur : `current_entitlements` retourne déjà `("free", [])` sur tout échec de validation, donc les features signées sont perdues dès le déclassement, et le court-circuit les refuse même si un chemin futur les laissait passer. `requiredTierFor(feature)` (dérivé de l'appartenance à la matrice, pas de l'ordre du tableau) alimente le libellé d'upsell « Obtenir Base / Premium ». ### 2. Enforcement UI-only — soft-paywall GPL assumé Le gating est appliqué **uniquement dans l'interface** (routes, navigation, points d'entrée). C'est un choix explicite, pas un oubli : sous GPL, un utilisateur qui recompile l'app sans les gardes est un cas assumé — le paywall s'adresse à l'utilisateur des binaires officiels, pas à un adversaire. La seule fonctionnalité réellement enforced l'est **côté serveur** : la récupération de cours (Premium) passe par maximus-api qui vérifie la licence à chaque requête ([ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Côté Rust, `FEATURE_TIERS` ne conserve que `auto-update` → Base+ (absorbe l'issue #271) — la matrice UI vit uniquement en TS, les deux tables ont des rôles disjoints. ### 3. `LicenseProvider` machine-level + `useEntitlement` synchrone anti-flash `LicenseContext` est monté **au-dessus** de `ProfileProvider` : la licence est une propriété de la machine, pas du profil actif, et le provider survit au remount `BrowserRouter key={refreshKey}` déclenché par un changement de profil. Il charge édition + licence **une fois au boot**, puis : - **Récupération d'erreur (CWE-703)** : le provider est un point unique de défaillance ; si l'invoke de boot échoue, l'état passe à `status: "error"` (édition précédente préservée) et un retry à backoff exponentiel plafonné (1 s → 30 s) relance le chargement. Les consommateurs rendent un placeholder neutre tant que `status !== "ready"` — jamais l'upsell — pour qu'une panne IPC transitoire ne verrouille pas un client payant. - **Validation de clé orthogonale** : un `submitKey` rejeté (clé mal saisie) alimente `validationError` sans toucher `status` — pas de flash de verrouillage sur une typo, et la boucle de retry ne peut pas s'armer sur une erreur de validation. `useEntitlement(feature)` retourne `{ allowed, ready }` (pas un booléen nu) : `allowed` est fail-closed pendant le boot (édition par défaut `free`), `ready` permet aux surfaces de supprimer le cadenas/upsell tant que la licence n'est pas résolue. ### 4. Upsell verrouillé visible, jamais masqué Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \ » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**. ### 5. Durcissement sec, non destructif Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils. ### 6. Dev-override compilé hors des builds normaux Pour tester les trois éditions sans forger de licences, `SR_DEV_EDITION` force l'édition résolue — mais **uniquement** dans un build compilé avec la Cargo feature `dev-override` (off par défaut, `cargo test --features dev-override`). Le choix d'une feature explicite plutôt que `debug_assertions` évite qu'un artefact debug distribué par erreur embarque la porte dérobée (CWE-489) : l'activation est un acte opt-in, jamais un effet de profil de build. ## Alternatives considérées ### A. Enforcement dur côté Rust pour tous les modules — rejeté Faire passer chaque gate par `check_entitlement` (IPC) et refuser les données côté commandes. Rejeté : sous GPL le client reste recompilable, donc la garantie est illusoire ; le coût est réel (latence, gestion d'erreur par gate, couplage des services SQL — qui n'appellent aucune commande Rust par convention — au module licence). Le seul enforcement qui vaut quelque chose est côté serveur, et il existe déjà pour les cours. ### B. Masquer les fonctionnalités non licenciées — rejeté Retirer de la Sidebar et du hub ce que l'édition ne couvre pas. Rejeté : l'utilisateur Gratuite doit **voir** ce que Base et Premium offrent (découvrabilité = le canal de vente d'une app sans télémétrie) ; un module invisible ne se vend pas. D'où l'upsell verrouillé : cadenas + écran explicite. ### C. Downgrade destructif ou données en lecture seule exportable — rejeté Purger ou geler les données des modules perdus au déclassement. Rejeté sans débat : contraire au principe privacy-first « vos données vous appartiennent », et transforme toute expiration de licence en incident. Le blocage d'accès réversible donne le même incitatif d'upgrade sans risque de perte. ### D. Édition admin dédiée — rejetée Une quatrième édition pour l'usage interne de Max. Rejetée : une licence Premium auto-émise donne le même résultat sans quatrième branche dans la matrice, les tests et l'UI. ### E. `features[]` seul, sans matrice statique — rejeté Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : chaque licence devrait énumérer toutes ses fonctionnalités (fragile à l'ajout d'un module — les licences déjà émises ne le porteraient pas), et le serveur d'émission deviendrait la seule source de vérité d'un comportement client. La matrice donne le défaut par édition ; l'override signé reste l'exception par-licence. ## Conséquences ### Positives - **Un point de vérité par couche** : `ENTITLEMENTS` (TS) pour l'UI, `FEATURE_TIERS` (Rust) réduit à `auto-update` — rôles disjoints, namespace kebab-case partagé (`features[]` JWT lisible par les deux). - **Fail-closed partout** : édition par défaut `free` au boot, override refusé en Free (CWE-863) des deux côtés, échec de résolution Rust → `("free", [])`. - **Pas de flash de verrouillage** : `{ allowed, ready }` + loader neutre dans `RequireFeature` + retry backoff dans le provider — un client payant ne voit jamais l'upsell sur une erreur transitoire. - **Zéro migration, zéro perte** : aucune table, aucun changement de schéma ; le déclassement est purement un état d'affichage réversible. - **#271 absorbé** : `auto-update` passe Base+ par une ligne de `FEATURE_TIERS`, sans code nouveau. ### Négatives / risques actés - **Contournable par build local** : assumé (GPL, soft-paywall). Ne jamais présenter ce gating comme une protection — la seule barrière réelle est serveur (cours). - **CTA d'achat inerte** : « Obtenir \ » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle. - **Deux tables à ne pas confondre** : un futur gate ajouté côté Rust dans `FEATURE_TIERS` ne gaterait rien dans l'UI, et réciproquement. La règle est documentaire (cet ADR + commentaires des deux modules). - **`dev-override` à surveiller en release** : la feature Cargo ne doit jamais apparaître dans un build publié ; le choix opt-in la rend improbable, pas impossible. ### Neutre - Le chemin abonnement Compte Maximus (Premium) ne porte pas de `features[]` — l'override est propre aux licences JWT ; c'est cohérent, Premium débloque déjà toute la matrice. - `useIsPremium` subsiste comme raccourci d'affichage (badge licence) au-dessus de `LicenseContext` ; `useLicense` (invoke par appel) est supprimé. ## Liens - `src/shared/entitlements.ts` — matrice `ENTITLEMENTS`, `isEntitled` (court-circuit Free), `requiredTierFor` - `src/contexts/LicenseContext.tsx` — provider machine-level, retry backoff (CWE-703), `validationError` orthogonal - `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium - `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé - `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création) - `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (machine-binding), `dev_override_edition` (CWE-489) - [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur - Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (câblage du CTA d'achat, à venir)