docs(motif): un interdit d'écriture doit dire ce qu'il fait d'un écho inchangé

§10 du contrat de données, à côté du §9. Le §9 disait que deux réponses de la même box
peuvent employer deux conventions sans le signaler ; celui-ci dit que deux RÈGLES posées
séparément, chacune juste, peuvent se contredire au point de rendre une écriture
impossible. Symptôme identique : muet.

L'app écrit verbatim (sinon un champ qu'une box plus récente porte serait perdu, en silence
et pour toutes les charges) ; le moteur refuse rankOrigin: "auto" à tout client (sinon un
écran fabriquerait une affirmation sur un geste du moteur). Leur produit : un simple
renommage rejeté en bloc dès qu'une installation porte une borne auto-provisionnée.

La règle doit porter sur le CHANGEMENT, pas sur la PRÉSENCE — même distinction que
l'effacement de rankOrigin, qui porte sur « rang différent pour cet id » et non sur
« écriture reçue ». Sinon la marque est fragile par construction : nettoyée à chaque
écriture, le badge s'éteint sur une borne que personne n'a classée au premier renommage
d'une autre charge. Une information qui s'efface au premier geste voisin ne vaut pas d'être
publiée.

Le §10 porte aussi le contrôle à passer une fois par règle d'écriture : l'app peut-elle lire
la valeur (alors elle la renverra) · l'écho inchangé est-il tranché par écrit (sinon c'est
un trou) · que devient l'information au premier geste voisin (si elle disparaît, la publier
est un leurre).

Et le motif derrière le motif, parce qu'il s'est répété : TROIS FOIS EN DEUX JOURS, le
chemin exposé n'était pas celui qu'on regardait — l'écriture du mode visait la première
borne (trouvé en déplaçant les boutons), le refus sur rangs doublés menaçait la suppression
et non le glisser-déposer, le refus de "auto" menace le renommage et non la gestion des
rangs. Le point commun n'est pas la malchance : ces chemins TRANSPORTENT des données qu'ils
ne regardent pas, donc ils sont exposés à tout ce qui a été ajouté après eux.

Le brief moteur passe de la question à la demande argumentée ; le nettoyage côté app reste,
documenté comme contournement — le jour où l'écho passe, la fonction disparaît.

222 tests inchangés, analyze inchangé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
Patrick Schurig 2026-08-29 10:23:25 +02:00
parent e26000945d
commit 761e1e369d
3 changed files with 98 additions and 6 deletions

View File

@ -23,10 +23,23 @@ sortie unique (`NymeaService.setLoadConfig`). L'écriture passe, mais la marque
la box » disparaîtra donc d'une borne que personne n'a classée, au premier renommage d'une
autre charge.
**La question** : un écho **inchangé** — même valeur, même id, rang inchangé — peut-il être
toléré ? Si oui, l'app le renverra tel quel et la marque survivra aux gestes qui ne la
concernent pas. Sinon, la marque est par construction fragile, et il vaut mieux le savoir
que le découvrir sur une installation client.
**La demande** : tolérer l'écho **inchangé** — même id, même rang, même valeur.
Le refus vise à empêcher un client de **fabriquer une affirmation** sur un geste du moteur.
Renvoyer à l'identique ce que le moteur a écrit n'affirme rien de neuf : **la règle doit
porter sur le changement, pas sur la présence** — exactement comme l'effacement de
`rankOrigin` porte sur « rang différent pour cet id » et non sur « écriture reçue ». Même
distinction, un cran plus loin.
L'alternative rend la marque **fragile par construction** : l'app doit alors la nettoyer sur
toutes les charges à chaque écriture, donc le badge s'éteint sur une borne que personne n'a
classée, au premier renommage d'une autre charge. Une information qui s'efface au premier
geste voisin ne vaut pas d'être publiée.
**Et une règle pour le contrat, parce que ça se reproduira** : une règle qui interdit une
valeur à l'écriture doit dire ce qu'elle fait d'un **écho inchangé**, sous peine de rendre
l'écriture verbatim impossible. Le motif est écrit côté app (`docs/UI_data_contract.md`
§10) avec le contrôle à passer une fois par règle.
Trois options écartées côté app, pour situer : refuser l'écriture (une installation avec
une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus systématique),

View File

@ -2,6 +2,9 @@
> Compagnon de `DASHBOARD_SPEC.md`. Tranche la question récurrente
> « **qui calcule les valeurs affichées ?** » pour chaque écran.
> Rev. 7 — ajout du **§10** : une valeur interdite à l'écriture doit dire ce qu'elle fait
> d'un écho inchangé, sinon l'écriture verbatim devient impossible. Et le motif derrière :
> trois fois en deux jours, le chemin exposé n'était pas celui qu'on regardait.
> Rev. 6 — ajout du **§9** : deux réponses de la même box peuvent employer deux
> conventions sans qu'aucun signal ne le dise, et l'erreur est toujours muette.
> Deux occurrences en une semaine (clés de log sans préfixe `currentPower`,
@ -409,6 +412,74 @@ chance de découvrir le bug ; afficher `0` l'enterre.
---
## 10. Une valeur interdite à l'écriture doit dire ce qu'elle fait d'un ÉCHO inchangé
> **Motif, comme le §9 — et il vient de le compléter.** Le §9 dit que deux réponses de la
> même box peuvent employer deux conventions sans le signaler. Celui-ci dit que deux
> **règles** posées séparément, chacune juste, peuvent se contredire au point de rendre une
> écriture impossible. Le symptôme est le même : muet.
### Le cas qui l'a produit
Deux règles, écrites à des moments différents, chacune correcte :
| Règle | Où | Pourquoi elle est bonne |
|---|---|---|
| L'app écrit **verbatim** — elle recopie ce que la box lui a rendu | `payloadWithoutLoad`, `flattenToPayload` | `SetLoadConfig` remplace tout l'ensemble : reconstruire depuis le modèle typé perdrait, en silence et pour toutes les charges, un champ qu'une box plus récente porterait |
| `rankOrigin: "auto"` est **refusé** à tout client | `isValid()` côté moteur | empêche un écran de fabriquer une affirmation sur un geste du moteur |
Leur produit : recopier verbatim une entrée marquée `"auto"`, **c'est écrire `"auto"`**. Donc
un simple **renommage** serait rejeté en bloc dès qu'une installation porte une borne
auto-provisionnée — motif invisible, le refus ne franchissant pas la frontière RPC. Le banc
ne pouvait pas le produire : aucune borne n'y a été détectée depuis `+etm27`.
### La règle, et pourquoi elle porte sur le CHANGEMENT
**Un interdit d'écriture doit se prononcer sur l'écho inchangé** — même id, même valeur,
même contexte. Renvoyer à l'identique ce que le moteur a écrit n'affirme rien de neuf : la
règle doit porter sur le **changement**, pas sur la **présence**. C'est exactement la
distinction que le moteur applique déjà à l'effacement de `rankOrigin` — il porte sur « un
rang différent pour cet id », pas sur « une écriture reçue » — à un cran de plus.
L'alternative rend la marque **fragile par construction** : l'app doit alors la nettoyer sur
toutes les charges à chaque écriture, et le badge s'éteint sur une borne que personne n'a
classée, au premier renommage d'une autre charge. **Une information qui s'efface au premier
geste voisin ne vaut pas d'être publiée.**
### Ce que ça impose à l'app, aujourd'hui
Le nettoyage vit à un **point de sortie unique** (`NymeaService.setLoadConfig`), jamais sur
chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne
l'annonce. C'est un contournement assumé, avec son coût écrit à côté — pas une solution.
### Le contrôle à faire, une fois par règle d'écriture
Pour chaque valeur qu'une box refuse en écriture, poser la question **avant** de la
rencontrer :
1. l'app peut-elle la **lire** ? Si oui, elle finira par la **renvoyer**, puisqu'elle écrit
verbatim ;
2. l'écho inchangé est-il toléré ? Si la réponse n'est pas écrite, **c'est un trou**, pas un
détail d'implémentation ;
3. si l'écho est refusé, que devient l'information au premier geste voisin ? Si elle
disparaît, la publier est un leurre.
### Et le motif derrière le motif
**Trois fois en deux jours, le chemin exposé n'était pas celui qu'on regardait** :
- l'écriture du mode de recharge visait « la première borne configurée » — trouvé en
**déplaçant** les boutons, pas en les testant ;
- le refus sur rangs doublés menaçait la **suppression** (qui réécrit verbatim), pas le
glisser-déposer qu'on surveillait ;
- le refus de `"auto"` menace le **renommage**, pas la gestion des rangs.
Le point commun n'est pas la malchance : c'est que ces chemins **transportent** des données
qu'ils ne regardent pas. Un chemin qui recopie est exposé à tout ce que la charge utile
contient, y compris ce qui a été ajouté après lui.
---
## Récap des décisions
1. Héos au centre du flow : **Option A — métaphore « cerveau » assumée**

View File

@ -153,8 +153,16 @@ List<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> entries) {
///
/// C'est le moindre mal entre trois options, aucune bonne : refuser l'écriture (une
/// installation avec une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus
/// systématique), ou effacer une marque informative. **Question posée au moteur** : un
/// écho *inchangé* de `"auto"` peut-il être toléré ? Cf. `docs/BRIEF_plugin_depuis_maquettes.md`.
/// systématique), ou effacer une marque informative.
///
/// **Contournement, pas solution.** La demande est portée au moteur : tolérer l'écho
/// *inchangé*, la règle devant porter sur le **changement** et non sur la **présence** —
/// même distinction que l'effacement de `rankOrigin`, qui porte sur « rang différent pour
/// cet id » et non sur « écriture reçue ». Le jour où l'écho passe, cette fonction disparaît
/// et la marque survit aux gestes qui ne la concernent pas.
///
/// Le motif général — *un interdit d'écriture doit se prononcer sur l'écho inchangé* — est
/// écrit en `docs/UI_data_contract.md` §10, avec le contrôle à faire une fois par règle.
List<Map<String, dynamic>> sansRankOriginAuto(List<Map<String, dynamic>> payload) => [
for (final m in payload)
if (m['rankOrigin'] == 'auto') {...m, 'rankOrigin': ''} else m,