docs(sync): freeze complete synchronization gap contract

This commit is contained in:
fy59 2026-09-16 22:07:55 +02:00
parent c04fa76f0b
commit 32877f11e9
9 changed files with 657 additions and 9 deletions

View file

@ -2,6 +2,16 @@
## Unreleased — 0.1.2
- Froze `TRAINLOG_SYNC_GAP_CONTRACT_V1` without changing production sync,
schemas or published formats. The contract distinguishes current evidence
from future behavior and defines domain ownership, active-draft lifecycle,
lost fields, causal deletion, conflicts, coherent generations, verified
consumption/acknowledgement, compatibility, reporting, a future Web service
boundary and dependency-ordered implementation lots. Added isolated
production-path characterization of V3 field loss, snapshot resurrection and
per-artifact rollback; existing Android coverage remains the proof of draft
exclusion followed by finalized-session export.
- Development opened after the stable `v0.1.1` release. The current
implementation cursor remains `WEB_DASHBOARD_CHARACTERIZATION_V1`; no Web
implementation has started.

View file

@ -37,6 +37,10 @@ evidence and chronology.
- [Synchronization exchange](sync_exchange.md) owns the active MTP workflow,
artifact direction, replay, publication, receipts, and daemon behavior.
- [Complete synchronization gap contract](design/sync_gap_contract_v1.md)
freezes the future lifecycle, causal deletion, generation, consumption,
acknowledgement and Web-service requirements. It is explicitly not an
implemented wire format.
- [Exchange formats](exchange_format.md) owns frozen Trainlog JSON V1 and
separately versioned contracts, including AI exchange definitions.
@ -60,8 +64,9 @@ evidence and chronology.
## Design
- [Design records](design/) retain proposals and design-gate evidence. They are
not the authority for current implementation status.
- [Design records](design/) retain proposals and design-gate evidence. The
synchronization gap contract is normative for future implementation; design
records do not override `current_state.md` for implemented status.
## Reviews and history

View file

