# ADR 0019 — Le format d'import est une donnée persistée intégralement, jamais re-devinée - Status: **Accepted** - Date: 2026-08-13 - Issues: #326 (corpus de fixtures figeant le comportement fautif), #323 (migration v17), #324 (codec `formatToRow`/`formatFromRow`, racine du bug), #325 (règle débit/crédit + parseur ancré), #327 (détection lexicale par libellé), #328 (score de confiance + détection autonome), #329 (aperçu obligatoire + recap signé), #330 (signatures de banques + dérive d'en-tête), #331 (sources et modèles dans le format SREF), #332 (cette doc) - Spec: [`spec-decisions-import-csv-format.md`](../../spec-decisions-import-csv-format.md), [`spec-plan-import-csv-format.md`](../../spec-plan-import-csv-format.md) - Prolonge [ADR 0003](0003-sqlx-migrations.md) (migrations SQL inline additives) pour la migration v17 ## Contexte Un import CSV transforme une cellule de texte en un montant signé au grand livre. Deux informations décident du signe, et elles seules : - **`amount_mode`** — le fichier porte-t-il un montant unique, ou deux colonnes débit et crédit ? - **`sign_convention`** — en montant unique, une dépense s'écrit-elle négative ou positive ? Ces deux champs vivaient sur `import_config_templates` mais **pas** sur `import_sources`. La source — la configuration réellement utilisée à chaque import — ne portait que les réglages mécaniques (délimiteur, encodage, format de date, mapping de colonnes). L'asymétrie était structurelle, et le code la comblait en devinant : `useImportWizard.ts:321-323` ré-inférait le mode depuis `mapping.debitAmount !== undefined` et écrivait `signConvention: "negative_expense"` **en dur**. Une source de carte de crédit configurée en dépenses positives revenait donc sur la convention par défaut à son **deuxième** import, et chaque montant était nié : les dépenses arrivaient en revenus. Sans erreur, sans avertissement, et sans contrôle agrégé capable de le voir — un signe inversé ne fait que changer le signe du total. Le défaut n'avait pas une voie mais **trois**, indépendantes l'une de l'autre : 1. **La restauration en dur.** Recharger une source configurée écrasait sa convention par la valeur codée en dur (#324). 2. **La dérive d'en-tête.** La banque déplace, renomme ou ajoute une colonne ; le mapping mémorisé continue de pointer sur des positions qui ne désignent plus les mêmes données, et l'import passe sans rien signaler (#330). 3. **L'export de données.** `dataExportService` ne sérialisait ni les sources ni les modèles, puis exécutait `DELETE FROM import_sources` à la restauration et les remplaçait par une source synthétique « Data Import ». Restaurer une sauvegarde détruisait toute configuration d'import (#331). Les trois produisent la même classe de panne — un format perdu, remplacé par une supposition — et une correction ponctuelle sur l'une n'apprend rien aux deux autres. ## Décision **Le format d'import est une valeur persistée intégralement sur `import_sources`, lue telle quelle, jamais re-inférée.** Quatre règles la tiennent. ### 1. La base porte le format en entier (migration v17) v17 ajoute quatre colonnes à `import_sources`, en additif pur (v1→v16 intactes) : | Colonne | Définition | |---|---| | `amount_mode` | `TEXT NOT NULL DEFAULT 'single'` + `CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'))` | | `sign_convention` | `TEXT NOT NULL DEFAULT 'negative_expense'` + `CHECK (sign_convention IN ('negative_expense','positive_expense'))` | | `header_signature` | `TEXT` — libellés normalisés de l'en-tête du dernier import réussi (détection de dérive) | | `template_id` | `INTEGER REFERENCES import_config_templates(id) ON DELETE SET NULL` | Le `CHECK` d'`amount_mode` admet `absolute_indicator` **dès maintenant** : le troisième mode de montant pourra être livré sans nouvelle migration, tandis que la base refuse déjà toute valeur corrompue. Le backfill (`UPDATE … WHERE column_mapping LIKE '%debitAmount%'`) reproduit exactement ce que le code déduisait à la volée — v17 ne change aucun comportement observable. `sign_convention` n'est délibérément **pas** backfillée : son `DEFAULT` restitue précisément la valeur que le code codait en dur, la seule convention passée qui puisse être inférée ; en deviner une autre réécrirait silencieusement du sens. `LIKE` plutôt que `json_extract`, pour que la migration ne dépende d'aucune extension JSON1 dans le SQLite embarqué. ### 2. Un codec unique, pas un type composé La garantie de complétude vient de **`src/utils/importFormat.ts`**, seul point de conversion entre : - `ImportFormatRow` — la forme persistée (snake_case, mapping en JSON, `has_header` normalisé en 0/1) ; - `ImportFormat` — la forme domaine consommée par le wizard et par `mapRow`. Un type `ImportFormat` **composé**, partagé par les deux porteurs, était l'approche naturelle et elle est **structurellement impossible** : `ImportSource.has_header` est déclaré `boolean`, `ImportConfigTemplate.has_header` est un `number`, et `SourceConfig` est camelCase sur un mapping déjà parsé. Les porteurs sont incompatibles ; le point de passage, lui, peut être unique. Ce qui donne des dents au codec est son **test de complétude**, sur deux niveaux : - `FORMAT_FIELD_PAIRS` est typé `Record` — un champ ajouté au format **ne compile pas** tant qu'il n'y figure pas ; - le test compare les clés réellement produites par chaque sens du codec à cette table — un champ déclaré mais non câblé **fait échouer le test**. Supprimer `sign_convention` de `formatToRow` — la forme exacte du bug d'origine — fait tomber 14 tests. Les deux écrivains de modèles passent aussi par le codec, pour qu'un nouveau champ ne puisse pas atteindre une table et manquer l'autre. `formatFromRow` **lève** sur une valeur qu'il ne sait pas lire au lieu de retomber sur un défaut : retomber sur la branche `single` lirait la mauvaise colonne à chaque ligne, et toute valeur autre que `positive_expense` signifierait silencieusement `negative_expense`. L'erreur porte une clé i18n et le wizard s'ouvre alors sur une configuration neuve — bloquer le wizard rendrait « reconfigurer cette source » impossible. ### 3. `template_id` est une étiquette de provenance, jamais relue comme format Un modèle sert à **initialiser** une source ; il ne la gouverne pas. `template_id` est enregistré, restauré et affiché, mais **jamais relu pour dériver un format**. Éditer un modèle ne change aucune source liée — c'est un critère d'acceptation testé de bout en bout, avec vérification de non-vacuité (le modèle a bien changé). `ON DELETE SET NULL` : supprimer le modèle efface l'étiquette et laisse le format intact. La raison est la même que celle du reste de l'ADR : si le format se déduisait du modèle, il redeviendrait une valeur devinée, portée par une ligne que l'utilisateur peut modifier ailleurs et à un autre moment. ### 4. Le format persisté gagne, et les deux autres voies de perte sont fermées - **Détection** — elle se déclenche seule sur une source **sans format enregistré**, et **jamais** sur une source qui en a un (garde `!existing`). Le bouton baguette reste, pour la rejouer à la demande. Une source dont le format stocké échoue à décoder émet son erreur explicite plutôt qu'une nouvelle supposition. - **Dérive d'en-tête** — `header_signature` stocke les libellés normalisés du dernier import réussi, en **tableau JSON et non en hachage** : le panneau doit pouvoir nommer les colonnes qui ont bougé (« Montant : colonne 3 → 4 »). Un en-tête qui normalise différemment ouvre `FormatDriftPanel` au-dessus de l'aperçu, avec les deux seules issues qui existent — **adopter** le format re-détecté ou **conserver** celui enregistré. Un simple renommage cosmétique normalise à l'identique et ne dit rien. Une source dont les fichiers n'ont pas de ligne d'en-tête garde `header_signature` à NULL et la détection de dérive y est inopérante : c'est documenté, pas contourné — une signature inventée depuis les données se déclencherait à chaque import. - **Export/import de données** — `import_sources` et `import_config_templates` sont sérialisés dans l'enveloppe SREF derrière un `format_version` explicite. Les modèles sont restaurés **avant** les sources (`template_id` est une clé étrangère), upsertés par nom, et `template_id` est remappé sur les identifiants résolus. Le wipe et la restauration sont enveloppés dans `withTransaction`. ## Alternatives considérées - **Un type `ImportFormat` composé, partagé par les deux tables.** Rejeté : impossible sans réécrire les deux porteurs (`has_header` `boolean` d'un côté, `number` de l'autre, mapping parsé vs JSON). Le codec obtient la même garantie sans toucher aux formes existantes, et son test la rend vérifiable — ce qu'un type partagé n'aurait pas donné à lui seul. - **Backfiller `sign_convention` en devinant depuis les données** (ex. « majorité de montants négatifs ⇒ `negative_expense` »). Rejeté : c'est exactement la re-inférence que cet ADR supprime, appliquée une fois pour toutes et sans possibilité de la relire. Le `DEFAULT` restitue la seule convention réellement en vigueur avant v17. - **Dériver le format depuis `template_id`.** Rejeté : voir la règle 3 — cela redonne au format un porteur mutable ailleurs. - **Hacher `header_signature`.** Rejeté : un hachage détecte la dérive mais ne peut pas la décrire, et le panneau de dérive n'aurait plus rien à montrer que « ça a changé ». - **Corriger rétroactivement les transactions déjà importées à l'envers.** Rejeté : on ne mute jamais des montants déjà écrits. La voie de réparation est `deleteImportWithTransactions` puis re-import — `RepairPathNotice` l'énonce dans l'interface, parce que ré-importer par-dessus **double** les lignes (`findDuplicates` apparie sur date + description + montant, donc une ligne corrigée ne s'apparie pas à la fautive). ## Conséquences - **Positif.** Le format d'une source est lisible en base et rejoué à l'identique. Le codec et son test forment le point de contrôle unique : ajouter un champ de format sans le persister ne compile pas, ou ne passe pas. Les trois voies de perte sont fermées par construction, pas par vigilance. - **Positif.** L'aperçu signé, obligatoire à chaque import, montre ce que le format *signifie* (sorties, entrées, totaux signés, lignes en erreur) — un score de lecture parfait ne dit rien du signe. - **Coût.** Une source dont le format enregistré est illisible (valeur hors `CHECK` arrivée par un chemin non prévu, JSON corrompu) affiche une erreur et repart sur une configuration neuve, au lieu de continuer sur un défaut. C'est le comportement voulu, mais c'est un arrêt visible là où il y avait un silence. - **Coût.** `header_signature` n'est écrite qu'à l'import réussi et reste NULL pour les fichiers sans en-tête : la détection de dérive ne couvre pas ces sources. - **À surveiller.** Le `CHECK` d'`amount_mode` admet `absolute_indicator`, que l'application **refuse** aujourd'hui avec un message dédié plutôt que de l'importer de travers. Le jour où ce mode sera livré, le codec devra l'accepter — la migration, elle, n'est pas à refaire. - **Portée.** Les modules d'import restent entièrement en édition Gratuite ; ce chantier n'ajoute aucun gating ([ADR 0017](0017-feature-gating-par-tier.md)). ## Liens - Spec : [`spec-decisions-import-csv-format.md`](../../spec-decisions-import-csv-format.md), [`spec-plan-import-csv-format.md`](../../spec-plan-import-csv-format.md) - [ADR 0003](0003-sqlx-migrations.md) — migrations SQL inline via tauri-plugin-sql (v17 en suit la convention additive) - [ADR 0004](0004-aes-256-gcm-encryption.md) — chiffrement de l'enveloppe SREF, dont le `format_version` est étendu par #331 - `docs/architecture.md` — section « Import CSV — format persisté et détection »