Simpl-Resultat/docs/adr/0017-feature-gating-par-tier.md
le king fu 01da65c215 docs(gating): ADR 0017 + architecture + user guide + CHANGELOG
Document the edition-gating work (#297-#301):

- ADR 0017 (accepted): tier->features matrix, signed features[] override
  fail-closed in Free (CWE-863), UI-only enforcement as an assumed GPL
  soft-paywall (server-enforced price fetching stays the only hard gate),
  non-destructive downgrade, dev-override behind an explicit Cargo
  feature (CWE-489), rejected alternatives.
- architecture.md: new 'Gating par edition' section (entitlements matrix,
  LicenseContext, useEntitlement, RequireFeature/UpsellGate, NavLock,
  profileGate, Rust side), rewritten entitlements.rs section (auto-update
  now Base+, stale 'open to free' note removed), gated routes listed in
  the routing section, hooks table updated (useLicense removed in #297 ->
  useEntitlement/useIsPremium), ADR index + header refreshed.
- guide-utilisateur.md + docs.editions.* i18n keys (FR/EN) wired into
  DocsContent: new 'Editions' section with the Free/Base/Premium table,
  unlock flow and non-destructive locking tips.
- CHANGELOG.md + CHANGELOG.fr.md: one global [Unreleased] entry listing
  the modules now gated Base (Budget, Adjustments, advanced reports,
  multi-profile, auto-update) and Premium (Balance), the visible-but-
  locked upsell with disabled 'coming soon' purchase CTA, and the
  data-preserving behaviour.

Resolves #302

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:48:55 -04:00

14 KiB

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-plan-feature-gating.md
  • S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur ADR 0009 / ADR 0011 (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<FeatureKey, Edition[]>. 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) :

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). 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, <Outlet/> sinon) qui rendent UpsellGate en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir <tier> » 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 <tier> » 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.rsFEATURE_TIERS, is_entitled (CWE-863) ; license_commands.rscurrent_entitlements (machine-binding), dev_override_edition (CWE-489)
  • ADR 0009 / ADR 0011 — 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)