ADR-014 — MPStorage : adopter l’enveloppe v0.1 de facto (lecture d’abord, écriture opt-in)
Statut
Accepté — 2026-06-12 (proposé 2026-06-11 ; révisé à l’acceptation après audit adversarial ; écriture opt-in livrée 2026-06-19/20, #593/#597)
Cette ADR formalise les verdicts de l’exploration du format MPStorage réel (2026-06-10 : confrontation MPStorage.user.js / DTCloud.user.js / multimp.user.js / doc Wiripse) et des vérifications live du 2026-06-11 (découverte par recherche cat=prive, fixtures mp_storage_search_*). models.md § MPStorage porte la projection canonique de cette décision.
État d’implémentation (à jour clôture Phase 3, #598) :
- Livré : décisions 1 à 3 (client lecture) —
DefaultMpStorageRepository(découverte par scan de l’inbox, cf. Addendum ; lecture ducontent_form;MpStorageParsertolérant). NB :MpStorageDiscoveryParser(recherche par titre) a été supprimé par l’Addendum 2026-06-14 — la découverte par recherche serveur ne marchait pas sur un compte réel. - Livré : décision 5 — seed des positions DT (
MpStorageReadPositionSeeder) + onglet « DT » câblé (réglage section DT, OFF par défaut). - Livré (opt-in OFF) : décision 4 — écriture RMW guardée (#593) + déclencheur automatique de synchronisation de la position de lecture DT (#597, mode
writeBackFlagIfPresentUPDATE-ONLY). L’opt-in est OFF par défaut (KEY_SYNC_PRIVATE_MESSAGES_WRITE_ENABLED ?: false,DataStoreUserPreferencesRepository). Le POSTbdd.php cat=priven’a pas été observé live : l’implémentation est fail-closed. - À venir (Phase 4, #6/#577) : activation de l’opt-in par défaut (suppose l’arbitrage de la clé write-back tranché), cache Room du contenu MP, synchronisation bidirectionnelle complète.
Contexte
L’issue #6 (MPStorage, sync cross-plateforme) attendait « MPStorage2 » dans XaaT/hfr-redkit — qui est vide : la seule spec déployée et interopérable est le format v0.1 de facto, en production depuis ~2019 dans les userscripts (DTCloud pour les drapeaux DT, HFR4K, …). Par ailleurs, models.md décrivait un modèle (MPStorageData/MultiMPFlag(lastReadDate, pinned)) incompatible avec ce format réel — l’implémenter aurait cassé la compatibilité additive avec les userscripts (exigence Q4 de #6, non négociable).
Mécanique du format réel (détail complet dans le rapport #6) :
- stockage = premier post d’un MP dédié (sujet = hash fixe
a2bcc09b796b8c6fab77058ff8446c34, destinataire = compte tiersMultiMP) ; - enveloppe JSON
{ data: [ { version: '0.1', <clés par outil> } ], sourceName, lastUpdate }— namespacing faible, chaque outil pose ses clés dans l’entrée partagée ; - lecture = GET du formulaire d’édition du premier post (textarea
content_form) ; écriture = POSTbdd.phpcat=priveen remplacement intégral (last-write-wins, pas de verrou) ; - piège connu de la bibliothèque d’origine : contenu invalide → reset destructif au défaut ;
mpFlags.list[](DTCloud) = position de reprise de lecture par conversation DT ({uri, post, page, href: "t<numreponse>", p}— sémantique champ par champ dans le rapport #6), pas un lu/non-lu (cf. ADR-013/#361 : le lu/non-lu MP est le dot serveur binaire).
Vérifications live 2026-06-11 (GET only, compte XaTriX) :
- le mécanisme de recherche authentifiée répond :
forum1.php?recherches=1&cat=prive&search=<hash>&titre=1renvoie le listing standard (fixturemp_storage_search_hit.html, capturée sur un sujet réel du compte — pas un MP storage) ou la page « aucune réponse » (fixturemp_storage_search_no_results.html) — alors que la REST API rejettecat=prive. La requête réelle envoyée par le client porte les paramètres complets du formulaire HFR :config=hfr.inc,orderSearch=1,resSearch=50,daterange=2,searchtype=1, plusjour/mois/anneesérialisés à la date du jour (le formulaire les envoie toujours, même sidaterange=2les rend en principe inopérants) — cf.HfrClient.searchPrivateMessagesBySubject; - le compte de test n’a pas de MP storage : « pas de storage » est donc le cas nominal premier du client, pas un cas d’erreur. Corollaire : la découverte d’un vrai document storage n’a jamais été observée de bout en bout (cf. Trous de vérification).
Décision
- Geler le contrat sur l’enveloppe v0.1 de facto. Pas de « MPStorage2 » côté Redface 2 : toute extension passe par de nouvelles clés additives dans l’entrée v0.1. Si un MPStorage2 émerge un jour dans
hfr-redkit, il fera l’objet d’une nouvelle ADR (et le format v0.1 restera lu pour la migration). - Lecture d’abord. Phase 3 livre un client lecture seule dans
:core:data: découverte (recherche par sujet) → premier post (numreponsevia la page de conversation) → formulaire d’édition →content_form→ parsing tolérant (clés inconnues ignorées à la projection mais le JSON intégral est conservé dansMpStorageDocument.rawEnvelope). Si plusieurs conversations portent le sujet-hash, la découverte retient le premier résultat du listing tel qu’ordonné parorderSearch=1(MpStorageDiscoveryParser.parseFirstThreadId) — acceptable en lecture seule, mais ce choix devra être re-tranché avant l’étape écriture (écrire dans le mauvais document forkerait silencieusement le storage). - Jamais de reset destructif. Un document illisible = échec de lecture explicite surfacé à l’UI ; aucune écriture de « réparation ».
- Écriture opt-in — LIVRÉE (OFF par défaut, #593/#597). Read-modify-write pur immédiatement avant le POST
bdd.php cat=prive, déclenché à la sortie d’une conversation DT — pas une édition par page vue comme DTCloud ; les clés tierces durawEnvelopesurvivent au round-trip. Détail livré :- Deux modes :
writeBackFlag(manuel, add-or-update : crée l’entrée si absente) etwriteBackFlagIfPresent(#597, UPDATE-ONLY : ne réécrit que si la conversation est déjà présente, sinonSkippedNotPresentet aucun POST — c’est le mode du déclencheur automatique, anti-pollution). - Verify-after-write : relecture après POST ; restauration bornée à la corruption réelle (garde
isJsonEnvelope), jamais un rollback aveugle. - Cap :
MAX_CONTENT_FORM_BYTES = 64 KiB; dépassement ⇒TooLarge, aucun POST (fail-closed, HFR tronque silencieusement). - Opt-in OFF par défaut : gardé par le réglage
KEY_SYNC_PRIVATE_MESSAGES_WRITE_ENABLED; OFF ⇒DisabledByPreference, aucune requête. - Arbitrage de clé write-back (#597) : la clé d’identification d’une entrée (threadId du MP vs id de topic DT côté forum) comporte un risque résiduel d’écriture dans la mauvaise entrée. Il est borné par le mode UPDATE-ONLY (jamais de création silencieuse) mais non nul — à requalifier avant d’activer l’opt-in par défaut (Phase 4, #6/#577). Le contrat
bdd.php cat=privereste non observé live.
- Deux modes :
- Surface UI : l’onglet « DT » opt-in (PR #397) consommera
mpFlags(liste des conversations DT avec position de reprise) ; fusion avec le drapal local ADR-013 étage 1 (local prioritaire, MPStorage = seed + sync).
Conséquences
models.md§ MPStorage remplace les modèles inventés parMpStorageDocument/MpStorageFlagEntryet référence cette ADR.- Les ids découverts (mpId, numreponse du premier post) sont cachés par compte et purgés au logout (même règle de vie privée que #316) — implémenté (cf. Addendum 2026-06-14) : table Room
mp_storage_locations, et non DataStore comme initialement envisagé, pour aligner la purge surmp_read_positions/uploaded_imagesviaCacheInvalidator. - Risques assumés et documentés : lost-update inter-outils (full overwrite sans condition), taille max du post MP inconnue (la
listDTCloud n’est jamais prunée — la vérification de taille devra précéder toute écriture), dépendance au compte tiersMultiMP. - Trous de vérification restants (l’écriture #593/#597 est livrée fail-closed et OFF par défaut précisément parce que ces trous ne sont pas tous comblés — à lever avant d’activer l’opt-in par défaut) :
- effet du GET du formulaire d’édition sur le dot du correspondant (non mesuré par #361) ;
- contrat
bdd.php cat=priveen écriture toujours non capturé live — le POST est codé selon le contrat de réponse mais jamais exercé contre HFR ; - round-trip JSON réel post-écriture, jamais observé de bout en bout sur un vrai document ;
- arbitrage de la clé write-back (#597, cf. Décision 4) : risque borné par UPDATE-ONLY mais non nul.
Note : la découverte par recherche par titre (et donc la sensibilité aux paramètres de date
daterange=2+jour/mois/annee) n’est plus un trou : l’Addendum 2026-06-14 a remplacé la découverte par un scan de l’inbox, supprimant cette voie.
Addendum — 2026-06-14 (découverte corrigée, cache, seed DT)
Confrontation du source réel MPStorage.user.js (récupéré depuis le dépôt Wiripse) et vérification live sur un compte possédant un vrai document storage (XaTriX) :
-
La découverte de la décision 2 était cassée. La recherche par titre (
forum1.php?recherches=1&…&search=<hash>&titre=1) renvoie toujours « aucune réponse » sur un compte réel : l’index de titres HFR n’indexe pas le hash 32-hex. Le client #406 reportait doncNotFoundmême quand le storage existait. Le source userscript ne fait aucune recherche serveur : il pagine la boîte de réception (findStorageMPOnPage→forum1.php?cat=prive&page=N) et matche le sujet == hash côté client, puis cachempId/mpRepId(GM storage) — il ne redécouvre pas à chaque chargement.Correction livrée :
DefaultMpStorageRepositorydécouvre désormais par scan de l’inbox (réutilisePrivateMessageListParser, borné àMAX_DISCOVERY_PAGES),MpStorageDiscoveryParseretHfrClient.searchPrivateMessagesBySubjectsont supprimés. Le choix « premier résultat » de la décision 2 devient « premier sujet == hash rencontré dans l’ordre inbox » (à re-trancher avant écriture, inchangé). -
Cache des ids par compte (table Room
mp_storage_locations, purgeCacheInvalidator) — résout la conséquence « redécouvre à chaque fetch ». Cache périmé (formulaire d’édition introuvable) → purge + rescan. -
Décision 5 — application des
mpFlagslivrée en partie : un seeder (MpStorageReadPositionSeeder) projette les positions DT dempFlags.list[]dans le store localPrivateMessageReadPositionStore(tablemp_read_positions), seed local-prioritaire (jamais de recul de page). Déclenché une fois par session sur l’écran liste MP, gated par le réglage « section DT ». L’onglet DT dédié reste à câbler ; l’écriture (décision 4) reste différée & opt-in.
Confirmation de format (document réel, 16 clés dans l’entrée v0.1) : mpFlags.list[].post = threadId d’une conversation MP de groupe (cat=prive dans toutes les uri), page entier, p string. Le piège du reset destructif sur contenu invalide est confirmé dans le source (getStorageData réécrit le défaut) — non reproduit (ADR-014 décision 3).