@ -48,6 +48,7 @@ parallel implementation of its rules.
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
@ -81,12 +82,21 @@ raw exchange or protocol format changed for interface language support.
access. No GVFS/FUSE mount is required.
- `trainlog_sync_run()` is shared by the TUI and `trainlog-syncd`.
- The desktop imports Android snapshots, publishes catalog/profile/feedback/AI
companions, and returns a receipt. Stable IDs and tombstones make defined
replay paths idempotent.
companions, and returns a receipt. Stable IDs, artifact idempotence and the
few domain-specific explicit states make defined replay paths idempotent.
Current snapshots do not provide general tombstones, a common generation or
proof that Android durably consumed the desktop publication.
- SQLite database files are never synchronized.
- The desktop AI flow uses external `rclone` for Drive inbox/archive and
read-only history export. Android owns no Drive credentials.
The frozen [complete synchronization gap contract](design/sync_gap_contract_v1.md)
documents the future target without implementing it. Mobile V3 still omits
`ended_at`, session/occurrence/body-observation notes, the body-observation to
session link and the active Android draft. Imports are transactional per
artifact, not globally across a logical publication. A receipt records desktop
processing; publication alone does not prove durable peer consumption.
## Android
Android currently provides:
@ -171,9 +181,9 @@ browser launch or layout persistence exists yet.
## Validation status
The durable commands are owned by [tests.md](tests.md). On 2026-09-15 final
validation passed desktop compilation, **60/60 normal Meson tests** and
**60/60 ASan/UBSan Meson tests**, Android debug assembly and **202 Android
The durable commands are owned by [tests.md](tests.md). On 2026-09-16 final
validation passed desktop compilation, **74/74 normal Meson tests** and
**74/74 ASan/UBSan Meson tests**, Android debug assembly and **202 Android
tests (198 passed, 4 skipped, 0 failed)**, the JSON validator, and the
import-contract validator. The source-derived TUI
`TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and

View file

@ -0,0 +1,441 @@
# TRAINLOG_SYNC_GAP_CONTRACT_V1
Statut : `CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
Ce document définit la cible produit d'une synchronisation complète sans
modifier les formats, schémas, moteurs, interfaces ou transports de production.
Les formes JSON décrites ici sont des **spécifications non implémentées**. Elles
ne sont acceptées par aucun importeur actuel.
Les mentions normatives sont :
- **[ACTUEL DÉMONTRÉ]** : comportement observé dans le code ou un test ;
- **[CIBLE DÉCIDÉE]** : décision produit de ce contrat ;
- **[CONTRAT FUTUR — NON IMPLÉMENTÉ]** : obligation d'un lot ultérieur ;
- **[OUVERT/BLOQUÉ]** : choix ou validation qui reste à fermer.
## 1. Principe et frontières
**[CIBLE DÉCIDÉE]** Une donnée d'entraînement destinée à plusieurs clients est
synchronisable. Une préférence propre à un appareil ou une interface reste
locale. Le desktop est le hub qui orchestre un run ; il n'est pas, par ce seul
fait, l'autorité absolue de chaque domaine.
**[ACTUEL DÉMONTRÉ]** Android et desktop échangent principalement par MTP dans
`Documents/Trainlog`. `trainlog_sync_run()` orchestre le moteur commun au TUI et
à `trainlog-syncd`, sous `$XDG_DATA_HOME/trainlog/sync.lock`. Google Drive porte
le brouillon IA entrant et l'export d'analyse IA, pas les séances ordinaires.
Les formats mobiles actifs sont V3 et les companions restent indépendants.
Sources : `tui/src/sync.c`, `tui/include/trainlog/sync.h`,
`tools/trainlog_syncd.py`, `tools/sync_ai_session_draft_drive.py`,
`tools/post_sync_ai_drive.py`, `docs/sync_exchange.md`.
**[CIBLE DÉCIDÉE]** La séquence bidirectionnelle est :
```text
publication Android préalable
-> pull Android
-> validation de génération
-> merge/import desktop
-> midpoint Drive IA, état séparé
-> capture cohérente de l'état convergé
-> publication vers Android
-> consommation durable Android
-> acknowledgement corrélé
```
Un échange des fichiers MTP déjà présents n'est pas une synchronisation de
l'état courant si Android n'a pas publié après ses dernières mutations. Un run
Web doit donc pouvoir rester `waiting_android_publication`; il ne promet jamais
des données fraîches sur la seule présence du téléphone.
## 2. Matrice canonique courant/cible
`A` signifie Android, `D` desktop, `IA` pipeline Drive. `C/M/S` décrivent
création, modification et suppression. Une absence actuelle reste une absence,
pas une fonctionnalité implicite.
| Domaine et champs | Producteur / consommateur | Autorité | C/M/S et conflits | Format actuel démontré | Évolution cible et limite |
|---|---|---|---|---|---|
| Séance réalisée : `session_id`, début, type, occurrences, faits, plans, snapshots | A↔D | identité créatrice ; merge par politiques V3 | C idempotente ; corrections V3 bornées ; S absente | mobile V3 | V4 transporte lifecycle, causalité et tombstone |
| `ended_at` | A et D / A et D | valeur établie par le client qui finalise | `null→date` admis ; deux dates établies divergent explicitement | stocké localement, absent V3 | nullable, jamais fabriqué ni reconstruit sans source |
| note de séance | client éditeur / A et D | objet séance | bornée ; absent≠vide ; conflit causal | colonne locale, absente V3 | suit `session_id` |
| Occurrence : `entry_id`, ordre, exercice, équipement, targets, modes, faits, MAX | A↔D | identité créatrice ; profils immuables pour l'historique | corrections V3 bornées ; S suit séance | mobile V3 + companions | V4 conserve `entry_id`, ajoute causalité/suppression |
| note d'occurrence | client éditeur / A et D | objet occurrence | bornée ; conflit causal | colonne locale, absente V3 | suit `(session_id, entry_id)` |
| Activité continue | A↔D | occurrence | idempotence par artifact | mobile V3 | inchangé, inclus dans génération |
| Brouillon actif d'exécution | A puis futurs A↔D/Web | identité créatrice | un seul actif A ; aucun remplacement silencieux | tables Android `active_session_draft`, `draft_*`, exclues V3 | artifact lifecycle dédié ; synchronisable |
| Proposition IA | IA→D→A | proposition IA immuable | import idempotent, archive explicite | `trainlog-ai-session-drafts` | reste distincte d'un brouillon actif |
| Programme | futur | non défini | non implémenté | aucun modèle actif | dépendance future, hors lot |
| Template | futur | non défini | non implémenté | aucun modèle actif | distinct d'un programme et d'une séance préparée |
| Séance préparée | futurs D/Web→A | créateur puis lifecycle | non implémenté | proposition IA seulement | modèle futur distinct, peut devenir brouillon explicitement |
| Exercice catalogue canonique | A↔D selon identités/politiques existantes | identité stable ; métadonnées conciliées | rename sûr ; profils référencés protégés | mobile V3/catalogue/aliases | tombstone pour exercice utilisateur ; canonique non supprimable arbitrairement |
| Exercice utilisateur | A↔D | créateur | C/M causales ; S tombstone | pas de tombstone distribué | tombstone obligatoire |
| Alias d'exercice | A↔D | mapping historique canonique | union/flattening ; suppression interdite | companion aliases | conservation nécessaire à l'interprétation historique |
| Équipement canonique | D→A | catalogue canonique | non supprimable dans ce contrat | catalogue/definitions | génération, pas de tombstone arbitraire |
| Équipement personnalisé | A↔D | identité créatrice | conflit d'identité explicite ; S tombstone | definitions V1, pas de tombstone | tombstone causal |
| Association occurrence-équipement | A↔D | occurrence | `set/cleared` explicite | associations V2 | état explicite conservé dans génération |
| Relations BODY ZONES | A↔D | relation `(exercise_id, zone, role)` | trois voies ; S par état explicite/tombstone | companion V1 | opération causale explicite |
| Profils causaux | A↔D | politiques existantes | révisions causales | companion dédié | inclus dans génération sans réinterprétation |
| Observation corporelle : mesures, `observation_id` | A↔D | identité créatrice | C idempotente ; M/S non distribuées | mobile V3 | révision causale + tombstone |
| note d'observation | client éditeur / A et D | objet observation | bornée ; absent≠vide | colonne locale, absente V3 | suit `observation_id` |
| lien observation→séance | client éditeur / A et D | relation métier stable | parent manquant différé/rejet explicite | `session_row_id` local, absent V3 | transporte uniquement `session_id` |
| Feedback immédiat | A↔D | `(session_id, entry_id, exercise_id)` exact | révisions immuables, union stricte | feedback V2 | génération conserve identités/révisions |
| Feedback J+1 texte | A↔D | `session_id` exact | révisions immuables | feedback V2 | comportement actuel conservé |
| Feedback J+1 structuré | futur A↔D | séance par défaut | zones, intensité, texte ; exercice seulement explicite | absent | nouveau stockage et format nécessaires |
| Cardio physiologique | aucun | aucune | non implémenté | aucune source active | aucun contrat inventé |
| Langue, layout Web, fenêtre, état visuel, caches, champs de formulaire invalides | client local | appareil/interface | jamais fusionnés | locaux | restent locaux |
| Requête/receipt/run | A↔D | run desktop, requête Android | receipt corrélé au `request_id`, pas à une consommation complète | request/receipt V1 + historique | run/génération/ack distincts |
| Midpoint Drive IA entrant | IA↔D | flux IA | best effort observé séparément | scripts Drive | état dédié, erreur visible sans annuler le merge principal |
| Export Drive post-sync | D→Drive | desktop | best effort séparé | script post-sync | état dédié, jamais confondu avec succès principal |
## 3. États d'un run
**[CONTRAT FUTUR — NON IMPLÉMENTÉ]** Un run possède `run_id`, déclencheur,
direction, pair attendu, capacités négociées et les états monotones suivants :
```text
requested
waiting_android_publication | running
desktop_import_validated
published
waiting_acknowledgement
peer_consumed
completed
failed
```
`published` signifie seulement que le commit marker est visible.
`peer_consumed` exige un ack valide après consommation durable. `completed`
signifie que les phases obligatoires du run ont réussi ; les flux IA portent en
parallèle `not_requested|pending|succeeded|failed` et leur diagnostic. Un flux
best effort échoué n'est ni caché ni converti en échec du merge principal.
Une expiration en `waiting_acknowledgement` ne prouve pas l'annulation de
l'import pair. Un ack valide observé plus tard ferme idempotemment l'attente.
## 4. Brouillon et lifecycle
**[CIBLE DÉCIDÉE]** Ces concepts ne sont jamais fusionnés :
```text
programme != template != séance préparée != brouillon actif d'exécution
!= proposition IA != séance réalisée
```
`ai_session_drafts` ne devient pas un modèle générique. Une conversion entre
concepts est une transition explicite qui crée/conserve les identités prévues.
### 4.1 Inventaire Android v17
Sources : `TrainlogRepository.createActiveDraftTables()`, migrations v8-v17 et
`finalizeActiveSessionDraft()`.
| Table/champ | Classe cible | Justification |
|---|---|---|
| `active_session_draft.id=1` | local | clé technique singleton, jamais identité réseau |
| futur `session_id` stable | métier synchronisable | créé au brouillon, conservé à la finalisation |
| `session_type`, `source_session_id`, `updated_at` | métier synchronisable | lifecycle/provenance causale |
| `selected_exercise_row_id`, `selected_exercise_label`, `selected_equipment_id` | préférence/état UI local | sélecteur en cours, pas fait validé |
| `weight_text`, `max_weight_text`, `set_count_text`, `reps_text`, `duration_text`, `speed_text`, `distance_text` | saisie transitoire non validée | chaîne brute potentiellement invalide, reprise locale seulement |
| `draft_session_exercises.entry_id`, position, exercice/équipement, profils, targets, repos | métier synchronisable | occurrence validée et ordonnée |
| `draft_performed_sets` | métier synchronisable si série confirmée | faits ; une ligne de formulaire non confirmée reste locale |
| `draft_continuous_activity`, `draft_max_results` | métier synchronisable si confirmé | faits explicites, jamais fausses séries |
| `draft_exercise_feedback` et révisions | métier synchronisable | feedback rattaché à l'occurrence |
**[CIBLE DÉCIDÉE]** « Reprise exacte » signifie restaurer tout état métier
validé : type, ordre, occurrences, identités, équipement, targets, snapshots de
modes, faits confirmés, MAX et feedback. Elle ne promet pas de déplacer entre
appareils le focus, la sélection, un cache, une chaîne invalide ni une série non
confirmée. Ces champs locaux peuvent être perdus lors d'une reprise sur un autre
client ; cette limite doit être annoncée.
Le `session_id` est créé une seule fois à la création du brouillon et chaque
`entry_id` à la création de l'occurrence. La transition `draft -> completed`
utilise les mêmes identités dans une transaction locale. Son rejeu reconnaît la
séance déjà finalisée et ne crée ni seconde séance ni second brouillon.
### 4.2 Concurrence de lifecycle
- deux éditions du même brouillon utilisent révisions causales ; des branches
concurrentes incompatibles deviennent un conflit, jamais du LWW général ;
- démarrage Android pendant édition Web : une seule branche peut acquérir
l'état `started`; l'autre doit se rebaser ou demander une décision ;
- une finalisation domine une édition ancienne du brouillon, qui est rejetée
comme lifecycle obsolète et ne recrée pas de brouillon ;
- suppression et finalisation concurrentes sont un conflit explicite ;
- reprendre un snapshot antérieur ne recule pas la causalité ;
- si le singleton Android est occupé, un brouillon entrant est conservé comme
candidat en attente ; aucun remplacement silencieux ni fusion automatique.
## 5. Champs actuellement perdus
### 5.1 `ended_at`
**[ACTUEL DÉMONTRÉ]** Android et desktop stockent `ended_at`, mais mobile V3 ne
le transporte pas ; l'import desktop produit `NULL`. Le test
`sync_gap_characterization` le verrouille.
**[CIBLE DÉCIDÉE]** `ended_at` est nullable et synchronisable. `null -> date`
est l'établissement normal. `date A != date B` entre deux valeurs établies est
une divergence explicite nécessitant preuve causale ou décision ; aucune règle
`max(date)`. L'import ne fait jamais `now()` et ne déduit jamais la valeur de la
dernière série. Une absence historique ne signifie pas « encore en cours ».
Les dates déjà perdues ne sont pas reconstruites sans source vérifiable.
### 5.2 Notes
**[CIBLE DÉCIDÉE]** Notes de séance, occurrence et observation sont trois
champs distincts attachés à leur identité stable. Taille UTF-8 future : au plus
4096 octets chacun après validation UTF-8 ; `null` signifie jamais renseigné,
`""` effacement explicite. Une édition crée une révision causale ; deux éditions
concurrentes non identiques sont un conflit. Les notes de filtre, focus,
placeholder ou cache UI restent locales.
### 5.3 Observation corporelle → séance
Le wire ne transporte jamais `session_row_id`. La relation métier transporte
`session_id`. Si parent et enfant sont dans la même génération, le staging
résout après validation complète. Parent temporairement absent : relation en
attente bornée, jamais attachée par heuristique. Parent tombstoné : enfant
conservé selon sa politique, lien rejeté/retiré explicitement. Parent inconnu
après consommation complète : artifact invalide ou conflit rapporté.
## 6. Suppressions et non-résurrection
| Domaine | Politique cible | Motif |
|---|---|---|
| séances réalisées | tombstone | suppression possible, identité historique stable |
| brouillons | état lifecycle `deleted` avec fait causal | distingue abandon, finalisation et absence |
| exercices utilisateur | tombstone | empêche recréation par ancien snapshot |
| observations corporelles | tombstone | objet utilisateur supprimable |
| équipements personnalisés | tombstone | définitions référencées historiquement |
| feedback | état/révision de retrait, contenu historique conservé selon politique | révisions immuables |
| aliases | suppression interdite ; flattening canonique | interprétation des anciennes identités |
| relations BODY ZONES | opération d'état/tombstone ciblée | relation synchronisable à trois voies |
Un tombstone contient type, identité stable, opération causale, identité du
créateur et génération d'émission. Un timestamp est informatif, jamais arbitre
unique. Supprimer un parent traite explicitement les enfants : la séance
supprime logiquement ses occurrences ; supprimer un exercice utilisateur ne
détruit pas les faits historiques qui conservent leur snapshot ; supprimer une
observation traite son lien sans supprimer la séance.
Les tombstones sont conservés tant qu'un pair enregistré peut présenter un
snapshot antérieur. La collecte requiert un horizon causal prouvé par ack de
tous les pairs concernés ou une révocation explicite du pair ; elle n'est pas
basée sur l'âge seul. Un pair longtemps hors ligne doit consommer l'historique
causal ou effectuer une réinitialisation sûre. La réintroduction volontaire
crée une nouvelle identité ; réutiliser une identité tombstonée est une
résurrection accidentelle rejetée.
## 7. Conflits
Résolutions automatiques permises : rejeu byte-identique/idempotent ; union de
révisions immuables identiques ; application d'une opération causale qui domine
clairement l'état connu ; canonicalisation d'alias selon les règles gelées ;
correction V3 déjà démontrée dans ses limites.
Conflits explicites : même identité avec attribut immuable divergent ; éditions
concurrentes ; delete/update ; finalize/update ; génération obsolète qui tente
une résurrection ; causalité inconnue ou contradictoire ; parent supprimé ;
artifact corrompu, incomplet ou version future. Aucun last-write-wins général et
aucun écran de résolution ne sont définis dans ce lot.
## 8. Génération et manifest
**[CONTRAT FUTUR — NON IMPLÉMENTÉ]** Toute publication multi-artifact possède un
`generation_id` opaque unique (UUIDv4 préfixé `gen_` recommandé), jamais un
timestamp seul. Le manifest indépendant est conceptuellement :
```json
{
"format": "trainlog-sync-manifest",
"version": 1,
"generation_id": "gen_<uuid-v4>",
"producer": {"peer_id": "peer_<uuid-v4>", "kind": "android"},
"generated_at": "offset-date-time",
"artifacts": [{
"logical_name": "mobile-history",
"format": "trainlog-mobile-export",
"version": 4,
"filename": "generations/<generation_id>/mobile-v4.json",
"size": 123,
"sha256": "64 lowercase hex",
"required": true
}]
}
```
Limites : 32 artifacts ; manifest 64 KiB ; artifact 64 MiB et génération
256 MiB par défaut, sous admission du Resource Governor ; noms logiques 64
octets ASCII ; filename relatif UTF-8 ≤ 240 octets, segments non vides, aucune
racine, `..`, NUL, backslash ou symlink suivi ; profondeur ≤ 3 ; formats et
versions sur liste négociée ; identités selon préfixe + UUIDv4. Les limites sont
opérationnelles configurables, pas une limite scientifique globale de dataset.
SHA-256 porte sur les bytes immuables exacts. Le manifest peut donc relier sans
modifier les companions gelés. Le digest prouve intégrité et appartenance
déclarée ; il ne prouve pas que les fichiers furent capturés au même instant.
Chaque producteur doit capturer tous les artifacts depuis un même snapshot
logique de base (transaction de lecture SQLite ou snapshot équivalent), puis
les sérialiser déterministement.
## 9. Capture, publication, consommation et reprise
```text
capturer et valider G
-> publier les artifacts immuables de G
-> publier manifest/commit marker en dernier
-> consommer uniquement G complète, vérifiée et compatible
```
Le transport n'a besoin d'aucun renommage atomique global. Le consommateur
ignore les répertoires sans manifest final. La génération courante est la plus
récente génération **valide et causalement admissible explicitement publiée**,
pas le fichier au mtime maximal. Une nouvelle génération invalide est signalée
et bloque l'avancement ; aucun repli silencieux vers l'ancienne ne masque le
problème. La dernière génération valide est retenue avec la nouvelle jusqu'à
ack. Rétention minimale : dernière ackée + génération publiée en attente + une
précédente valide ; nettoyage seulement après absence de lecteur et preuves
d'ack, avec quota borné et diagnostic si reporté.
**[CIBLE DÉCIDÉE]** La consommation utilise staging validé puis une transaction
SQLite commune par pair et génération lorsque tous les domaines touchent la
même base. Les artifacts sont validés et transformés en staging borné avant la
transaction ; la transaction applique domaines, causalité et journal de
consommation, puis commit. Les effets externes restent séparés et idempotents.
Le manifest ne rend donc pas, à lui seul, les imports SQL atomiques.
Reprise : crash avant manifest → génération invisible/nettoyable ; après
manifest avant consommation → rejeu complet ; entre validations → aucun commit ;
pendant transaction → rollback SQLite ; après commit avant ack → journal local
reconnaît G et réémet l'ack ; pendant nettoyage → marqueurs et ordre de
rétention rendent l'opération idempotente.
## 10. Acknowledgement et rapport
L'ack versionné contient `run_id`, `generation_id`, identités producteur et
consommateur, digest du manifest, état `consumed|rejected`, garantie de
durabilité, timestamp et diagnostic borné. `consumed` n'est émis qu'après commit
et journal durable. Même génération + même manifest réémet le même résultat ;
même ID + digest différent est un conflit. Ack perdu, rejeu et observation
ultérieure sont idempotents. Un timeout laisse `waiting_acknowledgement`.
Le rapport futur sépare phases et domaines et expose : run/génération/pairs,
déclencheur, direction, timestamps, résultat, états IA, diagnostics et compteurs
`imported`, `exported`, `ignored`, `reconciled`, `deleted`, `rejected`,
`conflicts`. Une valeur non calculée est `null`/indisponible, jamais zéro.
`sessions_reconciled`, aujourd'hui produit par l'importeur mais perdu par
`TrainlogSyncReport`, doit être préservé.
## 11. Formats et compatibilité
`TRAINLOG_FORMAT_V1`, mobile V3 et companions publiés restent inchangés.
Architecture cible proportionnée : mobile V4 pour histoire complétée et champs
perdus ; artifact dédié brouillon/lifecycle ; companions spécialisés conservés ;
manifest global indépendant. Aucun monolithe universel.
Chaque pair publie `peer_id`, versions de formats, features causales, limite de
taille et niveau d'ack. Nouveau desktop/ancien Android : mode V3 explicitement
`degraded`, sans drafts, `ended_at`, notes, liens ni suppressions ; il ne peut
pas annoncer « sync complète ». Ancien desktop/nouvel Android : Android peut
émettre V3 compatible seulement si cela ne contourne aucun tombstone/révision ;
sinon refus clair. Une version future inconnue est rejetée. Une génération
nouvelle invalide n'est pas masquée. Après activation causale/tombstones, un
snapshot legacy susceptible de ressusciter est refusé ou admis uniquement via
une porte de migration prouvant sa base causale.
Portes : environnement/test ; lecture seule des capacités ; double-écriture
contrôlée sans autorité V4 ; round-trip validé ; activation par pair ; ack de
migration ; interdiction du downgrade destructeur. Aucune donnée réelle n'est
migrée dans ce lot.
## 12. Façade application et HTTP futurs
**[CONTRAT FUTUR — NON IMPLÉMENTÉ]** Une façade réutilise moteur et verrou :
```text
start_sync_job(trigger, direction) -> accepted(job_id) | already_active
get_sync_job(job_id) -> structured state
get_latest_sync_result() -> structured immutable result
```
Le serveur ne bloque pas sa boucle HTTP. L'exécution proposée est un processus
connu, argv fixe, sans shell, environnement/XDG contrôlé, stdout/stderr bornés,
timeout et arrêt du groupe de processus maîtrisés, admission Resource Governor
et verrou inter-processus existant. Une connexion SQLite indépendante ne
résout pas la contention avec les snapshots Web : lectures cohérentes,
busy-timeout borné et invalidation après commit sont requis.
API envisagée seulement : `POST /api/v1/sync` → `202` et
`GET /api/v1/sync/status`. Elle valide méthode, `Host`, `Origin`, CSRF, type et
taille du corps ; refuse un second job (`409`) ; borne réponses et fréquence.
États utilisateur : attente de publication Android, appareil absent, opération
active, import desktop validé, publication faite, attente d'ack, consommation
confirmée, terminé dégradé, échec. Aucun faux compteur. Le bouton ne dit jamais
« synchronisation complète » pour un simple upload, traitement Drive ou rejeu
de fichiers Android anciens.
## 13. Tests obligatoires des lots futurs
- round-trip complet de chaque domaine et champ, absent/vide/bornes UTF-8 ;
- drafts A↔D, singleton occupé, reprise exacte, finalisation idempotente et
conservation `session_id`/`entry_id` ;
- transitions prepared/draft/completed/deleted et branches concurrentes ;
- update/update, delete/update, finalize/update, parents/enfants ;
- tombstones, ancien snapshot, pair longtemps déconnecté, collecte sûre et
réintroduction volontaire ;
- générations mélangées, incomplètes, digest/taille/chemin invalides ;
- versions anciennes/futures, négociation, downgrade refusé ;
- crash à chaque frontière publication/consommation/commit/nettoyage ;
- ack perdu/tardif/rejoué, double import, même ID/digest divergent ;
- contention Web/TUI/daemon et lock ; sorties/processus/temps bornés ;
- flux IA midpoint et post-sync en succès/échec indépendants ;
- rapports : compteurs présents, `null` si indisponibles, réconciliations
conservées.
## 14. Ordre d'implémentation futur
1. `TRAINLOG_SYNC_TEST_ENV_V1` : environnement Android/JDK/quota et harness
isolé reproductible ;
2. `TRAINLOG_SYNC_CHARACTERIZATION_V1` : compléter les preuves actuelles sur
toutes plateformes ;
3. `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` : mobile V4, drafts et champs perdus ;
4. `TRAINLOG_SYNC_CAUSAL_DELETE_V1` : opérations causales et tombstones ;
5. `TRAINLOG_SYNC_GENERATION_ACK_V1` : manifest, staging, publication,
consommation, reprise et ack ;
6. `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1` : états et rapport complet ;
7. `TRAINLOG_WEB_SYNC_API_V1` : façade et endpoints ;
8. `TRAINLOG_WEB_SYNC_BUTTON_V1` : UX et rafraîchissement.
Chaque lot dépend des précédents et reste borné. Aucun n'est commencé par ce
contrat.
## 15. Statut des preuves
**[ACTUEL DÉMONTRÉ]** Desktop : test dédié des pertes V3, résurrection et
transaction par artifact ; suites existantes pour V3 strict, aliases,
équipements, BODY ZONES, profils, feedback, MAX et idempotence.
**[ACTUEL DÉMONTRÉ]** Android : le test existant
`TrainlogRepositoryDraftTest.finalizeIsAtomicAndDraftNeverExportsBeforeCompletion`
prouve l'exclusion du brouillon, la finalisation transactionnelle et
l'apparition de la séance finalisée dans l'export.
**[ACTUEL DÉMONTRÉ]** La validation de ce lot a exécuté les 202 tests Android
(198 réussis, 4 ignorés, 0 échec) et `assembleDebug` avec le JDK 17 documenté.
Le quota du tmpfs `/tmp` a nécessité un `java.io.tmpdir` ticket-spécifique sous
le home ; Robolectric est resté actif. Ce contournement d'exécution ne constitue
pas encore le harness reproductible du futur lot `TRAINLOG_SYNC_TEST_ENV_V1`.
## 16. Sources propriétaires
- état/roadmap : `docs/current_state.md`, `docs/roadmap.md` ;
- architecture/run : `docs/architecture.md`, `docs/sync_exchange.md` ;
- formats : `docs/exchange_format.md`, `tools/import_mobile_export.py`,
`tools/export_pc_mobile.py` ;
- schémas : `docs/database.md`, `tui/src/database.c`,
`android/.../TrainlogRepository.kt` ;
- feedback : `docs/training_feedback.md` ;
- tests : `docs/tests.md`, `tests/test_session_exchange_v3.py`,
`tests/test_sync_gap_characterization.py`, tests Android du repository.

