ADR-013 — Lecture MP : partage topic↔MP, cache à trois étages, prefetch borné
Statut
Accepté — 2026-06-12 (proposé 2026-06-10 ; révisé le 2026-06-12 après audit adversarial : descriptions actualisées au code livré, bornes du prefetch précisées — le fond des décisions est inchangé)
Cette ADR formalise les arbitrages rendus dans #351 (analyse code + addendum cache) et #361 (investigation live du contrat serveur lu/non-lu, 2026-06-09). Elle n’invente aucun verdict : chaque assertion factuelle sur HFR renvoie au commentaire d’issue qui l’a vérifiée.
État d’implémentation à l’acceptation : la décision 1 est livrée (PR #428 tranche a + #429 tranche b, ainsi que le prérequis UI keep-content des Conséquences) ; les décisions 2 et 3 restent à implémenter (aucun drapal local, cache RAM ni cache Room MP dans le code — suivis par #430 et #6).
Contexte
Le constat (#351)
Retour bêta v94 (0.6.0) : le swipe de pages ne marche pas dans les MP. Plus largement, la vue conversation (PrivateMessageThreadScreen) ne reprend aucun des gestes de lecture du topic (swipe #282, ascenseur #300, pull-to-refresh #335, cluster bas de page #283), et MessageCard duplique une version allégée de TopicPostCard.
L’analyse code sur dev a montré que le frein réel n’est pas la visibilité internal des composants topic, mais la divergence des modèles de pagination :
- topic = route-driven : le commit du swipe remplace la
TopicRoutecourante (nouvelle entrée nav, nouveau ViewModel, nouvelle composition). Toute la machinerie defeature/topic/.../TopicSwipe.ktrepose sur cette hypothèse — le latchcommittedn’est jamais réarmé explicitement, il est détruit avec la composition au changement de route ; - MP = in-place :
PrivateMessageThreadViewModel.selectPage(page)recharge la page dans le même ViewModel, même composition, même entrée nav.
Porter Modifier.topicPageSwipe tel quel sur les MP produirait donc un écran gelé après le premier swipe (latch jamais détruit). Par ailleurs, le ressenti instantané du swipe topic repose sur le cache Room et le prefetch anonyme — tous deux absents côté MP : cat=prive exige l’authentification (403 anonyme, #351), et la décision vie privée d’origine (#316 : routes opaques, pas de persistance) excluait tout cache MP. Cette décision « pas de cache MP » a été explicitement rouverte par XaaT le 2026-06-09 (addendum #351).
Le contrat serveur mesuré (#361)
Le prefetch MP butait sur un contrat serveur jamais mesuré (le comportement topic — GET authentifié = drapeau déplacé — était supposé s’appliquer aux MP). L’investigation live #361 (2026-06-09, compte XaTriX, sandbox = conversation existante déjà lue, état final restauré à l’identique) a établi :
- Q1 — un GET authentifié de n’importe quelle page d’une conversation
cat=priveefface le non-lu de toute la conversation ; le GET de la liste (forum1.php?cat=prive) est inerte ; - Q2 — « marquer comme non lu » =
GET /user/nonlu.php?...&cat=prive&post=<threadId>...sanshash_check; granularité binaire, conversation entière (le paramètrepagen’encode aucune position) ; - Q3 — il n’existe aucune position de lecture serveur pour les MP : pas de drapal en
cat=prive(zéronew=1, zéronumreponsenon nul, colonne drapeau vide), l’état serveur se réduit au dot binaire par conversation ; - MultiMP — l’état de lecture est visible des autres participants (span « Ce message n’a pas été lu par :
») : accusé de lecture de fait ; - compensation — la boucle
nonlu→ lecture est sans perte précisément parce que l’état est binaire (pas de position à perdre).
C’est le cas « (b) binaire » anticipé par #361, mais avec une observation clé qui change le verdict prefetch : l’ouverture d’une conversation consomme déjà tout l’état observable.
Décision
Les décisions 2 et 3 décrivent un état cible à implémenter ; la décision 1 est livrée (cf. Statut).
1. Partage topic↔MP à deux niveaux dans :core:ui — LIVRÉ (PR #428/#429)
- Les fonctions pures du swipe (
swipeTargetPage,swipeCommitDirection,swipeCommitDistancePx,swipeFollowOffset,swipeArmed,swipeEdgeHintAlpha) vivent danscore/ui/.../pager/PageSwipe.kt(publiques, testées parPageSwipeTest) — promues depuisfeature/topic/.../TopicSwipe.ktpar la PR #428. Elles portent l’intégralité du « ressenti » (seuils distance/vélocité, overpull, hint d’armement) et garantissent un geste identique sur les deux écrans. - Le scrollbar générique
LazyListScrollbar(paramétrique surLazyListState, callbacks internes au composant, zéro référence à un type topic — vérifié #351) vit danscore/ui/.../list/(promu par la même PR ; sous-packageslist/etpager/documentés dans architecture.md §:core:ui). Pas de nouveau module. - La machinerie gestuelle nav-driven (
Modifier.topicPageSwipe:pointerInput+ latch + slide-out, intrinsèquement couplée à la destruction de composition du modèle route-driven) reste dans:feature:topic. - Les MP ont une implémentation minimale in-place réutilisant les mêmes fonctions pures (PR #429,
feature/messages/.../ThreadPageSwipe.kt) : commit →selectPage(), gate relue en fin de chargement, sans slide-out (décision XaaT, #351).
2. Cache MP à trois étages
- Position de lecture locale par conversation (« drapal local », esprit MPStorage) : retenue inconditionnellement. Ce n’est pas un nice-to-have : c’est la seule option possible, puisqu’il n’existe aucune position de lecture serveur pour les MP (#361 Q3). Format aligné sur l’enveloppe v0.1 de facto (
MpStorageFlagEntry, ADR-014) pour la sync future via #6. Corrige au passage la restauration post process-death (la route reste figée sur la page d’ouverture, bug relevé en #351 puis tracé #430) — mieux queSavedStateHandle: survit au process death (stockage local). Purgé à la déconnexion comme le reste de l’état privé : la perte des positions au logout est assumée, le filet étant la sync MPStorage future (différée et opt-in). - Cache RAM de session : retenu. Purgé à la déconnexion, rien sur disque (et, étant en mémoire processus, il ne survit naturellement pas au process death). Donne les retours de page instantanés et permet de garder le contenu à l’écran pendant les chargements.
- Cache Room du contenu : opt-in explicite uniquement — toggle dans les réglages, défaut OFF, purge à la déconnexion. (Décisions XaaT 2026-06-09, addendum #351.)
Cette politique précise #316 sans l’annuler : les routes MP restent opaques (threadId, page), aucune métadonnée privée dans le back stack, et rien de persistant par défaut.
3. Prefetch : exception bornée à l’invariant « prefetch anonyme »
L’invariant général « les requêtes de prefetch ne sont jamais authentifiées » (protocol-hfr.md) reste en vigueur partout ailleurs. Pour les MP, où le prefetch anonyme est impossible (cat=prive exige l’auth), une exception bornée est définie :
- Autorisé : prefetch authentifié intra-conversation ouverte — les pages adjacentes (N−1 et N+1, le swipe est bidirectionnel) de la conversation que l’utilisateur lit ; « ouverte » = l’écran de la conversation est composé et au premier plan (un prefetch encore en vol quand l’écran se ferme s’annule avec le scope, il n’en repart pas de nouveau). Pas d’effet supplémentaire dans le cas nominal : le GET d’ouverture a déjà effacé le dot binaire de toute la conversation, et en MultiMP l’utilisateur est déjà sorti de la liste « pas lu par » (#361, verdict 1). Pas de compensation nécessaire. Hors cas nominal, une race documentée et assumée : un message arrivant entre la lecture de N et le prefetch d’une page adjacente verrait son dot effacé (et, en MultiMP, le read-receipt mis à jour) sans avoir été affiché — effet observable mais jugé bénin, l’utilisateur est précisément dans cette conversation. Cas particulier : si l’app expose un jour « Marquer comme non lu » (opportunité notée en Conséquences), un marquage manuel doit suspendre le prefetch de cette conversation jusqu’à réouverture — le raisonnement « le dot est déjà consommé » ne tient plus après un
nonlu.phpdélibéré. - Interdit : prefetch depuis la liste (conversations non ouvertes, dot non-lu) : il effacerait un non-lu jamais vu par l’utilisateur et le retirerait de la liste « pas lu par » des autres participants en MultiMP (read-receipt). La compensation
nonlu.phpserait sans perte, mais les deux requêtes ne sont pas atomiques : un crash entre les deux corromprait un état visible des autres clients (#361, verdict 2). Interdit en v1, réévaluable.
Conséquence d’implémentation : la garde Konsist actuelle (ArchitectureKonsistTest, test « prefetch call sites use the prefetch entry points only ») ne couvre que le domaine topic — elle interdit aux contextes prefetch d’appeler refreshTopicPage/refreshTopicList, avec le marqueur d’exemption konsist:bypass-prefetch-guard déjà en place. Un prefetch MP authentifié ne la déclencherait donc pas du tout : le travail réel de la PR qui introduira ce prefetch est d’étendre la garde au domaine MP (nommage dédié du point d’entrée + règle qui le reconnaît), pas d’exempter un call-site — à faire explicitement, pas silencieusement.
4. Critère de convergence route-driven topic↔MP
Le passage des écrans MP au modèle route-driven du topic (qui permettrait de porter la machinerie topicPageSwipe telle quelle et de fusionner les deux modèles de pagination) est conditionné à la réunion des deux prérequis :
- cache MP en place (RAM a minima, Room si opt-in activé) ;
- prefetch intra-conversation borné en place.
Tant qu’ils ne sont pas réunis, les MP restent in-place avec le swipe minimal (décision 1). Une fois réunis, la parité de ressenti avec le topic devient possible et la convergence peut être engagée — le swipe minimal in-place se remplace alors à coût nul, les fonctions pures partagées restant la base dans les deux cas (addendum #351, verdict #361).
Conséquences
:core:uiporte le scrollbar générique (list/LazyListScrollbar) et les helpers purs du swipe (pager/PageSwipe) ;:feature:messageset:feature:topicles consomment sans nouvelle arête de dépendance (les deux dépendent déjà de:core:ui). Livré, PR #428.- Prérequis UI côté MP :
selectPage()/ refresh ne doivent plus passer parPrivateMessageThreadUiState.Mode.Loadingplein écran (qui efface le contenu affiché) — contenu conservé + indicateur de chargement, tranche a du plan en trois tranches de #351. - La position de lecture locale introduit le premier stockage MP côté app : clé par conversation, format aligné MPStorage2 (#6), purge à la déconnexion comme le reste de l’état privé.
- Vie privée : rien de plus persistant par défaut qu’aujourd’hui. Le cache Room est OFF par défaut, purgé au logout ; les routes opaques de #316 sont inchangées.
- Opportunité produit hors périmètre de cette ADR : exposer « Marquer comme non lu » dans l’app — le contrat
nonlu.phpest trivial (GET sanshash_check, #361). - Pages canoniques mises à jour à l’acceptation (2026-06-12) : architecture.md (stratégie de cache MP, exception prefetch), protocol-hfr.md (exception MP à la règle prefetch, contrat
nonlu.php). navigation.md reste inchangé tant que la convergence route-driven (décision 4) n’est pas engagée.
Alternatives considérées
- Option A — porter
topicPageSwipetel quel sur les MP : rejetée. Le latch n’est réarmé que par destruction de composition (modèle route-driven) ; en pagination in-place l’écran serait gelé après le premier swipe — bug structurel garanti, pas un détail d’implémentation (#351). - Option B — module
:core:postlist/ composant de liste paginée unifié : rejetée dans sa forme module dédié. Sur-ingénierie à deux consommateurs, et les modèles de pagination divergent précisément là où le composant devrait être commun (#351). Réévaluable au troisième consommateur. - Option C — statu quo (MP sans gestes) : rejetée. Le coût de la version minimale est faible et le retour testeur resterait sans réponse (#351).
- Généraliser la machinerie gestuelle nav-driven pour couvrir les deux modèles : rejetée — complexité spéculative pour deux consommateurs ; la frontière retenue (fonctions pures partagées, machinerie par modèle de pagination) est plus simple et suffisante.
- Pas de cache MP du tout (décision d’origine, époque #316) : remplacée — rouverte explicitement par XaaT (addendum #351). Les garanties de #316 qui restent pertinentes (routes opaques, pas de métadonnée privée dans le back stack) sont conservées.
- Cache Room par défaut (opt-out) : rejeté — du contenu privé persisté sur disque sans consentement explicite irait contre l’esprit de #316.
- Prefetch depuis la liste avec compensation
nonlu.php: rejeté en v1 — deux mutations non atomiques sur un état serveur visible des autres clients (et des autres participants en MultiMP) ; une interruption entre les deux corromprait l’état (#361). - Position de lecture serveur : impossible, pas un choix — HFR n’offre aucun mécanisme de position pour
cat=prive, l’état serveur est un dot binaire par conversation (#361 Q3).