docs(sync): freeze complete synchronization gap contract
This commit is contained in:
parent
c04fa76f0b
commit
32877f11e9
9 changed files with 657 additions and 9 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
441
docs/design/sync_gap_contract_v1.md
Normal file
441
docs/design/sync_gap_contract_v1.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
tests/test_sync_gap_characterization.py
Normal file
147
tests/test_sync_gap_characterization.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue