docs(correction): mon « écho refusé » était un écho PÉRIMÉ — deux cas, un symptôme

L'agent plugin a mesuré l'inverse sur la même box : écho verbatim, "auto" compris,
EnergyErrorNoError. L'exception existe déjà côté moteur — reconcileRankOrigin() accepte
"auto" quand le persisté le porte déjà, écrite exprès pour ne pas rompre la neutralité
Get → Set.

Ce que j'avais mesuré est autre chose : entre ma lecture et mon écriture, la marque avait
été consommée par une manipulation concurrente. Mon écho portait donc un "auto" que le
persisté ne portait plus — un écho périmé, correctement refusé. Deux cas distincts, un seul
symptôme, et c'est précisément pourquoi une écriture qui ne relit pas juste avant ne prouve
rien. Le contrôle de la suppression, lui, relit avant de conclure ; ma sonde jetable, non.

§10 corrigé : la ligne du tableau dit maintenant ce qu'elle mesure vraiment, et ce qui
reste à mesurer (un écho inchangé sur une marque FRAÎCHE). sansRankOriginAuto() est
documentée comme un contournement EN SURSIS — elle part au résultat de cette mesure, et la
marque survivra alors aux gestes qui ne la concernent pas. Rien n'est retiré d'ici là.

Ce qui reste vrai de mon relevé : "user" s'écrit, ne déplace pas le rang, survit aux gestes
voisins. Et l'effacement de "auto" que j'ai observé est le fait de l'APP — de son
nettoyage — pas d'un refus de la box.

── Deux pièges de sonde, consignés dans tools/rpc/README.md ─────────────────

1. Un refus arrive en `status: "success"`, verdict dans params.energyError. Une sonde qui ne
   teste que `status` annonce « accepté » sur une écriture rejetée.
2. Les identifiants ne s'écrivent pas pareil d'un appel à l'autre — le §9, documenté la
   VEILLE, et la sonde y est quand même tombée : elle rendait des verdicts sur une charge
   qu'elle ne regardait pas.

La leçon n'est pas « écrire des sondes plus soigneusement » : c'est que la sonde ne partage
pas les garde-fous de l'app. Quand la question porte sur ce que l'app fera, importer son
modèle vaut mieux que relire le JSON ; quand elle porte sur la box, croiser la réponse RPC
avec journalctl avant de conclure.

Et la raison lisible d'un refus n'existe QUE dans ce journal — côté RPC il ne reste qu'un
code. Silence de refus ouvert depuis le premier jour, rencontré une fois de plus, et raison
d'être des miroirs clients comme validateSet.

Rien de touché côté entrelacement : ça attend le moteur.

226 tests inchangés, analyze inchangé à 27.

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:44:14 +02:00
parent ba5a8eb230
commit 3f5f6e0d9f
4 changed files with 60 additions and 9 deletions

View File

@ -54,6 +54,13 @@ 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.
> ✅ **Répondu le 2026-08-29 : l'exception existe déjà.** `reconcileRankOrigin()` accepte
> `"auto"` quand le persisté le porte déjà. Le refus mesuré côté app portait un écho
> **périmé** — la marque avait été consommée entre la lecture et l'écriture — ce qui est le
> bon comportement, et un symptôme identique pour deux causes distinctes. La mesure sur
> marque fraîche est en cours côté moteur ; à son résultat, `sansRankOriginAuto()` disparaît
> côté app. **Tout ce qui suit reste comme trace de la demande, et de son argument.**
**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.

View File

@ -453,14 +453,27 @@ Une seconde wallbox simulée appairée après `+etm27` a produit les trois cas d
| Écriture | Réponse de la box | État après |
|---|---|---|
| renommage d'une AUTRE charge, `"auto"` renvoyé verbatim | `status: "success"` + `energyError: "EnergyErrorInvalidParameter"` | **inchangé** |
| renommage, `"auto"` renvoyé **alors que le persisté ne le portait plus** | `status: "success"` + `energyError: "EnergyErrorInvalidParameter"` | **inchangé** |
| même renommage, `"auto"` → `""` (ce que fait l'app) | `EnergyErrorNoError` | **la marque est effacée** |
| `"user"` sur la seule borne visée | `EnergyErrorNoError` | rang inchangé, seul ce champ change |
| deux renommages voisins ensuite | `EnergyErrorNoError` | **`"user"` survit** |
Le coût annoncé est donc réel et vérifié : le badge « rang posé par la box » a disparu d'une
borne que personne n'avait classée, au renommage d'une autre charge. La fragilité ne touche
que `"auto"` — `"user"`, lui, traverse les gestes voisins.
> ⚠️ **La première ligne ne dit PAS ce que j'ai d'abord conclu.** J'y avais lu « l'écho
> verbatim est refusé ». C'est faux : le moteur porte une exception écrite exprès
> (`reconcileRankOrigin()`) qui **accepte `"auto"` quand le persisté le porte déjà** —
> précisément pour ne pas rompre la neutralité `Get` → `Set`. Entre ma lecture et mon
> écriture, la marque avait été consommée par une manipulation concurrente : mon écho était
> donc **périmé**, et son refus est le bon comportement. Deux cas distincts, un seul
> symptôme — et c'est exactement pourquoi une mesure sans relecture immédiate ne prouve
> rien (cf. le contrôle de la suppression, qui relit avant de conclure).
>
> **Ce qui reste à mesurer** : un écho *inchangé* sur une marque *fraîche*. Tant que ce
> n'est pas fait, `sansRankOriginAuto()` reste — et le jour où l'écho passe, elle part et la
> marque survit aux gestes qui ne la concernent pas.
Le reste tient : `"user"` s'écrit, ne déplace pas le rang, et **survit** aux gestes voisins.
L'effacement observé de `"auto"` est, lui, **le fait de l'app** — de son nettoyage, pas d'un
refus de la box.
> ⚠️ **Un refus arrive en `status: "success"`.** Le verdict est dans `params.energyError`,
> et un contrôle sur `status` seul le prend pour un succès. Le service de l'app teste bien

View File

@ -155,11 +155,15 @@ List<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> entries) {
/// installation avec une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus
/// 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.
/// **Contournement EN SURSIS.** Le moteur porte déjà l'exception demandée —
/// `reconcileRankOrigin()` accepte `"auto"` quand le persisté le porte déjà, pour ne pas
/// rompre la neutralité `Get` → `Set`. Le refus mesuré le 2026-08-29 côté app portait un
/// écho **périmé** : la marque avait été consommée entre la lecture et l'écriture, et
/// refuser un écho périmé est le bon comportement.
///
/// Dès que l'écho *inchangé* sur une marque *fraîche* sera vérifié, **cette fonction
/// disparaît** — et la marque survivra alors aux gestes qui ne la concernent pas, ce qui
/// est le comportement voulu. Elle ne reste que le temps de cette vérification.
///
/// 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.

View File

@ -28,6 +28,33 @@ dart tools/rpc/<script>.dart [host] [args…] # host par défaut : 192.168.1
| `domain_assign.dart` | Classe des charges par `o:domain`, **par le code d'écriture de l'app**. | ⚠️ **OUI** |
| `fault_probe.dart` | Provoque une charge en défaut, éprouve `ClearLoadFault`, restaure. | ⚠️ **OUI** |
> ⚠️ **Une sonde écrite vite retombe dans les pièges que l'app évite.** Deux payés dans la
> même session, le 2026-08-29, dans des scripts jetables de vingt lignes :
>
> 1. **Un refus arrive en `status: "success"`.** Le verdict est dans `params.energyError` —
> `EnergyErrorInvalidParameter` pour un rejet, `EnergyErrorNoError` pour un succès. Une
> sonde qui ne teste que `status` annonce « accepté » sur une écriture que la box vient
> de rejeter, et c'est le journal de la box qui rétablit les faits. `NymeaService`
> teste bien les deux ; la sonde, non.
> 2. **Les identifiants ne s'écrivent pas pareil d'un appel à l'autre** — `{uuid}` en
> configuration, `uuid` nu côté Thing. Le §9 du contrat de données le documente **depuis
> la veille**, et la sonde est quand même tombée dedans : la comparaison n'a jamais
> trouvé sa cible, et le script a rendu des verdicts sur une charge qu'il ne regardait
> pas.
>
> La leçon n'est pas « écrire des sondes plus soigneusement ». C'est que **la sonde ne
> partage pas les garde-fous de l'app** : elle relit le JSON à sa façon, donc elle refait
> les erreurs que le modèle a déjà résolues. Quand la question porte sur ce que l'app fera,
> importer le modèle de l'app (`dart run`, cf. `measurement_check.dart`) vaut mieux que le
> relire — et quand la question porte sur la box, croiser la réponse RPC avec
> `journalctl -u nymead` avant de conclure.
>
> ⚠️ **Et la raison lisible d'un refus n'existe QUE dans ce journal.** Côté RPC il ne reste
> qu'un code : la box dit *« la charge "Simulated wallbox 2" prétend un rankOrigin "auto"
> que le moteur n'a pas posé »*, l'app reçoit `EnergyErrorInvalidParameter`. C'est le
> silence de refus ouvert depuis le premier jour, rencontré une fois de plus — et la raison
> pour laquelle chaque miroir client (`validateSet`) vaut le coût de sa maintenance.
> ⚠️ **Avant de rapprocher deux réponses.** Deux appels de la même box n'emploient pas
> forcément la même convention — clés de champs, graphie des identifiants, unités — et
> **rien ne le signale** : le croisement raté rend un neutre plausible, jamais une erreur.