View file

@ -32,9 +32,16 @@ tile rendering exist. Advanced visualizations remain a future slice.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_NEXT_MODULE_SELECTION_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_TEST_ENV_V1
```
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
defines the complete-sync target and its bounded dependency order. Its first
implementation cursor is only `TRAINLOG_SYNC_TEST_ENV_V1`: make the isolated
desktop/Android validation environment reproducible, including the documented
JDK and resource gates. It does not authorize a protocol, schema, endpoint or
button change. [Contract details](design/sync_gap_contract_v1.md).
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
system, permanent Header/Footer, empty-data states and health status. The next
@ -96,7 +103,7 @@ The slice order may be refined by the next contract, but it must preserve the
existing Core ownership, keep visual layout outside business SQLite and frozen
formats, and specify Activité/Progression before implementing those metrics.
`WEB_NEXT_MODULE_SELECTION_V1` is the next documentary cursor. The current
`WEB_NEXT_MODULE_SELECTION_V1` remains a future Web documentary cursor. The current
roadmap does not yet establish a contract order between Analyse, Programmes,
Séances and Exercices. This cursor must inventory their existing Core services,
read/write boundaries and dependencies, then select one bounded module contract;
@ -104,6 +111,13 @@ it does not authorize silently implementing a page.
## Next
### Complete synchronization implementation
The frozen contract orders future bounded lots as environment/test,
characterization, data/lifecycles, causal deletion, generation/consumption/ack,
orchestrator/report, Web API, then button/refresh. No implementation lot is
started. `WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged.
### Gym catalog V1
Build an inventory from the equipment actually available in the user's gym.

View file

@ -720,6 +720,11 @@ between passes and the three explicit unclassified states remained intact.
## 18. Audited protocol limitations
The future complete-sync semantics are frozen separately in
[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). That document
is a non-implemented specification and does not extend any format described
here.
Each mutating importer validates strictly and owns a SQLite transaction. The
V2 association companion only corroborates equipment already imported in the
mobile snapshot and refuses divergent state. A complete

View file

@ -598,6 +598,16 @@ reimport and rejection of a missing custom definition.
## 8. Bidirectional synchronization validation
`sync_gap_characterization` uses the production mobile V3 exporter/importer and
temporary SQLite databases to prove the current gaps without blessing them: V3
omits `ended_at`, three distinct note owners and the body-observation session
link; the imported values are `NULL`; deleting a local session and replaying an
old snapshot resurrects it; an invalid artifact rolls back while an earlier
artifact remains independently committed. The existing Android test
`TrainlogRepositoryDraftTest.finalizeIsAtomicAndDraftNeverExportsBeforeCompletion`
proves that the active draft is absent before finalization and the completed
session appears afterward. These tests do not implement the future contract.
Validated workflow:
```text

View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Characterize current V3 gaps without defining future wire behavior."""
import copy
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools/import_mobile_export.py"
EXPORTER = ROOT / "tools/export_pc_mobile.py"
sys.path.insert(0, str(ROOT / "tests"))
from test_session_exchange_v3 import V11_SCHEMA, payload # noqa: E402
def run(command, *, succeeds=True):
result = subprocess.run(command, text=True, capture_output=True, check=False)
if (result.returncode == 0) != succeeds:
raise AssertionError(result.stdout + result.stderr)
return result
def create_database(path):
with sqlite3.connect(path) as connection:
connection.executescript(V11_SCHEMA)
def import_document(path, database, document, name="mobile-v3.json", *, succeeds=True):
artifact = path / name
artifact.write_text(json.dumps(document), encoding="utf-8")
return run(
[sys.executable, str(IMPORTER), str(artifact), "--database", str(database)],
succeeds=succeeds,
)
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-sync-gap-") as directory:
root = Path(directory)
source = root / "source.sqlite"
create_database(source)
current = payload()
import_document(root, source, current)
# WHY: these persisted values are real business data, but V3 has no
# fields for them. This test records loss at the production boundary;
# it does not approve that loss or extend the frozen wire format.
with sqlite3.connect(source) as connection:
session_row = connection.execute(
"SELECT id FROM sessions WHERE session_id='se_v3'"
).fetchone()[0]
connection.execute(
"UPDATE sessions SET ended_at=?, notes=? WHERE id=?",
("2026-09-09T12:59:00+02:00", "session note", session_row),
)
connection.execute(
"UPDATE session_exercises SET notes='occurrence note' "
"WHERE entry_id='sxe_reps'"
)
connection.execute(
"UPDATE body_observations SET session_row_id=?, notes='body note' "
"WHERE observation_id='bo_v3'",
(session_row,),
)
exported = root / "pc-v3.json"
run([sys.executable, str(EXPORTER), str(exported), "--database", str(source)])
wire = json.loads(exported.read_text(encoding="utf-8"))
session = next(item for item in wire["sessions"] if item["session_id"] == "se_v3")
occurrence = next(item for item in session["exercises"] if item["entry_id"] == "sxe_reps")
observation = next(
item for item in wire["body_observations"] if item["observation_id"] == "bo_v3"
)
assert "ended_at" not in session
assert "notes" not in session
assert "notes" not in occurrence
assert "notes" not in observation
assert "session_id" not in observation and "session_row_id" not in observation
destination = root / "destination.sqlite"
create_database(destination)
run([sys.executable, str(IMPORTER), str(exported), "--database", str(destination)])
with sqlite3.connect(destination) as connection:
connection.execute("PRAGMA foreign_keys=ON")
assert connection.execute(
"SELECT ended_at, notes FROM sessions WHERE session_id='se_v3'"
).fetchone() == (None, None)
assert connection.execute(
"SELECT notes FROM session_exercises WHERE entry_id='sxe_reps'"
).fetchone()[0] is None
assert connection.execute(
"SELECT session_row_id, notes FROM body_observations "
"WHERE observation_id='bo_v3'"
).fetchone() == (None, None)
# CONTRACT: current snapshots have no session tombstone. Local
# deletion followed by replay therefore resurrects the identity.
connection.execute("DELETE FROM sessions WHERE session_id='se_v3'")
replay = run(
[sys.executable, str(IMPORTER), str(exported), "--database", str(destination)]
)
assert "sessions_imported=1" in replay.stdout
with sqlite3.connect(destination) as connection:
assert connection.execute(
"SELECT count(*) FROM sessions WHERE session_id='se_v3'"
).fetchone()[0] == 1
# INVARIANT: validation/transactionality is per artifact. A malformed
# V3 artifact leaves none of its otherwise-valid definitions or rows,
# while the earlier valid artifact remains committed independently.
before = None
with sqlite3.connect(destination) as connection:
before = tuple(
connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
for table in ("exercises", "sessions", "session_exercises", "body_observations")
)
malformed = copy.deepcopy(current)
malformed["exercises"].append(
{
"exercise_id": "ex_should_rollback",
"name": "Rollback sentinel",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
}
)
malformed["sessions"][0]["exercises"][0]["target"]["sets"] = 0
import_document(root, destination, malformed, "malformed-v3.json", succeeds=False)
with sqlite3.connect(destination) as connection:
after = tuple(
connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
for table in ("exercises", "sessions", "session_exercises", "body_observations")
)
assert after == before
assert connection.execute(
"SELECT count(*) FROM exercises WHERE exercise_id='ex_should_rollback'"
).fetchone()[0] == 0
print("PASS sync_gap_characterization")
if __name__ == "__main__":
main()

View file

@ -702,6 +702,12 @@ test(
args: [meson.project_source_root() / 'tests/test_session_exchange_v3.py'],
)
test(
'sync_gap_characterization',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_sync_gap_characterization.py'],
)
test(
'equipment_associations_exchange',
python3_trainlog_tests,