feat(sync): add generation acknowledgements

This commit is contained in:
fy59 2026-09-17 12:29:14 +02:00
parent 444508b8b9
commit a6fe9be296
49 changed files with 1954 additions and 405 deletions

View file

@ -166,7 +166,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name
heuristics.
Android local SQLite schema v18 retains the exactly-one durable active-session
Android local SQLite schema v20 retains the exactly-one durable active-session
draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit
@ -180,7 +180,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v19 and Android migrations reach v18 without changing
desktop migrations reach v21 and Android migrations reach v20 without changing
that identity contract. The active
completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -237,11 +237,12 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version;
```
The current desktop schema is v19. Schema v9 added an occurrence-owned explicit
The current desktop schema is v21. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state. None changes `TRAINLOG_FORMAT_V1`.
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and
regression coverage.

View file

@ -2,6 +2,15 @@
## Unreleased — 0.1.2
- Completed `TRAINLOG_SYNC_GENERATION_ACK_V1` with desktop schema v21 and
Android schema v20. Added coherent per-platform capture, strict bounded
manifest V1, immutable manifest-last publication, whole-generation SQLite
consumption, pair-scoped lineage, durable consumed/rejected ACK V1 replay,
and a separate causal-operation publication ledger. Real isolated Android →
desktop and desktop → Android chains use production entry points and retain
operation identity across generations. Automatic V3/MTP selection and all
operational services remain unchanged.
- Completed the residual causal-deletion evidence with real Android and
desktop producers for exercise, body-observation, custom-equipment, feedback
and BODY ZONE relation targets, including production replica exchange,

View file

@ -308,7 +308,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-17:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v20 and Android SQLite schema v19;
- desktop SQLite schema v21 and Android SQLite schema v20;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
@ -322,9 +322,9 @@ As of 2026-09-17:
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN`; the other four Web routes remain placeholders;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN /
IMPLEMENTATION_NOT_STARTED` and the next cursor remains
`TRAINLOG_SYNC_CHARACTERIZATION_V1`.
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The next cursor is
`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`.
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.

View file

@ -0,0 +1,357 @@
package com.labfytools.trainlog.data
import android.content.ContentValues
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.util.UUID
internal class SyncGenerationException(message: String) : IllegalArgumentException(message)
internal data class CapturedSyncGeneration(
val generationId: String,
val runId: String,
val manifestSha256: String,
val stagingDirectory: File,
)
/**
* Staged generation service. It is deliberately not wired into the active V3
* transport. WHY: manifest publication and peer consumption need durable,
* independently retryable identities. CONTRACT: artifacts are immutable,
* bounded and published before the manifest marker. INVARIANT: causal
* operation payloads are never rewritten to attach generation identity.
*/
internal class SyncGenerationService(private val repository: TrainlogRepository) {
companion object {
const val MAX_MANIFEST_BYTES = 64 * 1024
const val MAX_ARTIFACTS = 32
const val MAX_ARTIFACT_BYTES = 64L * 1024L * 1024L
const val MAX_GENERATION_BYTES = 256L * 1024L * 1024L
const val MAX_LOGICAL_NAME_BYTES = 64
const val MAX_PATH_BYTES = 240
const val MAX_DIAGNOSTIC_BYTES = 1024
const val MAX_RETAINED_PER_PEER = 8
private val ID = Regex("^(gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
private val DIGEST = Regex("^[0-9a-f]{64}$")
private data class Kind(val format: String, val version: Int, val filename: String, val required: Boolean = true)
private val ANDROID_KINDS = linkedMapOf(
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
"history" to Kind("trainlog-mobile-export", 4, "history-v4.json"),
"execution-drafts" to Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
"exercise-aliases" to Kind("trainlog-exercise-aliases", 1, "exercise-aliases-v1.json"),
"exercise-profile-state" to Kind("trainlog-exercise-profile-state", 1, "exercise-profile-state-v1.json"),
"equipment-definitions" to Kind("trainlog-equipment-definitions", 1, "equipment-definitions-v1.json"),
"equipment-associations" to Kind("trainlog-equipment-associations", 2, "equipment-associations-v2.json"),
"body-zones" to Kind("trainlog-exercise-body-zones", 1, "body-zones-v1.json"),
"feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"),
"causal-deletions" to Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
)
private val SUPPORTED = (ANDROID_KINDS.values + Kind("trainlog-ai-session-drafts",1,"ai-proposals-v1.json",false))
.map { it.format to it.version }.toSet()
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
.digest(bytes).joinToString("") { "%02x".format(it) }
private fun canonical(value: Any?): String = when (value) {
null, JSONObject.NULL -> "null"
is JSONObject -> value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
JSONObject.quote(it) + ":" + canonical(value.get(it))
}
is JSONArray -> (0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
is String -> JSONObject.quote(value)
is Boolean, is Int, is Long -> value.toString()
else -> throw SyncGenerationException("unsupported manifest JSON value")
}
private fun requireId(value: String, prefix: String) {
if (!value.startsWith("${prefix}_") || !ID.matches(value))
throw SyncGenerationException("invalid $prefix identity")
}
private fun safePath(value: String, generationId: String) {
if (value.toByteArray(StandardCharsets.UTF_8).size > MAX_PATH_BYTES || '\u0000' in value || '\\' in value || value.startsWith('/'))
throw SyncGenerationException("unsafe artifact path")
val parts = value.split('/')
if (parts.size != 3 || parts.any { it.isEmpty() || it == "." || it == ".." } || parts[0] != "generations" || parts[1] != generationId)
throw SyncGenerationException("artifact outside generation namespace")
}
private fun boundedJson(root: Any) {
val stack=java.util.ArrayDeque<Pair<Any,Int>>();stack.add(root to 1);var nodes=0
while(stack.isNotEmpty()){
val (value,depth)=stack.removeLast();nodes++
if(depth>8 || nodes>8192)throw SyncGenerationException("JSON nesting/node bound exceeded")
when(value){
is JSONObject -> value.keys().forEach{stack.add(value.get(it) to depth+1)}
is JSONArray -> for(index in 0 until value.length())stack.add(value.get(index) to depth+1)
}
}
}
}
private fun stablePeerId(kind: String): String = repository.inSyncGenerationTransaction { db ->
db.rawQuery("SELECT peer_id,kind FROM sync_peer_identity WHERE singleton=1", null).use { cursor ->
if (cursor.moveToFirst()) {
if (cursor.getString(1) != kind) throw SyncGenerationException("peer kind conflict")
return@inSyncGenerationTransaction cursor.getString(0)
}
}
val value = "peer_${UUID.randomUUID()}"
db.execSQL("INSERT INTO sync_peer_identity(singleton,peer_id,kind) VALUES(1,?,?)", arrayOf(value, kind))
value
}
fun capture(
ownedRoot: File,
consumerPeerId: String,
runId: String = "sy_${UUID.randomUUID()}",
generationId: String = "gen_${UUID.randomUUID()}",
): CapturedSyncGeneration {
requireId(consumerPeerId, "peer"); requireId(runId, "sy"); requireId(generationId, "gen")
repository.inSyncGenerationTransaction { db ->
val retained=db.rawQuery("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')",arrayOf(consumerPeerId)).use{it.moveToFirst();it.getInt(0)}
if(retained>=MAX_RETAINED_PER_PEER)throw SyncGenerationException("generation retention capacity exhausted")
}
val producer = stablePeerId("android")
val stage = File(File(ownedRoot, "staging"), generationId)
if (stage.exists() || !stage.mkdirs()) throw SyncGenerationException("generation staging already exists")
try {
val artifacts = repository.captureSyncGenerationArtifacts()
if (artifacts.keys != ANDROID_KINDS.keys) throw SyncGenerationException("incomplete Android domain capture")
val descriptors = JSONArray(); var total = 0L
artifacts.forEach { (logical, json) ->
val kind = checkNotNull(ANDROID_KINDS[logical]); val bytes = json.toByteArray(StandardCharsets.UTF_8)
if (bytes.size.toLong() > MAX_ARTIFACT_BYTES) throw SyncGenerationException("artifact exceeds 64 MiB")
total = Math.addExact(total, bytes.size.toLong())
if (total > MAX_GENERATION_BYTES) throw SyncGenerationException("generation exceeds 256 MiB")
val output = File(stage, kind.filename)
output.outputStream().use { it.write(bytes) }; output.setReadable(false, false); output.setReadable(true, true); output.setWritable(false, false); output.setWritable(true, true)
descriptors.put(JSONObject().put("logical_name", logical).put("format", kind.format)
.put("version", kind.version).put("filename", "generations/$generationId/${kind.filename}")
.put("size", bytes.size).put("sha256", sha256(bytes)).put("required", kind.required))
}
val generatedAt = OffsetDateTime.now().toString()
val parent = repository.inSyncGenerationTransaction { db ->
db.rawQuery("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", arrayOf(producer,consumerPeerId)).use {
if(!it.moveToFirst())null else {val value=it.getString(0);if(it.moveToNext())throw SyncGenerationException("outgoing generation lineage has multiple tips");value}
}
}
val manifest = JSONObject().put("format", "trainlog-sync-manifest").put("version", 1)
.put("generation_id", generationId).put("run_id", runId)
.put("producer", JSONObject().put("peer_id", producer).put("kind", "android"))
.put("consumer_peer_id", consumerPeerId).put("generated_at", generatedAt)
.put("parent_generation_id", parent ?: JSONObject.NULL).put("artifacts", descriptors)
val raw = canonical(manifest).toByteArray(StandardCharsets.UTF_8)
validateManifest(raw, consumerPeerId)
val digest = sha256(raw)
File(stage, "manifest.json").outputStream().use { it.write(raw) }
repository.inSyncGenerationTransaction { db ->
db.execSQL("INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL)", arrayOf(generationId,runId,producer,consumerPeerId,"android",generatedAt,parent,digest,"captured",raw.toString(StandardCharsets.UTF_8),stage.absolutePath))
for (index in 0 until descriptors.length()) {
val item=descriptors.getJSONObject(index)
db.execSQL("INSERT INTO sync_generation_artifacts VALUES(?,?,?,?,?,?,?,?)", arrayOf(generationId,item.getString("logical_name"),item.getString("format"),item.getInt("version"),item.getString("filename"),item.getLong("size"),item.getString("sha256"),if(item.getBoolean("required"))1 else 0))
}
val causal = JSONObject(checkNotNull(artifacts["causal-deletions"]))
val operations = causal.getJSONArray("operations")
for (index in 0 until operations.length()) {
val operationId=operations.getJSONObject(index).getString("operation_id")
val existed=db.rawQuery("SELECT 1 FROM sync_causal_publications WHERE operation_id=? LIMIT 1",arrayOf(operationId)).use{it.moveToFirst()}
db.execSQL("INSERT INTO sync_causal_publications VALUES(?,?,?)",arrayOf(operationId,generationId,if(existed)0 else 1))
}
}
return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) {
stage.deleteRecursively()
throw error
}
}
fun publish(captured: CapturedSyncGeneration, objectRoot: File): File {
val destination = File(File(objectRoot, "generations"), captured.generationId)
val sourceManifest = File(captured.stagingDirectory, "manifest.json").readBytes()
val manifest = validateManifest(sourceManifest, null)
val marker=File(destination,"manifest.json")
if (marker.isFile) {
if (sha256(marker.readBytes()) != captured.manifestSha256) throw SyncGenerationException("same generation has different published bytes")
validatePublished(destination,manifest.getString("consumer_peer_id"))
} else {
if(destination.exists() && (!destination.isDirectory || java.nio.file.Files.isSymbolicLink(destination.toPath())))throw SyncGenerationException("unsafe generation namespace")
if (!destination.exists() && !destination.mkdirs()) throw SyncGenerationException("cannot create generation namespace")
val values=manifest.getJSONArray("artifacts")
for(index in 0 until values.length()) {
val name=values.getJSONObject(index).getString("filename").substringAfterLast('/')
val item=values.getJSONObject(index);val source=File(captured.stagingDirectory,name)
if(!source.isFile || java.nio.file.Files.isSymbolicLink(source.toPath()) || source.canonicalFile.parentFile!=captured.stagingDirectory.canonicalFile || source.length()!=item.getLong("size") || sha256(source.readBytes())!=item.getString("sha256"))throw SyncGenerationException("staging artifact changed after capture")
val target=File(destination,name)
if(target.exists()){
if(java.nio.file.Files.isSymbolicLink(target.toPath()) || target.length()!=item.getLong("size") || sha256(target.readBytes())!=item.getString("sha256"))throw SyncGenerationException("conflicting partial publication")
}else source.inputStream().use { input -> target.outputStream().use { output -> input.copyTo(output, 1024*1024) } }
}
// The manifest is the visibility/commit marker and is always last.
marker.outputStream().use { it.write(sourceManifest) }
}
repository.inSyncGenerationTransaction { db -> db.execSQL("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=? AND status IN('captured','published','waiting_acknowledgement')", arrayOf(captured.generationId)) }
return destination
}
fun validateManifest(raw: ByteArray, expectedConsumer: String?): JSONObject {
if (raw.size > MAX_MANIFEST_BYTES || !repository.syncGenerationJsonHasUniqueKeys(raw.toString(StandardCharsets.UTF_8)))
throw SyncGenerationException("invalid or oversized manifest JSON")
val root=try { JSONObject(raw.toString(StandardCharsets.UTF_8)) } catch (_: Exception) { throw SyncGenerationException("invalid manifest JSON") }
boundedJson(root)
val keys=setOf("format","version","generation_id","run_id","producer","consumer_peer_id","generated_at","parent_generation_id","artifacts")
if(root.keys().asSequence().toSet()!=keys || root.optString("format")!="trainlog-sync-manifest" || root.opt("version") !is Int || root.getInt("version")!=1) throw SyncGenerationException("unsupported manifest shape/version")
val generation=root.getString("generation_id");requireId(generation,"gen");requireId(root.getString("run_id"),"sy")
val producer=root.getJSONObject("producer");if(producer.keys().asSequence().toSet()!=setOf("peer_id","kind") || producer.getString("kind") !in setOf("android","desktop")) throw SyncGenerationException("invalid producer")
requireId(producer.getString("peer_id"),"peer"); val consumer=root.getString("consumer_peer_id");requireId(consumer,"peer")
if(expectedConsumer!=null && consumer!=expectedConsumer)throw SyncGenerationException("wrong consumer")
if(consumer==producer.getString("peer_id"))throw SyncGenerationException("producer and consumer must differ")
if(!root.isNull("parent_generation_id")){requireId(root.getString("parent_generation_id"),"gen");if(root.getString("parent_generation_id")==generation)throw SyncGenerationException("generation cannot parent itself")}
try { OffsetDateTime.parse(root.getString("generated_at")) } catch (_:Exception){throw SyncGenerationException("invalid generated_at")}
val values=root.getJSONArray("artifacts");if(values.length() !in 1..MAX_ARTIFACTS)throw SyncGenerationException("artifact descriptor bound")
val names=mutableSetOf<String>();val paths=mutableSetOf<String>();var total=0L
for(index in 0 until values.length()){
val item=values.getJSONObject(index);if(item.keys().asSequence().toSet()!=setOf("logical_name","format","version","filename","size","sha256","required"))throw SyncGenerationException("invalid artifact descriptor")
val name=item.getString("logical_name");if(!name.all{it.code in 0..127} || name.toByteArray().size !in 1..MAX_LOGICAL_NAME_BYTES || !names.add(name))throw SyncGenerationException("invalid logical name")
val path=item.getString("filename");safePath(path,generation);if(!paths.add(path))throw SyncGenerationException("duplicate artifact path")
if(item.opt("version") !is Int || item.getInt("version")<1 || item.opt("size") !is Number || item.getLong("size") !in 0..MAX_ARTIFACT_BYTES || item.opt("required") !is Boolean || !DIGEST.matches(item.getString("sha256")))throw SyncGenerationException("invalid artifact metadata")
if(item.getBoolean("required") && (item.getString("format") to item.getInt("version")) !in SUPPORTED)throw SyncGenerationException("unsupported required capability")
total=Math.addExact(total,item.getLong("size"));if(total>MAX_GENERATION_BYTES)throw SyncGenerationException("generation exceeds bound")
}
if(!ANDROID_KINDS.keys.all{it in names})throw SyncGenerationException("missing required domain")
return root
}
private data class ValidatedGeneration(val manifest: JSONObject, val digest: String, val artifacts: Map<String,String>)
private fun validatePublished(directory: File, expectedConsumer: String): ValidatedGeneration {
if (directory.isDirectory.not() || java.nio.file.Files.isSymbolicLink(directory.toPath()))
throw SyncGenerationException("generation directory missing or unsafe")
val marker=File(directory,"manifest.json")
if(!marker.isFile || java.nio.file.Files.isSymbolicLink(marker.toPath()) || marker.length()>MAX_MANIFEST_BYTES)
throw SyncGenerationException("complete manifest marker missing")
val raw=marker.readBytes();val manifest=validateManifest(raw,expectedConsumer)
val artifacts=linkedMapOf<String,String>();var total=0L
val values=manifest.getJSONArray("artifacts")
for(index in 0 until values.length()){
val item=values.getJSONObject(index);val filename=item.getString("filename").substringAfterLast('/')
val file=File(directory,filename)
if(!file.isFile || java.nio.file.Files.isSymbolicLink(file.toPath()) || file.canonicalFile.parentFile!=directory.canonicalFile)
throw SyncGenerationException("missing or unsafe listed artifact")
val declared=item.getLong("size")
if(file.length()!=declared || declared>MAX_ARTIFACT_BYTES)throw SyncGenerationException("artifact size mismatch")
total=Math.addExact(total,declared);if(total>MAX_GENERATION_BYTES)throw SyncGenerationException("generation exceeds bound")
val digest=MessageDigest.getInstance("SHA-256")
val bytes=file.inputStream().use { input ->
val output=java.io.ByteArrayOutputStream(declared.toInt())
val buffer=ByteArray(1024*1024);var count:Int;var seen=0L
while(input.read(buffer).also{count=it}>=0){if(count==0)continue;seen+=count;if(seen>declared)throw SyncGenerationException("artifact grew during staging");digest.update(buffer,0,count);output.write(buffer,0,count)}
output.toByteArray()
}
if(digest.digest().joinToString(""){"%02x".format(it)}!=item.getString("sha256"))throw SyncGenerationException("artifact digest mismatch")
artifacts[item.getString("logical_name")]=bytes.toString(StandardCharsets.UTF_8)
}
return ValidatedGeneration(manifest,sha256(raw),artifacts)
}
private fun failure(result: Any): String? = when(result){
is PcCatalogImportResult.Applied,is ExerciseProfileStateImportResult.Applied,
is EquipmentDefinitionImportResult.Applied,is MobileSessionImportResult.Applied,
is EquipmentAssociationImportResult.Applied,is ExerciseBodyZoneImportResult.Applied,
is TrainingFeedbackImportResult.Applied,is ExerciseAliasImportResult.Applied,
is ExecutionDraftImportResult.Applied,is AiSessionDraftImportResult.Applied,
is CausalDeleteResult.Applied,is CausalDeleteResult.Unchanged -> null
else -> result.toString()
}
private fun recordRejected(manifest:JSONObject,digest:String,localPeer:String,error:SyncGenerationException):String = repository.inSyncGenerationTransaction { db ->
val generation=manifest.getString("generation_id")
db.rawQuery("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?",arrayOf(generation)).use{known->if(known.moveToFirst()){if(known.getString(0)!=digest)throw SyncGenerationException("generation identity reused with different manifest");return@inSyncGenerationTransaction known.getString(1)}}
var diagnostic=error.message ?: "generation rejected"
while(diagnostic.toByteArray(StandardCharsets.UTF_8).size>MAX_DIAGNOSTIC_BYTES)diagnostic=diagnostic.dropLast(1)
val at=OffsetDateTime.now().toString();val producer=manifest.getJSONObject("producer").getString("peer_id")
val ack=JSONObject().put("format","trainlog-sync-ack").put("version",1).put("ack_id","ack_${generation.removePrefix("gen_")}")
.put("run_id",manifest.getString("run_id")).put("generation_id",generation).put("producer_peer_id",producer).put("consumer_peer_id",localPeer)
.put("manifest_sha256",digest).put("result","rejected").put("durability","sqlite-commit-rejection").put("consumed_at",at).put("diagnostic",diagnostic)
ack.put("payload_sha256",sha256(canonical(ack).toByteArray(StandardCharsets.UTF_8)));val json=canonical(ack)
db.execSQL("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(generation,manifest.getString("run_id"),producer,localPeer,if(manifest.isNull("parent_generation_id"))null else manifest.getString("parent_generation_id"),digest,at,"rejected","sqlite-commit-rejection",diagnostic,json))
db.execSQL("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(ack.getString("ack_id"),generation,manifest.getString("run_id"),producer,localPeer,digest,"rejected","sqlite-commit-rejection",at,diagnostic,ack.getString("payload_sha256")))
json
}
/**
* Validates all bytes before mutation, then applies every listed domain and
* the durable consumption/ACK record in one outer SQLite transaction.
*/
fun consume(directory: File): String {
val localPeer=stablePeerId("android")
val frozen=validatePublished(directory,localPeer)
val manifest=frozen.manifest;val generation=manifest.getString("generation_id")
return try { repository.inSyncGenerationTransaction { db ->
db.rawQuery("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?",arrayOf(generation)).use{known->
if(known.moveToFirst()){
if(known.getString(0)!=frozen.digest)throw SyncGenerationException("generation identity reused with different manifest")
return@inSyncGenerationTransaction known.getString(1)
}
}
val producerPeer=manifest.getJSONObject("producer").getString("peer_id")
val expectedParent=db.rawQuery("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",arrayOf(producerPeer)).use{if(!it.moveToFirst())null else{val value=it.getString(0);if(it.moveToNext())throw SyncGenerationException("consumed generation lineage has multiple tips");value}}
val suppliedParent=if(manifest.isNull("parent_generation_id"))null else manifest.getString("parent_generation_id")
if(suppliedParent!=expectedParent)throw SyncGenerationException("generation lineage is stale or unrelated")
val a=frozen.artifacts
fun apply(name:String,result:()->Any){val json=a[name]?:throw SyncGenerationException("missing required domain: $name");failure(result())?.let{throw SyncGenerationException("$name rejected: $it")}}
// Tombstones are visible before live snapshots; typed generation
// imports know the complete causal envelope is present.
apply("causal-deletions"){repository.applyCausalDeletionExportV1Json(checkNotNull(a["causal-deletions"]))}
apply("catalog"){repository.applyPcCatalogJson(checkNotNull(a["catalog"]))}
apply("exercise-profile-state"){repository.applyExerciseProfileStateJson(checkNotNull(a["exercise-profile-state"]),allowPending=true)}
apply("exercise-aliases"){repository.applyExerciseAliasesJson(checkNotNull(a["exercise-aliases"]))}
apply("equipment-definitions"){repository.applyPcEquipmentDefinitionsJson(checkNotNull(a["equipment-definitions"]),true)}
apply("history"){repository.applyPcMobileExportV4Json(checkNotNull(a["history"]))}
apply("equipment-associations"){repository.applyPcEquipmentAssociationsJson(checkNotNull(a["equipment-associations"]))}
apply("body-zones"){repository.applyExerciseBodyZonesJson(checkNotNull(a["body-zones"]),true)}
apply("feedback"){repository.applyTrainingFeedbackJson(checkNotNull(a["feedback"]),true)}
apply("execution-drafts"){repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"]))}
if("ai-proposals" in a)apply("ai-proposals"){repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))}
val consumedAt=OffsetDateTime.now().toString()
val ack=JSONObject().put("format","trainlog-sync-ack").put("version",1)
.put("ack_id","ack_${generation.removePrefix("gen_")}").put("run_id",manifest.getString("run_id"))
.put("generation_id",generation).put("producer_peer_id",manifest.getJSONObject("producer").getString("peer_id"))
.put("consumer_peer_id",localPeer).put("manifest_sha256",frozen.digest).put("result","consumed")
.put("durability","sqlite-commit-full").put("consumed_at",consumedAt).put("diagnostic","")
val payload=canonical(ack);ack.put("payload_sha256",sha256(payload.toByteArray(StandardCharsets.UTF_8)))
val ackJson=canonical(ack)
db.execSQL("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(generation,manifest.getString("run_id"),manifest.getJSONObject("producer").getString("peer_id"),localPeer,suppliedParent,frozen.digest,consumedAt,"consumed","sqlite-commit-full","",ackJson))
db.execSQL("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(ack.getString("ack_id"),generation,manifest.getString("run_id"),manifest.getJSONObject("producer").getString("peer_id"),localPeer,frozen.digest,"consumed","sqlite-commit-full",consumedAt,"",ack.getString("payload_sha256")))
ackJson
}} catch(error:SyncGenerationException){recordRejected(manifest,frozen.digest,localPeer,error)}
}
fun acceptAcknowledgement(raw: ByteArray): String {
if(raw.size>MAX_MANIFEST_BYTES || !repository.syncGenerationJsonHasUniqueKeys(raw.toString(StandardCharsets.UTF_8)))throw SyncGenerationException("invalid ACK JSON")
val ack=try{JSONObject(raw.toString(StandardCharsets.UTF_8))}catch(_:Exception){throw SyncGenerationException("invalid ACK JSON")}
boundedJson(ack)
val keys=setOf("format","version","ack_id","run_id","generation_id","producer_peer_id","consumer_peer_id","manifest_sha256","result","durability","consumed_at","diagnostic","payload_sha256")
if(ack.keys().asSequence().toSet()!=keys || ack.optString("format")!="trainlog-sync-ack" || ack.opt("version") !is Int || ack.getInt("version")!=1 || ack.getString("result") !in setOf("consumed","rejected") || ack.getString("durability")!=(if(ack.getString("result")=="consumed")"sqlite-commit-full" else "sqlite-commit-rejection") || ack.getString("diagnostic").toByteArray().size>MAX_DIAGNOSTIC_BYTES)throw SyncGenerationException("unsupported ACK")
requireId(ack.getString("run_id"),"sy");requireId(ack.getString("generation_id"),"gen");requireId(ack.getString("producer_peer_id"),"peer");requireId(ack.getString("consumer_peer_id"),"peer")
if(ack.getString("ack_id")!="ack_${ack.getString("generation_id").removePrefix("gen_")}" || !DIGEST.matches(ack.getString("manifest_sha256")))throw SyncGenerationException("invalid ACK identity")
try{OffsetDateTime.parse(ack.getString("consumed_at"))}catch(_:Exception){throw SyncGenerationException("invalid ACK timestamp")}
val payload=JSONObject(ack.toString());val claimed=payload.remove("payload_sha256") as? String ?: throw SyncGenerationException("ACK digest missing")
if(!DIGEST.matches(claimed) || sha256(canonical(payload).toByteArray(StandardCharsets.UTF_8))!=claimed)throw SyncGenerationException("ACK digest mismatch")
return repository.inSyncGenerationTransaction { db ->
db.rawQuery("SELECT run_id,producer_peer_id,consumer_peer_id,manifest_sha256,status FROM sync_generations WHERE generation_id=?",arrayOf(ack.getString("generation_id"))).use{row->
if(!row.moveToFirst())throw SyncGenerationException("ACK has no pending generation")
if(ack.getString("run_id")!=row.getString(0)||ack.getString("producer_peer_id")!=row.getString(1)||ack.getString("consumer_peer_id")!=row.getString(2)||ack.getString("manifest_sha256")!=row.getString(3))throw SyncGenerationException("ACK does not match pending generation")
val target=if(ack.getString("result")=="consumed")"acknowledged" else "rejected"
if(row.getString(4) in setOf("acknowledged","rejected")){if(row.getString(4)!=target)throw SyncGenerationException("conflicting ACK replay");return@inSyncGenerationTransaction "unchanged"}
if(row.getString(4)!="waiting_acknowledgement")throw SyncGenerationException("generation is not awaiting ACK")
db.execSQL("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",arrayOf(target,ack.getString("consumed_at"),ack.getString("generation_id")))
target
}
}
}
}

View file

@ -443,6 +443,52 @@ class TrainlogRepository(
database.close()
}
/**
* Runs generation work in one SQLite scope. WHY: independently opened
* exporter/importer connections cannot prove a coherent generation.
* CONTRACT: the caller owns the outer transaction and decides success.
* INVARIANT: nested repository transactions cannot commit independently
* of this boundary.
*/
internal fun <T> inSyncGenerationTransaction(block: (SQLiteDatabase) -> T): T {
val db = database.writableDatabase
db.beginTransaction()
return try {
val result = block(db)
db.setTransactionSuccessful()
result
} finally {
db.endTransaction()
}
}
internal fun syncGenerationJsonHasUniqueKeys(json: String): Boolean =
jsonHasUniqueObjectKeys(json)
/** Captures Android-owned generation artifacts from one read snapshot. */
internal fun captureSyncGenerationArtifacts(afterFirstArtifact: (() -> Unit)? = null): Map<String, String> =
inSyncGenerationTransaction {
val artifacts=linkedMapOf("catalog" to buildPcCatalogJson())
afterFirstArtifact?.invoke()
artifacts["history"]=buildMobileExport(4, true);artifacts["execution-drafts"]=buildExecutionDraftExportV1Json()
artifacts["exercise-aliases"]=buildExerciseAliasesJson();artifacts["exercise-profile-state"]=buildExerciseProfileStateJson()
artifacts["equipment-definitions"]=buildEquipmentDefinitionsJson(true);artifacts["equipment-associations"]=buildEquipmentAssociationsJson()
artifacts["body-zones"]=buildExerciseBodyZonesJson(true);artifacts["feedback"]=buildTrainingFeedbackJson(true)
artifacts["causal-deletions"]=buildCausalDeletionExportV1Json();artifacts
}
/** Direction-neutral stable-identity catalogue used before generation history. */
internal fun buildPcCatalogJson(): String {
val items=JSONArray()
listExercises().sortedWith(compareBy<ExerciseProfile> { it.name.lowercase(Locale.ROOT) }.thenBy { it.exerciseId }).forEach { exercise ->
items.put(JSONObject().put("exercise_id",exercise.exerciseId).put("name",exercise.name)
.put("recording_mode",exercise.recordingMode.wireValue).put("tracking_mode",exercise.trackingMode.wireValue)
.put("data_fields",exercise.dataFields))
}
return JSONObject().put("format","trainlog-pc-catalog").put("version",1)
.put("generated_at",OffsetDateTime.now().toString()).put("exercises",items).toString()
}
/**
* WHY: dashboard comparisons must remain conservative. CONTRACT: rows are
* actual observable-history facts only; external working loads are grouped
@ -1222,8 +1268,10 @@ class TrainlogRepository(
* CONTRACT: this is the sole body-zone exchange source in both directions.
* Session V2 and frozen TRAINLOG_FORMAT_V1 retain their exact shapes.
*/
fun buildExerciseBodyZonesJson(): String {
check(!hasCausalProtection("body_zone_relation")) { "Causal BODY ZONES protection requires the staged artifact." }
fun buildExerciseBodyZonesJson(): String = buildExerciseBodyZonesJson(false)
private fun buildExerciseBodyZonesJson(completeCausalEnvelope: Boolean): String {
check(completeCausalEnvelope || !hasCausalProtection("body_zone_relation")) { "Causal BODY ZONES protection requires the staged artifact." }
val exercises = JSONArray()
listExercises().sortedBy { it.exerciseId }.forEach { exercise ->
exercises.put(JSONObject()
@ -1422,8 +1470,11 @@ class TrainlogRepository(
} catch(_:Exception){ExerciseProfileStateImportResult.DatabaseError} finally {if(db.inTransaction())db.endTransaction()}
}
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
if (hasCausalProtection("body_zone_relation"))
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult =
applyExerciseBodyZonesJson(json, false)
internal fun applyExerciseBodyZonesJson(json: String, completeCausalEnvelope: Boolean): ExerciseBodyZoneImportResult {
if (!completeCausalEnvelope && hasCausalProtection("body_zone_relation"))
return ExerciseBodyZoneImportResult.Invalid("Causal BODY ZONES protection requires the staged artifact.")
val root = try { JSONObject(json) } catch (_: Exception) {
return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.")
@ -3740,9 +3791,9 @@ class TrainlogRepository(
if (note.isNull("value")) null else note.getString("value"))
}
private fun buildMobileExport(version: Int): String {
private fun buildMobileExport(version: Int, completeCausalEnvelope: Boolean = false): String {
require(version in 1..4)
if (database.readableDatabase.rawQuery(
if (!completeCausalEnvelope && database.readableDatabase.rawQuery(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1", null,
).use { it.moveToFirst() }) {
error("Causal protection refuses a mobile snapshot that omits tombstones.")
@ -4926,8 +4977,10 @@ class TrainlogRepository(
/** Snapshot only user-created definitions; bundled manifest rows are never
* exported as mutable data and absence never requests deletion. */
fun buildEquipmentDefinitionsJson(): String {
check(!hasCausalProtection("custom_equipment")) { "Causal equipment protection requires the staged artifact." }
fun buildEquipmentDefinitionsJson(): String = buildEquipmentDefinitionsJson(false)
private fun buildEquipmentDefinitionsJson(completeCausalEnvelope: Boolean): String {
check(completeCausalEnvelope || !hasCausalProtection("custom_equipment")) { "Causal equipment protection requires the staged artifact." }
val supplied = EquipmentCatalog.load(applicationContext).map { it.equipmentId }.toSet()
val items = JSONArray()
listEquipment().filter { it.equipmentId !in supplied }.forEach { entry ->
@ -4957,8 +5010,11 @@ class TrainlogRepository(
.put("equipment", items).toString()
}
fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult {
if (hasCausalProtection("custom_equipment"))
fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult =
applyPcEquipmentDefinitionsJson(json, false)
internal fun applyPcEquipmentDefinitionsJson(json: String, completeCausalEnvelope: Boolean): EquipmentDefinitionImportResult {
if (!completeCausalEnvelope && hasCausalProtection("custom_equipment"))
return EquipmentDefinitionImportResult.Invalid("Causal equipment protection requires the staged artifact.")
val root = try { JSONObject(json) } catch (_: Exception) {
return EquipmentDefinitionImportResult.Invalid("Définitions équipement JSON invalides.")
@ -5760,8 +5816,10 @@ class TrainlogRepository(
text.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES
/** Direction-neutral V2 companion: roots and every immutable revision. */
fun buildTrainingFeedbackJson(): String {
check(!hasCausalProtection("feedback")) { "Causal feedback protection requires the staged artifact." }
fun buildTrainingFeedbackJson(): String = buildTrainingFeedbackJson(false)
private fun buildTrainingFeedbackJson(completeCausalEnvelope: Boolean): String {
check(completeCausalEnvelope || !hasCausalProtection("feedback")) { "Causal feedback protection requires the staged artifact." }
val root = JSONObject().put("format", "trainlog-training-feedback").put("version", 2)
.put("generated_at", OffsetDateTime.now().toString())
val exercise = JSONArray()
@ -5798,8 +5856,11 @@ class TrainlogRepository(
return root.put("exercise_feedback",exercise).put("session_followups",followups).toString()
}
fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult {
if (hasCausalProtection("feedback"))
fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult =
applyTrainingFeedbackJson(json, false)
internal fun applyTrainingFeedbackJson(json: String, completeCausalEnvelope: Boolean): TrainingFeedbackImportResult {
if (!completeCausalEnvelope && hasCausalProtection("feedback"))
return TrainingFeedbackImportResult.Invalid("Causal feedback protection requires the staged artifact.")
if (json.toByteArray(Charsets.UTF_8).size > 40 * 1024 * 1024)
return TrainingFeedbackImportResult.Invalid("Compagnon de ressentis trop volumineux.")
@ -7836,7 +7897,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
19,
20,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -7880,6 +7941,7 @@ private class TrainlogDatabaseHelper(
createAiSessionDraftTables(db)
createSyncDataLifecycleTables(db)
createCausalDeleteTables(db)
createSyncGenerationTables(db)
seedEquipment(db)
}
@ -8039,6 +8101,14 @@ private class TrainlogDatabaseHelper(
createCausalDeleteTables(db)
version = 19
}
if (version < 20 && newVersion >= 20) {
/* WHY: publication is not peer consumption. CONTRACT: v20 stores
* staged generation/manifest/ACK identity separately from frozen
* domain artifacts. INVARIANT: migration invents no generation,
* acknowledgement, ancestry, or causal operation. */
createSyncGenerationTables(db)
version = 20
}
if (version != newVersion) {
error(
@ -8128,6 +8198,15 @@ private class TrainlogDatabaseHelper(
)
}
private fun createSyncGenerationTables(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN('desktop','android')));")
db.execSQL("CREATE TABLE IF NOT EXISTS sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,producer_kind TEXT NOT NULL,generated_at TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('captured','published','waiting_acknowledgement','acknowledged','rejected')),manifest_json TEXT NOT NULL,staging_path TEXT NOT NULL,acknowledged_at TEXT);")
db.execSQL("CREATE TABLE IF NOT EXISTS sync_generation_artifacts(generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE CASCADE,logical_name TEXT NOT NULL,format TEXT NOT NULL,version INTEGER NOT NULL,filename TEXT NOT NULL,size_bytes INTEGER NOT NULL,sha256 TEXT NOT NULL,required INTEGER NOT NULL CHECK(required IN(0,1)),PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));")
db.execSQL("CREATE TABLE IF NOT EXISTS sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,consumed_at TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,diagnostic TEXT NOT NULL,ack_json TEXT NOT NULL,UNIQUE(producer_peer_id,generation_id));")
db.execSQL("CREATE TABLE IF NOT EXISTS sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT NOT NULL,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,manifest_sha256 TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,created_at TEXT NOT NULL,diagnostic TEXT NOT NULL,payload_sha256 TEXT NOT NULL);")
db.execSQL("CREATE TABLE IF NOT EXISTS sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));")
}
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(19, scalar(db, "PRAGMA user_version"))
assertEquals(20, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(19, scalar(db, "PRAGMA user_version"))
assertEquals(20, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(19, scalar(db, "PRAGMA user_version"))
assertEquals(20, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(19, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(20, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -0,0 +1,184 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name="generation-migration-${UUID.randomUUID()}.db"
TrainlogRepository(context,name).also{
assertTrue(it.createExercise(NewExerciseProfile("Migration sentinel",RecordingMode.SETS,TrackingMode.REPS,0)) is CreateExerciseResult.Created)
it.close()
}
val path=context.getDatabasePath(name)
SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READWRITE).use{db->
listOf("sync_causal_publications","sync_acknowledgements","sync_consumed_generations","sync_generation_artifacts","sync_generations","sync_peer_identity").forEach{db.execSQL("DROP TABLE $it")}
db.execSQL("PRAGMA user_version=19")
}
val migrated=TrainlogRepository(context,name).also{it.listExercises()}
try{
SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->
db.rawQuery("PRAGMA user_version",null).use{assertTrue(it.moveToFirst());assertEquals(20,it.getInt(0))}
db.rawQuery("SELECT COUNT(*) FROM exercises",null).use{assertTrue(it.moveToFirst());assertTrue(it.getInt(0)>0)}
listOf("sync_generations","sync_consumed_generations","sync_acknowledgements","sync_causal_publications").forEach{table->db.rawQuery("SELECT COUNT(*) FROM $table",null).use{assertTrue(it.moveToFirst());assertEquals(0,it.getInt(0))}}
}
}finally{migrated.close();context.deleteDatabase(name)}
}
@Test
fun lateSemanticFailureRollsBackWholeAndroidGeneration() {
val sourceName="generation-rollback-source-${UUID.randomUUID()}.db";val destinationName="generation-rollback-destination-${UUID.randomUUID()}.db"
val root=Files.createTempDirectory("trainlog-generation-rollback-").toFile();val source=TrainlogRepository(context,sourceName);val destination=TrainlogRepository(context,destinationName)
try{
val exercise=(source.createExercise(NewExerciseProfile("Rollback exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=5)))))) is SaveSessionResult.Saved)
val destinationService=SyncGenerationService(destination);val identity=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111")
val peer=JSONObject(java.io.File(identity.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
val sourceService=SyncGenerationService(source);val captured=sourceService.capture(root,peer);val published=sourceService.publish(captured,java.io.File(root,"objects"))
val bad="{}".toByteArray();java.io.File(published,"feedback-v2.json").writeBytes(bad)
val manifest=JSONObject(java.io.File(published,"manifest.json").readText());val artifacts=manifest.getJSONArray("artifacts")
for(index in 0 until artifacts.length())if(artifacts.getJSONObject(index).getString("logical_name")=="feedback")artifacts.getJSONObject(index).put("size",bad.size).put("sha256",java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(""){"%02x".format(it)})
java.io.File(published,"manifest.json").writeText(manifest.toString())
assertEquals("rejected",JSONObject(destinationService.consume(published)).getString("result"))
assertTrue(destination.listSessions().isEmpty())
SQLiteDatabase.openDatabase(context.getDatabasePath(destinationName).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->db.rawQuery("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0))}}
}finally{source.close();destination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);root.deleteRecursively()}
}
@Test
fun causalOperationBytesRemainImmutableAcrossAndroidGenerations() {
val name="generation-causal-${UUID.randomUUID()}.db";val root=Files.createTempDirectory("trainlog-generation-causal-").toFile();val repo=TrainlogRepository(context,name)
try{
val exercise=(repo.createExercise(NewExerciseProfile("Causal generation",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=4)))))) is SaveSessionResult.Saved)
val sessionId=repo.listSessions().single().sessionId
assertTrue(repo.deleteCausally("session",sessionId,"peer_android_generation") is CausalDeleteResult.Applied)
val service=SyncGenerationService(repo);val peer="peer_33333333-3333-4333-8333-333333333333"
val first=service.capture(root,peer);val second=service.capture(root,peer)
fun operation(captured:CapturedSyncGeneration)=JSONObject(java.io.File(captured.stagingDirectory,"causal-deletions-v1.json").readText()).getJSONArray("operations").getJSONObject(0)
assertEquals(operation(first).toString(),operation(second).toString());assertTrue(operation(first).isNull("publication_context"))
SQLiteDatabase.openDatabase(context.getDatabasePath(name).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->
db.rawQuery("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0));assertEquals(1,it.getInt(1))}
}
}finally{repo.close();context.deleteDatabase(name);root.deleteRecursively()}
}
@Test
fun androidCaptureUsesOneSnapshotAgainstControlledWriter() {
val name="generation-snapshot-${UUID.randomUUID()}.db";val repo=TrainlogRepository(context,name)
try{
val first=(repo.createExercise(NewExerciseProfile("Snapshot A",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=first,sets=listOf(SessionSetDraft(reps=3)))))) is SaveSessionResult.Saved)
val attempted=CountDownLatch(1);var writer:Thread?=null
val captured=repo.captureSyncGenerationArtifacts{
writer=Thread{
attempted.countDown()
repo.createExercise(NewExerciseProfile("Snapshot B",RecordingMode.SETS,TrackingMode.REPS,0))
}.also{it.start()}
assertTrue(attempted.await(5,TimeUnit.SECONDS))
}
val completedWriter=checkNotNull(writer)
completedWriter.join(5000);assertTrue(!completedWriter.isAlive)
assertEquals(1,JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length())
assertEquals(1,JSONObject(captured.getValue("exercise-profile-state")).getJSONArray("exercises").length())
assertEquals(2,repo.listExercises().size)
}finally{repo.close();context.deleteDatabase(name)}
}
@Test
fun androidGenerationCapturePublishConsumeAndAckSurviveReopen() {
val sourceName="generation-source-${UUID.randomUUID()}.db"
val destinationName="generation-destination-${UUID.randomUUID()}.db"
val desktopDestinationName="generation-desktop-destination-${UUID.randomUUID()}.db"
val root=Files.createTempDirectory("trainlog-generation-android-").toFile()
var source=TrainlogRepository(context,sourceName);var destination=TrainlogRepository(context,destinationName)
var desktopDestination=TrainlogRepository(context,desktopDestinationName)
try {
val created=source.createExercise(NewExerciseProfile("Generation exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
val saved = source.saveSession(
SessionDraft(listOf(SessionExerciseDraft(
exercise=created.exercise,
sets=listOf(SessionSetDraft(reps=7,weightKg=21.5)),
))),
)
assertTrue(saved is SaveSessionResult.Saved)
val destinationService=SyncGenerationService(destination)
val identityCapture=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111")
val destinationPeer=JSONObject(java.io.File(identityCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
val sourceService=SyncGenerationService(source)
val captured=sourceService.capture(root,destinationPeer)
val published=sourceService.publish(captured,java.io.File(root,"objects"))
val before=published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()}
assertEquals(published,sourceService.publish(captured,java.io.File(root,"objects")))
assertEquals(before,published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()})
val ack=destinationService.consume(published)
assertEquals("acknowledged",sourceService.acceptAcknowledgement(ack.toByteArray()))
source.close();destination.close()
source=TrainlogRepository(context,sourceName);destination=TrainlogRepository(context,destinationName)
assertEquals(1,destination.listSessions().size)
assertEquals("unchanged",SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()))
assertEquals(ack,SyncGenerationService(destination).consume(published))
// A desktop-created mutation uses the public C persistence API,
// then the production desktop generation entry point. This is not
// an Android generation re-exported through desktop.
val repositoryRoot=generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))){it.parentFile}
.first{java.io.File(it,"tools/sync_generation_exchange.py").isFile}
val desktopDb=java.io.File(root,"desktop-source.sqlite")
fun run(vararg command:String):String{
val process=ProcessBuilder(*command).directory(repositoryRoot).redirectErrorStream(true).start()
val text=process.inputStream.bufferedReader().readText()
assertEquals("command failed: ${command.joinToString(" ")}\n$text",0,process.waitFor())
return text
}
val desktopConsumerDb=java.io.File(root,"desktop-consumer.sqlite")
run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopConsumerDb.absolutePath)
val desktopPeer=Regex("PEER_ID=(peer_[^\\s]+)").find(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"peer-id","--database",desktopConsumerDb.absolutePath))!!.groupValues[1]
val androidToDesktop=sourceService.capture(root,desktopPeer)
val androidPublished=sourceService.publish(androidToDesktop,java.io.File(root,"android-desktop-objects"))
val androidAckFile=java.io.File(root,"android-desktop-ack.json")
run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"consume-desktop",androidPublished.absolutePath,"--database",desktopConsumerDb.absolutePath,"--ack-output",androidAckFile.absolutePath)
assertEquals("acknowledged",sourceService.acceptAcknowledgement(androidAckFile.readBytes()))
val androidSessionId=source.listSessions().single().sessionId
assertEquals("1",run("sqlite3",desktopConsumerDb.absolutePath,"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';").trim())
run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopDb.absolutePath)
val androidPeerCapture=SyncGenerationService(desktopDestination).capture(root,"peer_22222222-2222-4222-8222-222222222222")
val androidPeer=JSONObject(java.io.File(androidPeerCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
val desktopOwned=java.io.File(root,"desktop-owned");val desktopObjects=java.io.File(root,"desktop-objects")
val captureText=run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"capture-desktop","--database",desktopDb.absolutePath,"--owned-root",desktopOwned.absolutePath,"--consumer-peer",androidPeer)
val desktopGeneration=Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1]
run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"publish","--database",desktopDb.absolutePath,"--generation-id",desktopGeneration,"--object-root",desktopObjects.absolutePath)
val desktopPublished=java.io.File(desktopObjects,"generations/$desktopGeneration")
val desktopAck=SyncGenerationService(desktopDestination).consume(desktopPublished)
val desktopAckFile=java.io.File(root,"desktop-ack.json").apply{writeText(desktopAck)}
assertTrue(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"accept-ack",desktopAckFile.absolutePath,"--database",desktopDb.absolutePath).contains("ACK_ACCEPT=acknowledged"))
assertTrue(desktopDestination.listSessions().any{it.sessionId=="se_99999999-9999-4999-8999-999999999999"})
} finally {
source.close();destination.close();desktopDestination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);context.deleteDatabase(desktopDestinationName);root.deleteRecursively()
}
}
}

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(19, cursor.getInt(0))
assertEquals(20, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(19, cursor.getInt(0))
assertEquals(20, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -921,3 +921,18 @@ admission covers ordered occurrences, targets, confirmed sets/continuous/MAX,
notes, feedback revisions and linked observations. Draft admission uses the
stable draft revision for active and pending storage; unfinished form strings
remain local and never affect cross-platform identity.
Android schema v20 additively stores installation peer identity, outgoing
generation/artifact state, consumed-generation state, durable ACKs, and the
separate causal-operation publication ledger. Migration from v19 preserves
business, draft, finalization, revision, and causal rows and creates none of
those new records retroactively.
`SyncGenerationService` is an explicit staged entry point. It captures catalog,
V4 history, drafts, aliases, profiles, custom equipment, associations, BODY
ZONES, feedback, and causal deletions in one repository transaction; writes an
immutable private generation; publishes its manifest last; consumes all listed
domains plus the durable generation record in one outer transaction; and
replays the stored logical ACK after restart. The complete-envelope context is
internal and typed: standalone artifact methods retain their causal guards.
This service is not selected by the automatic V3 storage/request path.

View file

@ -468,7 +468,7 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v19 is canonical long-term history. Its additive v18 ->
Desktop SQLite schema v21 is canonical long-term history. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
@ -816,15 +816,20 @@ schema change.
## 13. Audited evolution constraints
Each mutating importer has strict validation and a SQLite transaction. The V2
Each standalone mutating importer has strict validation and a SQLite
transaction. The V2
association companion is a strict corroboration of the equipment already
carried by the mobile snapshot and does not rewrite divergent state. However,
one definitions/mobile/body-zones/associations publication has no common generation
manifest and is not one cross-artifact database transaction. A late association
manifest in the active V3 path and is not one cross-artifact database
transaction. A late association
conflict can therefore follow a successfully committed definitions or mobile
import; replay remains idempotent and existing conflicting content is not
overwritten. A future batch protocol must be separately versioned rather than
retrofitted into frozen formats.
overwritten. The explicit staged generation service now supplies that
separately versioned batch boundary: one source snapshot, immutable artifacts,
manifest-last publication, complete validation, and one destination
transaction including consumption/ACK state. It is intentionally not selected
by the active V3 engine.
Body-zone concurrency uses an internal canonical-state baseline. Equal states
are idempotent; the sole changed side wins; if both local and incoming states

View file

@ -25,8 +25,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v20 |
| Android SQLite | schema v19 |
| Desktop SQLite | schema v21 |
| Android SQLite | schema v20 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
@ -52,6 +52,8 @@ parallel implementation of its rules.
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
@ -94,18 +96,22 @@ Android inbox/outbox still select V3 and do not move the draft artifact.
- The desktop imports Android snapshots, publishes catalog/profile/feedback/AI
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.
Explicit staged generation services now provide coherent V4/companion
capture, immutable manifests, whole-generation SQLite consumption and
durable peer ACKs. The active automatic path remains V3 and does not select
these services.
- 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
remains the authority for the later orchestrator. 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.
session link and the active Android draft. Legacy imports remain transactional
per artifact. Explicit generation consumption instead validates every listed
artifact, applies all domains and its consumption record in one transaction,
and emits `consumed` only after commit. A legacy receipt remains distinct from
the correlated generation ACK.
Characterization now also freezes the observed preservation of local-only
session/body values on an identical replay, loss of an occurrence-local note
when V3 correction reconstructs that occurrence, delete-before-send exposure,
@ -293,15 +299,16 @@ smoke test are not automated. The latter is why
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
domains are durably protected; exact replay is idempotent and unprovable or
concurrent ancestry conflicts before mutation. Protected legacy snapshots are
refused before resurrection. Active synchronization still selects V3: no
generation acknowledgement, service activation, deployment, or user-data
migration occurred.
refused before resurrection. Staged generation acknowledgement is implemented,
but active synchronization still selects V3: no staged-service activation,
deployment, or user-data migration occurred.
The focused causal closeout additionally proves child-data delete/update
conflicts including change-and-revert, imported built-in and alias protection,
active/pending/desktop draft-revision equivalence, protected companion refusal,
current-view retirement and bounded pre-effect admission. It changes neither
the v20/v19 schemas nor active V3 transport selection.
current-view retirement and bounded pre-effect admission. The later generation
slice additively advances schemas to desktop v21/Android v20 without changing
active V3 transport selection.
The residual causal evidence also proves legitimate Android and desktop
producer directions separately for exercise retirement, populated body

View file

@ -16,8 +16,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=20
DATABASE_SCHEMA_V20=IMPLEMENTED
TRAINLOG_DATABASE_SCHEMA_VERSION=21
DATABASE_SCHEMA_V21=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
@ -36,7 +36,7 @@ PRAGMA user_version;
Current value:
```text
20
21
```
The independent actual-set loads documented in the current desktop, Android
@ -63,6 +63,15 @@ They do not fabricate migration ancestry: they are written only by a real
post-migration mutation. Session exercise replacement writes this revision in
the same transaction and refuses to modify a deleted target.
Schema v21 additively introduces stable installation peer identity, outgoing
generation/artifact state, consumed-generation state, durable ACK records, and
the separate immutable-operation publication ledger. The v20→v21 migration is
one SQLite transaction, preserves all business and causal rows, and creates no
historical generation, ancestry, consumption, or ACK. A complete generation
consumer owns one outer transaction covering every domain mutation, its
consumption record, and the data required to replay the same ACK after restart.
Transport publication and ACK-file writes remain outside that transaction.
Version 19 additively stores causal note revisions/current state and staged
execution drafts, their immutable revisions, pending/active state and durable
finalization ledger. It changes no published V1 format and performs no user

View file

@ -262,17 +262,20 @@ 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 :
**[IMPLEMENTED — PASS/FROZEN]** Every staged multi-artifact publication has a
unique opaque `generation_id` (a `gen_`-prefixed UUIDv4), never a timestamp.
The independent manifest is conceptually:
```json
{
"format": "trainlog-sync-manifest",
"version": 1,
"generation_id": "gen_<uuid-v4>",
"run_id": "sy_<uuid-v4>",
"producer": {"peer_id": "peer_<uuid-v4>", "kind": "android"},
"consumer_peer_id": "peer_<uuid-v4>",
"generated_at": "offset-date-time",
"parent_generation_id": null,
"artifacts": [{
"logical_name": "mobile-history",
"format": "trainlog-mobile-export",
@ -421,14 +424,14 @@ de fichiers Android anciens.
toutes plateformes ;
3. `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`: mobile V4, drafts and previously lost fields — `PASS/FROZEN`;
4. `TRAINLOG_SYNC_CAUSAL_DELETE_V1` : opérations causales et tombstones ;
5. `TRAINLOG_SYNC_GENERATION_ACK_V1` : manifest, staging, publication,
consommation, reprise et ack ;
5. `TRAINLOG_SYNC_GENERATION_ACK_V1`: manifest, staging, publication,
transactional consumption, restart, and ACK — `PASS/FROZEN`;
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.
Each tranche remains bounded by its predecessors. The next implementation
cursor is `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`.
## 15. Statut des preuves

View file

@ -658,7 +658,7 @@ atomically. Timestamps are evidence, never last-writer-wins authority.
The artifact is available only through explicit repository/tool entry points.
It is not selected by V3 transport. Null publication context is the typed
handoff to the later generation/acknowledgement tranche, not consumption proof.
handoff to the separate generation association ledger, not consumption proof.
`predecessor_revision_id` identifies either the canonical complete business
projection or a durable mutation revision created by a supported correction.
@ -669,3 +669,35 @@ Readers consume at most the 4 MiB limit plus one sentinel byte, exporters
enumerate at most 4096 operations plus one sentinel row, and a local deletion
is refused before its effect if the complete protection artifact would exceed
either bound.
## Staged generation manifest and acknowledgement V1
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains
`generation_id`, `run_id`, producer `{peer_id, kind}`, `consumer_peer_id`, an
offset-aware `generated_at`, nullable `parent_generation_id`, and the complete
artifact descriptor array. A descriptor contains `logical_name`, `format`,
integer `version`, generation-relative `filename`, exact byte `size`, lowercase
SHA-256, and boolean `required`. Unknown root/descriptor fields, duplicate JSON
keys, duplicate names/paths, unknown required capabilities, unsafe paths, wrong
peer context, and missing required domains are rejected.
The inclusive outer limits are 64 KiB per manifest, 32 descriptors, 64 MiB per
artifact, 256 MiB per generation, 64 ASCII bytes per logical name, 240 UTF-8
bytes per relative path, path depth three, JSON depth eight, and 8192 JSON
nodes. Tighter domain bounds still apply. Artifacts live under
`generations/<generation_id>/`; the manifest is the final visibility marker.
SHA-256 proves exact byte integrity under that manifest, not authentication,
freshness, or snapshot coherence.
`trainlog-sync-ack` V1 contains `ack_id`, run/generation and both peer
identities, the exact manifest digest, `consumed|rejected`, durability,
offset-aware completion time, a diagnostic limited to 1024 UTF-8 bytes, and a
digest of the ACK payload without `payload_sha256`. `consumed` uses
`sqlite-commit-full`; `rejected` uses `sqlite-commit-rejection`. Exact replay
returns the stored ACK. A wrong peer, run, generation, manifest digest, ACK
digest, timestamp, result/durability pair, or conflicting replay is rejected.
Generation membership never changes a causal V1 operation. The immutable
operation retains null `publication_context`, its original ID and digest;
`sync_causal_publications` records first emission and retransmission by
operation/generation identity.

View file

@ -19,7 +19,7 @@ distinction between measured values and estimates.
## Current baseline
Desktop schema v20, Android schema v19, Notcurses, direct
Desktop schema v21, Android schema v20, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -33,25 +33,28 @@ visualizations are implemented; the other four Web routes remain placeholders.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_GENERATION_ACK_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
cursor: complete predecessor state, imported built-in authorization,
cross-platform draft revision identity and resource-bound admission are closed.
Generation manifests, publication context assignment and acknowledgement have
not started.
`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit
coherent capture, immutable manifest publication, whole-generation
transactional consumption, durable correlated ACK and restart/replay entry
points. Automatic V3 selection remains unchanged. The next cursor is only
`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`; no orchestrator work starts here.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
defines the complete-sync target and its bounded dependency order.
`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` supplies the reproducible isolated
desktop/Android validation entry point, JDK 17 gate and private JVM/XDG paths.
`TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` reuses and completes the
current-behavior evidence for cross-implementation V3 exchange, identities,
ordering, replay, partial publication, request/receipt processing and the real
lock. The next cursor is only `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`; it is not
started and does not yet authorize a protocol, schema, endpoint or button
change.
lock. Data lifecycle, causal deletion, and generation/ACK slices are complete;
the orchestrator/report slice remains next and does not authorize an endpoint,
button, deployment, or transport activation here.
[Contract details](design/sync_gap_contract_v1.md).
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,

View file

@ -18,7 +18,7 @@ available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility
Desktop schema v20 and Android schema v19 retain mobile export V3, readable V1/V2 imports,
Desktop schema v21 and Android schema v20 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names
@ -742,12 +742,44 @@ equal to the first pass, and both peers retained the same 32 relations for 20
of 23 stable exercise IDs. The final companion states were semantically equal
between passes and the three explicit unclassified states remained intact.
## 18. Audited protocol limitations
## 18. Staged coherent generations and durable acknowledgement
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.
`tools/sync_generation_exchange.py` and Android `SyncGenerationService` are
the production staged owners for manifest V1 and ACK V1. Neither is wired into
the automatic V3 MTP selection. Both allocate stable installation peer IDs and
opaque UUIDv4 generation/run IDs only in the explicitly opened staged store.
Desktop capture uses one private SQLite backup and runs every established
exporter against that immutable snapshot. Android holds one repository read
transaction while calling the existing domain exporters. Both validate domain
and outer limits before recording a captured generation. Publication copies
verified immutable bytes into `generations/<generation_id>/`, resumes only
byte-identical partial publication, and writes `manifest.json` last. Capacity
admits at most eight retained outgoing generations per consumer; exhaustion is
recoverable and never evicts an unacknowledged generation or causal proof.
The required artifact order is causal deletions, catalog/profile identity,
custom-equipment definitions, V4 history, aliases and occurrence equipment,
BODY ZONES, feedback, execution drafts, then optional desktop AI proposals.
All files are validated and frozen before mutation. Transaction-neutral desktop
helpers and the Android repository's outer transaction apply every business
row, causal state and the consumption/ACK record together. A late semantic
failure rolls back earlier writes and commits a separate bounded `rejected`
ACK; missing/incomplete transport input remains retryable and produces no ACK.
Lineage is scoped to the producer/consumer pair. Exact replay returns the
stored result; an accepted successor must name the last consumed generation as
parent. UUIDs, clocks, mtimes, and directory scans never choose a successor.
An ACK timeout leaves `waiting_acknowledgement`; a late exact ACK resolves it
idempotently. Causal V1 operation bytes remain unchanged across first emission
and retransmission because generation membership lives only in
`sync_causal_publications`.
## 19. Legacy active-protocol limitations
The complete-sync semantics remain frozen separately in
[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). The staged
generation slice implements its batch boundary but does not activate it.
Each mutating importer validates strictly and owns a SQLite transaction. The
V2 association companion only corroborates equipment already imported in the
@ -756,9 +788,9 @@ definitions/mobile/body-zones/associations batch nevertheless has no common gene
or cross-file transaction. Independent “newest artifact” selection can
therefore observe a partially published generation; validation stops on a
mismatch, but an earlier artifact may already have committed. Replay is
idempotent and no conflicting local value is overwritten. A future atomic-batch
design requires a new versioned manifest rather than a semantic change to any
published format.
idempotent and no conflicting local value is overwritten. That active legacy
design remains non-atomic. Explicit manifest V1 generation consumption
provides the separate atomic path without changing any published companion.
Snapshots carry no exercise, session, body-observation, body-zone-relation, or equipment-definition
tombstones. Omission therefore never deletes one of those objects. The only

View file

@ -713,7 +713,7 @@ passing tests:
| Inter-process lock | `sync_body_zone_wiring` against the real `sync.lock` acquisition with private `XDG_DATA_HOME` | A pipe-coordinated child holds the lock; the second trigger returns conflict before any MTP probe; every child is joined | Added |
| Enriched V4 completed history | `TrainlogRepositoryDraftTest.v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android V4 repository methods and the real desktop V4 importer/exporter | Android → desktop → fresh Android and desktop → Android → fresh desktop preserve stable identities, order, heterogeneous sets, plans, equipment, causal null/empty/escaped UTF-8 notes and idempotent replay | Added cross-implementation proof |
| Execution-draft V1 | `TrainlogRepositoryDraftTest.executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android repository methods and `execution_draft_exchange.py` | Both implementation directions preserve stable session/entry identity and confirmed facts; raw unfinished form text remains local; replay is unchanged and pre-finalization replay is stale after reopen/finalization | Added cross-implementation proof |
| General tombstones, coherent generations, causal deletion, and consumption acknowledgement | Frozen gap contract only | No active production implementation exists | Future criterion, not tested green |
| General tombstones, coherent generations, causal deletion, and consumption acknowledgement | Causal-deletion suites plus `sync_generation_exchange` and `SyncGenerationServiceTest` | Explicit staged production entry points pass cross-implementation, rollback, replay, bounds and restart assertions; automatic V3 remains unchanged | PASS/FROZEN staged evidence |
The cross-implementation helpers only provision minimal production-compatible
desktop schemas and launch production Python tools; they do not duplicate
@ -1190,6 +1190,34 @@ Documents and Download roots while failing on any legacy access.
Android unit tests and any instrumented tests run on an emulator; the primary
personal phone is reserved for manual, non-destructive install-and-sync smoke
validation and must not run `connectedDebugAndroidTest`.
## Generation, transaction, and acknowledgement V1
The normal Meson inventory includes `sync_generation_exchange` and
`schema_v21_migration`. The service suite exercises deterministic desktop
snapshot capture against a controlled writer, immutable publication/replay,
partial and substituted input, strict paths/capabilities/bounds, wrong ACK
context, durable ACK replay after reopen, late-domain rollback, rejected ACKs,
and unchanged causal-operation IDs/digests across first emission and
retransmission for all seven causal target kinds. The v20 fixture migration
preserves populated business state and creates no historical generation/ACK.
Android `SyncGenerationServiceTest` uses production repository codecs and the
filesystem object adapter. It proves a transaction-held capture against a
deterministically blocked writer, v19→v20 preservation, manifest-last immutable
publication, whole-generation rollback on a late semantic failure, committed
ACK replay after close/reopen, and causal-operation byte stability. Its real
cross-implementation test runs both chains: an Android-created session is
captured and transactionally consumed by the desktop Python service, and a
separate desktop-created session written through the public C17 database API is
captured by the desktop service and consumed by Android. Both ACKs return to
the original producer. The bridge moves bytes and invokes real entry points; it
does not author expected business JSON or repair artifacts.
After these additions the direct normal suites report 78/78 Meson tests and
216 Android tests: 212 passed, four historical-fixture skips, zero failures.
The publication adapter is an isolated directory/object-store double. No
physical MTP, phone, Drive, active service, or user database participates.
## Causal deletion V1
The normal Meson inventory includes `causal_delete_exchange`, which invokes

View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Production generation/manifest/ACK service regressions."""
import json, sqlite3, sys, tempfile, unittest
from contextlib import closing
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sync_generation_exchange as generation
PEER_A="peer_11111111-1111-4111-8111-111111111111"
PEER_B="peer_22222222-2222-4222-8222-222222222222"
RUN="sy_33333333-3333-4333-8333-333333333333"
GEN="gen_44444444-4444-4444-8444-444444444444"
SCHEMA="""
PRAGMA foreign_keys=ON;
CREATE TABLE facts(id TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,payload_sha256 TEXT,publication_context TEXT);
CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL);
CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,producer_kind TEXT,generated_at TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,status TEXT,manifest_json TEXT,staging_path TEXT,acknowledged_at TEXT);
CREATE TABLE sync_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id));
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
PRAGMA user_version=21;
"""
class GenerationTest(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(); self.root=Path(self.temp.name)
self.database=self.root/"source.sqlite"
with closing(sqlite3.connect(self.database)) as db:
db.executescript(SCHEMA); db.execute("INSERT INTO facts VALUES('state','A')"); db.commit()
db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_A,))
def tearDown(self): self.temp.cleanup()
@staticmethod
def artifacts():
return (("history","trainlog-mobile-export",4,"history.json",True,"controlled.py",()),)
def capture(self):
def exporter(_tool,_extra,output,snapshot):
with closing(sqlite3.connect(self.database)) as live: live.execute("UPDATE facts SET value='B'"); live.commit()
with closing(sqlite3.connect(snapshot)) as frozen: value=frozen.execute("SELECT value FROM facts").fetchone()[0]
output.write_text(json.dumps({"format":"trainlog-mobile-export","version":4,"state":value}))
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}),mock.patch.object(generation,"run_export",exporter):
return generation.capture_desktop(self.database,self.root/"owned",PEER_B,RUN,GEN)
def validate(self,directory):
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}):
return generation.validate_published(directory,PEER_B)
def publish(self):
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}):
return generation.publish(self.database,GEN,self.root/"objects")
def test_coherent_capture_publish_replay_and_ack_restart(self):
stage,manifest,checksum=self.capture()
self.assertEqual("A",json.loads((stage/"history.json").read_text())["state"])
with closing(sqlite3.connect(self.database)) as db:self.assertEqual("B",db.execute("SELECT value FROM facts").fetchone()[0])
published=self.publish()
before={p.name:p.read_bytes() for p in published.iterdir()}
self.assertEqual(published,self.publish())
self.assertEqual(before,{p.name:p.read_bytes() for p in published.iterdir()})
checked,actual=self.validate(published);self.assertEqual(manifest["generation_id"],checked["generation_id"]);self.assertEqual(checksum,actual)
destination=self.root/"destination.sqlite"
with closing(sqlite3.connect(destination)) as db:
db.executescript(SCHEMA);db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_B,));db.commit();db.execute("BEGIN IMMEDIATE")
db.execute("INSERT INTO facts VALUES('imported','yes')");ack=generation.record_consumed(db,manifest,checksum);db.commit()
ack_path=self.root/"ack.json";ack_path.write_bytes(generation.canonical(ack))
self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path))
with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_substitution_and_transaction_failure_leave_no_consumption(self):
_,manifest,checksum=self.capture();published=self.publish()
(published/"history.json").write_text("{}")
with self.assertRaisesRegex(generation.GenerationError,"size/digest"):self.validate(published)
destination=self.root/"rollback.sqlite"
with closing(sqlite3.connect(destination)) as db:
db.executescript(SCHEMA);db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_B,));db.commit();db.execute("BEGIN IMMEDIATE")
db.execute("INSERT INTO facts VALUES('partial','rollback')");generation.record_consumed(db,manifest,checksum);db.rollback()
with closing(sqlite3.connect(destination)) as db:
self.assertEqual(0,db.execute("SELECT count(*) FROM facts WHERE id='partial'").fetchone()[0]);self.assertEqual(0,db.execute("SELECT count(*) FROM sync_consumed_generations").fetchone()[0])
def test_paths_duplicates_limits_and_wrong_ack_are_rejected(self):
_,manifest,checksum=self.capture()
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}):
for filename in ("/absolute",f"generations/{GEN}/../escape",f"generations/{GEN}/deep/too-deep",f"generations\\{GEN}\\x"):
bad=json.loads(json.dumps(manifest));bad["artifacts"][0]["filename"]=filename
with self.assertRaises(generation.GenerationError):generation.validate_manifest_bytes(generation.canonical(bad),PEER_B)
duplicate=json.loads(json.dumps(manifest));duplicate["artifacts"].append(dict(duplicate["artifacts"][0]))
with self.assertRaises(generation.GenerationError):generation.validate_manifest_bytes(generation.canonical(duplicate),PEER_B)
exact=b" "*generation.MAX_MANIFEST
with self.assertRaisesRegex(generation.GenerationError,"invalid"):generation.validate_manifest_bytes(exact,PEER_B)
with self.assertRaisesRegex(generation.GenerationError,"byte bound"):generation.validate_manifest_bytes(exact+b" ",PEER_B)
self.publish()
published=self.root/"objects"/"generations"/GEN
original=(published/"manifest.json").read_bytes();(published/"manifest.json").write_bytes(original+b" ")
with self.assertRaisesRegex(generation.GenerationError,"different published bytes"):self.publish()
(published/"manifest.json").write_bytes(original)
wrong=generation.ack_document(RUN,GEN,PEER_A,PEER_B,checksum,"consumed",generation.now());wrong["consumer_peer_id"]="peer_55555555-5555-4555-8555-555555555555"
payload=dict(wrong);payload.pop("payload_sha256");wrong["payload_sha256"]=generation.digest_bytes(generation.canonical(payload))
path=self.root/"wrong.json";path.write_bytes(generation.canonical(wrong))
with self.assertRaisesRegex(generation.GenerationError,"does not match"):generation.accept_ack(self.database,path)
with closing(sqlite3.connect(self.database)) as db:self.assertEqual("waiting_acknowledgement",db.execute("SELECT status FROM sync_generations").fetchone()[0])
def test_missing_marker_unsupported_required_capability_and_aggregate_bounds(self):
empty=self.root/"partial";empty.mkdir()
with self.assertRaisesRegex(generation.GenerationError,"marker"):generation.validate_published(empty,PEER_B)
_,manifest,_=self.capture()
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",set()):
with self.assertRaisesRegex(generation.GenerationError,"unsupported required"):
generation.validate_manifest_bytes(generation.canonical(manifest),PEER_B)
descriptors=[]
for index in range(4):
descriptors.append({"logical_name":f"part-{index}","format":"test","version":1,"filename":f"generations/{GEN}/p{index}","size":generation.MAX_ARTIFACT,"sha256":"0"*64,"required":False})
bounded=dict(manifest);bounded["artifacts"]=descriptors
with mock.patch.object(generation,"ARTIFACTS",()),mock.patch.object(generation,"SUPPORTED",set()):
generation.validate_manifest_bytes(generation.canonical(bounded),PEER_B)
bounded["artifacts"].append({"logical_name":"overflow","format":"test","version":1,"filename":f"generations/{GEN}/overflow","size":1,"sha256":"0"*64,"required":False})
with self.assertRaisesRegex(generation.GenerationError,"generation exceeds"):generation.validate_manifest_bytes(generation.canonical(bounded),PEER_B)
def test_causal_operation_bytes_survive_multiple_generation_associations(self):
kinds=("session","execution_draft","exercise","body_observation","custom_equipment","feedback","body_zone_relation")
operations=[]
for index,kind in enumerate(kinds,1):
operations.append({"operation_id":f"op_{index:08x}-aaaa-4aaa-8aaa-aaaaaaaaaaaa","target_kind":kind,"target_id":f"target-{index}","creator_id":"peer_cccccccc-cccc-4ccc-8ccc-cccccccccccc","predecessor_revision_id":f"lv_deadbeef{index}","created_at":"2026-09-17T10:00:00+02:00","payload_sha256":f"{index:x}"*64,"publication_context":None})
with closing(sqlite3.connect(self.database)) as db:
db.executemany("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)",[tuple(operation.values()) for operation in operations]);db.commit()
artifacts=(("causal-deletions","trainlog-causal-deletions",1,"causal-deletions-v1.json",True,"controlled.py",()),)
def exporter(_tool,_extra,output,_snapshot):output.write_text(json.dumps({"format":"trainlog-causal-deletions","version":1,"generated_at":"2026-09-17T10:00:00+02:00","operations":operations},sort_keys=True))
with mock.patch.object(generation,"ARTIFACTS",artifacts),mock.patch.object(generation,"SUPPORTED",{("trainlog-causal-deletions",1)}),mock.patch.object(generation,"run_export",exporter):
first=generation.capture_desktop(self.database,self.root/"causal",PEER_B,RUN,GEN)[0]
second_id="gen_55555555-5555-4555-8555-555555555555"
second=generation.capture_desktop(self.database,self.root/"causal",PEER_B,"sy_66666666-6666-4666-8666-666666666666",second_id)[0]
self.assertEqual(json.loads((first/"causal-deletions-v1.json").read_text())["operations"],json.loads((second/"causal-deletions-v1.json").read_text())["operations"])
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual((7,7),tuple(db.execute("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications").fetchone()))
self.assertEqual([operation["payload_sha256"] for operation in operations],[row[0] for row in db.execute("SELECT payload_sha256 FROM sync_causal_operations ORDER BY operation_id")])
def test_real_consumer_rolls_back_earlier_domain_write_on_late_failure(self):
names=("catalog","history","execution-drafts","exercise-aliases","exercise-profile-state","equipment-definitions","equipment-associations","body-zones","feedback","causal-deletions")
directory=self.root/"incoming";directory.mkdir()
artifacts=[]
for name in names:
filename=name+".json";(directory/filename).write_text("{}")
artifacts.append({"logical_name":name,"format":"test","version":1,"filename":f"generations/{GEN}/{filename}","size":2,"sha256":"0"*64,"required":True})
manifest={"format":generation.MANIFEST,"version":1,"generation_id":GEN,"run_id":RUN,"producer":{"peer_id":PEER_A,"kind":"android"},"consumer_peer_id":PEER_B,"generated_at":"2026-09-17T10:00:00+02:00","parent_generation_id":None,"artifacts":artifacts}
def write_fact(db,_payload,_trace=False):db.execute("INSERT INTO facts VALUES('partial','must roll back')");return {}
with mock.patch.object(generation,"validate_published",return_value=(manifest,"a"*64)), \
mock.patch.object(generation.causal_delete_exchange,"load",return_value={"operations":[]}), \
mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \
mock.patch.object(generation.import_exercise_aliases,"load",return_value=[]), \
mock.patch.object(generation,"strict_json",return_value={}), \
mock.patch.object(generation.import_equipment_definitions,"supplied_ids",return_value=set()), \
mock.patch.object(generation.import_equipment_definitions,"validate",return_value=[]), \
mock.patch.object(generation.import_mobile_export,"load_payload",return_value={}), \
mock.patch.object(generation.import_mobile_export,"validate_payload"), \
mock.patch.object(generation.import_equipment_associations,"load_mobile_occurrences",return_value={}), \
mock.patch.object(generation.import_exercise_body_zones,"parse_payload",return_value=[]), \
mock.patch.object(generation.import_training_feedback,"load",return_value={}), \
mock.patch.object(generation.import_exercise_profile_state,"apply_profile_state"), \
mock.patch.object(generation.import_equipment_definitions,"apply_definitions"), \
mock.patch.object(generation.import_mobile_export,"apply_payload",side_effect=write_fact), \
mock.patch.object(generation.import_exercise_aliases,"apply_aliases"), \
mock.patch.object(generation.import_equipment_associations,"apply_associations"), \
mock.patch.object(generation.import_exercise_body_zones,"apply_body_zones"), \
mock.patch.object(generation.import_training_feedback,"apply_feedback",side_effect=ValueError("late semantic failure")):
rejected=generation.consume_desktop(self.database,directory)
self.assertEqual("rejected",rejected["result"]);self.assertIn("late semantic",rejected["diagnostic"])
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual(0,db.execute("SELECT COUNT(*) FROM facts WHERE id='partial'").fetchone()[0])
self.assertEqual(1,db.execute("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'").fetchone()[0])
if __name__=="__main__":unittest.main()

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version != 20:
raise CausalError(f"desktop schema v20 required, found v{version}")
if version not in (20, 21):
raise CausalError(f"desktop schema v20/v21 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -169,14 +169,15 @@ def canonical(value):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20):
raise LifecycleError(f"desktop schema v19/v20 required, found v{version}")
if version not in (19, 20, 21):
raise LifecycleError(f"desktop schema v19/v20/v21 required, found v{version}")
def import_document(connection, document):
def import_document(connection, document, own_transaction=True):
require_schema(connection)
outcomes = []
connection.execute("BEGIN IMMEDIATE")
if own_transaction:
connection.execute("BEGIN IMMEDIATE")
try:
for draft in document["drafts"]:
session_id, revision = draft["session_id"], draft["revision_id"]
@ -215,9 +216,12 @@ def import_document(connection, document):
connection.execute("INSERT INTO execution_draft_revisions VALUES(?,?,?,?)",
(session_id, revision, draft["parent_revision_id"], payload))
outcomes.append((session_id, outcome))
connection.commit()
if own_transaction:
connection.commit()
except Exception:
connection.rollback(); raise
if own_transaction:
connection.rollback()
raise
return outcomes

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20):
raise ValueError("schema desktop v18-v20 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
raise ValueError("schema desktop v18-v21 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(

View file

@ -16,7 +16,7 @@ def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def main():
def main(complete_causal_envelope=False):
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
@ -24,9 +24,9 @@ def main():
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v8 à v16 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "

View file

@ -16,7 +16,7 @@ def main():
args = parser.parse_args()
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20):
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v12-v16 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "

View file

@ -32,7 +32,7 @@ def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def main():
def main(complete_causal_envelope=False):
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
@ -41,9 +41,9 @@ def main():
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v11-v16 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20):
if version not in (17, 18, 19, 20, 21):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -73,7 +73,7 @@ def note_payload(connection, owner_kind, owner_id, value):
return {"value": row[2], "revision_id": row[0], "parent_revision_id": row[1]}
def main():
def main(complete_causal_envelope=False):
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
@ -83,11 +83,11 @@ def main():
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20):
if args.version == 4 and schema_version not in (19, 20, 21):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20) and not (args.version == 2 and schema_version == 10):
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if schema_version >= 20 and con.execute(
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
known_equipment = supplied_equipment_ids()

View file

@ -6,12 +6,12 @@ from pathlib import Path
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def main():
def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20): raise ValueError("schema desktop v15-v20 requis")
if db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v15-v21 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")

View file

@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20):
raise ImportFailure("schema desktop v18-v20 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
raise ImportFailure("schema desktop v18-v21 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -85,7 +85,7 @@ def load_mobile_occurrences(path):
payload = json.loads(path.read_text(encoding="utf-8"))
if (
payload.get("format") != "trainlog-mobile-export"
or payload.get("version") not in (2, 3)
or payload.get("version") not in (2, 3, 4)
or not isinstance(payload.get("sessions"), list)
):
fail("snapshot mobile V2 de preuve invalide")
@ -117,6 +117,24 @@ def load_mobile_occurrences(path):
return occurrences
def apply_associations(connection, payload, known, mobile_occurrences):
if payload.get("format") != FORMAT or payload.get("version") != VERSION or set(payload) != {"format", "version", "generated_at", "associations"}:
fail("extension équipement non supportée")
known = set(known); known.update(row[0] for row in connection.execute("SELECT equipment_id FROM custom_equipment"))
associations = validate_associations(payload["associations"], known)
for session_id, entry_id, exercise_id, equipment_id in associations:
exists = connection.execute("SELECT e.exercise_id,se.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?", (session_id, entry_id)).fetchone()
if exists is None: fail(f"entrée séance inconnue: {session_id}/{entry_id}")
stored_canonical = canonical_exercise_id(connection, exists[0]); incoming_canonical = canonical_exercise_id(connection, exercise_id)
if stored_canonical != incoming_canonical:
proof = mobile_occurrences.get((session_id, entry_id))
incoming_still_exists = connection.execute("SELECT 1 FROM exercises WHERE exercise_id=?", (exercise_id,)).fetchone() is not None
if not (proof == exercise_id and not incoming_still_exists) and not approved_machine_split(session_id, entry_id, incoming_canonical, stored_canonical, mobile_occurrences):
fail(f"conflit exercice association: {session_id}/{entry_id}")
if exists[1] != equipment_id: fail(f"conflit association équipement: {session_id}/{entry_id}")
return len(associations)
def approved_machine_split(session_id, entry_id, incoming_id, local_id,
mobile_occurrences):
"""Corroborate one frozen completed-history split with the current snapshot."""
@ -167,18 +185,10 @@ def main():
)
args = parser.parse_args()
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
fail("extension équipement non supportée")
if set(payload) != {"format", "version", "generated_at", "associations"}:
fail("clés extension équipement invalides")
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
fail("schema desktop v8 à v20 requis")
known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
associations = validate_associations(payload["associations"], known)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
@ -189,41 +199,9 @@ def main():
if proof_path is not None
else {}
)
for session_id, entry_id, exercise_id, equipment_id in associations:
exists = connection.execute(
"SELECT e.exercise_id,se.equipment_id FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
"WHERE s.session_id=? AND se.entry_id=?",
(session_id, entry_id)).fetchone()
if exists is None:
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
# WHY: the association companion can outlive the creator ID used
# by its source occurrence. The durable flattened alias is the
# synchronization identity evidence and is stronger than raw text.
stored_canonical = canonical_exercise_id(connection, exists[0])
incoming_canonical = canonical_exercise_id(connection, exercise_id)
if stored_canonical != incoming_canonical:
proof = mobile_occurrences.get((session_id, entry_id))
incoming_still_exists = connection.execute(
"SELECT 1 FROM exercises WHERE exercise_id=?;",
(exercise_id,),
).fetchone() is not None
alias_reconciliation = proof == exercise_id and not incoming_still_exists
split_reconciliation = approved_machine_split(
session_id, entry_id, incoming_canonical,
stored_canonical, mobile_occurrences)
if not alias_reconciliation and not split_reconciliation:
fail(f"conflit exercice association: {session_id}/{entry_id}")
# CONTRACT: the first fallback is only for schemas/runs without
# a persistent alias. The second is the closed v13 split table
# above; no source ID becomes a global one-to-many alias.
# INVARIANT: identity reconciliation never changes session_id,
# entry_id, equipment_id, or any persisted occurrence. A distinct
# live canonical exercise therefore remains a hard conflict.
if exists[1] != equipment_id:
fail(f"conflit association équipement: {session_id}/{entry_id}")
association_count = apply_associations(connection, payload, load_catalog(args.catalog), mobile_occurrences)
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
print(f"associations={len(associations)}")
print(f"associations={association_count}")
finally:
connection.close()

View file

@ -51,6 +51,23 @@ def validate(payload, reserved):
return output
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0
for definition in definitions:
row = connection.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone()
if row is not None and tuple(row) != definition: fail(f"conflit définition équipement: {definition[0]}")
for definition in definitions:
cursor = connection.execute("INSERT OR IGNORE INTO custom_equipment(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)", definition)
if cursor.rowcount == 1: imported += 1
else: skipped += 1
return imported, skipped
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
@ -61,28 +78,8 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
fail("schema desktop v8 à v16 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0
# Validate every same-ID row before inserting any definition.
for definition in definitions:
row = connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone()
if row is not None and tuple(row) != definition:
fail(f"conflit définition équipement: {definition[0]}")
with connection:
for definition in definitions:
cursor = connection.execute(
"INSERT OR IGNORE INTO custom_equipment"
"(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)",
definition)
if cursor.rowcount == 1:
imported += 1
else:
skipped += 1
imported, skipped = apply_definitions(connection, definitions)
print("EQUIPMENT_DEFINITIONS_IMPORT=PASS")
print(f"definitions_imported={imported}")
print(f"definitions_skipped={skipped}")

View file

@ -54,6 +54,35 @@ def load(path):
return result
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")
current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone()
if current is not None:
if current[0] != canonical: fail(f"conflit alias: {source}")
continue
retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone()
if retired is not None:
if retired[1:] != target[1:]: fail(f"profil incompatible: {source}")
primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone()
sp, tp = primary(retired[0]), primary(target[0])
if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}")
chosen = tp[0] if tp else (sp[0] if sp else None)
if chosen:
con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen))
con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen))
con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen))
con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0]))
con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0]))
con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source))
con.execute("DELETE FROM exercises WHERE id=?", (retired[0],))
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
@ -63,35 +92,8 @@ def main():
con = connect_database(args.database)
try:
con.execute("PRAGMA foreign_keys=ON")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20):
fail("schema desktop v12-v16 requis")
con.execute("BEGIN IMMEDIATE")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None:
fail(f"cible canonique absente: {canonical}")
current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone()
if current is not None:
if current[0] != canonical: fail(f"conflit alias: {source}")
continue
retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone()
if retired is not None:
if retired[1:] != target[1:]: fail(f"profil incompatible: {source}")
primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone()
sp, tp = primary(retired[0]), primary(target[0])
if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}")
chosen = tp[0] if tp else (sp[0] if sp else None)
if chosen:
con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen))
con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen))
con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen))
con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0]))
con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0]))
# INVARIANT: earlier sources stay one hop from a live target;
# deleting an intermediate canonical can never create a chain.
con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source))
con.execute("DELETE FROM exercises WHERE id=?", (retired[0],))
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical))
apply_aliases(con, aliases)
con.commit()
except Exception:
con.rollback(); raise

View file

@ -149,6 +149,57 @@ def replace(connection, row_id, primary, secondary):
)
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21): raise ImportFailure("schema desktop v11-v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct
# primary/secondary claims may be equal but are never unioned.
for exercise_id,primary,secondary in parsed:
row_id=resolve_exercise_row(connection,exercise_id,mobile_proof);incoming=(primary,secondary);group=grouped.get(row_id)
if group is None:grouped[row_id]=(exercise_id,incoming)
elif group[1]!=incoming:raise ImportFailure(f"données de zones incompatibles après résolution d'alias: {group[0]} / {exercise_id}")
planned=[]
for row_id,(exercise_id,incoming) in grouped.items():
primary,secondary=incoming;local_primary,local_secondary=current(connection,row_id);local_state=state(local_primary,local_secondary);incoming_state=state(primary,secondary)
baseline_row=connection.execute("SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?",(row_id,)).fetchone();baseline=None if baseline_row is None else baseline_row[0]
if local_state==incoming_state:action="skip"
elif baseline is not None and local_state==baseline:action="update"
elif baseline is not None and incoming_state==baseline:action="keep"
elif baseline is None and local_state=="|":action="update"
else:raise ImportFailure(f"conflit zones simultané: {exercise_id}")
planned.append((action,row_id,primary,secondary,incoming_state))
updated=skipped=kept_local=0
# WHY: aliases are compatibility identities, not extra exercises. Apply
# once per canonical row so input order cannot change the chosen payload.
for action,row_id,primary,secondary,incoming_state in planned:
if action=="keep":kept_local+=1;continue
if action=="update":replace(connection,row_id,primary,secondary);updated+=1
else:skipped+=1
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)",(row_id,incoming_state))
return updated,skipped,kept_local
def parse_payload(payload):
zones=load_catalog();exact(payload,{"format","version","generated_at","exercises"},"racine")
if payload["format"]!="trainlog-exercise-body-zones" or payload["version"]!=1 or isinstance(payload["version"],bool) or not isinstance(payload["generated_at"],str) or not payload["generated_at"] or not isinstance(payload["exercises"],list):raise ImportFailure("companion zones v1 invalide")
try: generated_at=datetime.fromisoformat(payload["generated_at"].replace("Z","+00:00"))
except ValueError as error:raise ImportFailure("generated_at invalide") from error
if generated_at.utcoffset() is None:raise ImportFailure("generated_at sans offset")
parsed=[];seen=set()
for index,item in enumerate(payload["exercises"]):
exact(item,{"exercise_id","primary_zone_id","secondary_zone_ids"},f"exercises[{index}]");exercise_id=item["exercise_id"];primary=item["primary_zone_id"];secondary=item["secondary_zone_ids"]
if not isinstance(exercise_id,str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or exercise_id in seen:raise ImportFailure(f"exercises[{index}].exercise_id invalide")
if primary is not None and (not isinstance(primary,str) or primary not in zones):raise ImportFailure(f"exercises[{index}].primary_zone_id invalide")
if not isinstance(secondary,list) or any(not isinstance(zone,str) or zone not in zones for zone in secondary):raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide")
if len(secondary)!=len(set(secondary)) or primary in secondary:raise ImportFailure(f"exercises[{index}]: zones dupliquées")
if primary is None and secondary:raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale")
if any(zones[zone]["kind"]=="group" for zone in ([primary] if primary else [])+secondary):raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite")
seen.add(exercise_id);parsed.append((exercise_id,primary,sorted(secondary)))
return parsed
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
@ -159,42 +210,8 @@ def main():
help="snapshot V2 importé juste avant, utilisé seulement pour prouver un ID réconcilié",
)
args = parser.parse_args()
zones = load_catalog()
payload = json.loads(args.input.read_text(encoding="utf-8"))
exact(payload, {"format", "version", "generated_at", "exercises"}, "racine")
if payload["format"] != "trainlog-exercise-body-zones" or payload["version"] != 1 or \
isinstance(payload["version"], bool) or \
not isinstance(payload["generated_at"], str) or not payload["generated_at"] or \
not isinstance(payload["exercises"], list):
raise ImportFailure("companion zones v1 invalide")
try:
generated_at = datetime.fromisoformat(payload["generated_at"].replace("Z", "+00:00"))
except ValueError as error:
raise ImportFailure("generated_at invalide") from error
if generated_at.utcoffset() is None:
raise ImportFailure("generated_at sans offset")
parsed = []
seen = set()
for index, item in enumerate(payload["exercises"]):
exact(item, {"exercise_id", "primary_zone_id", "secondary_zone_ids"}, f"exercises[{index}]")
exercise_id = item["exercise_id"]
primary = item["primary_zone_id"]
secondary = item["secondary_zone_ids"]
if not isinstance(exercise_id, str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or \
exercise_id in seen:
raise ImportFailure(f"exercises[{index}].exercise_id invalide")
if primary is not None and (not isinstance(primary, str) or primary not in zones):
raise ImportFailure(f"exercises[{index}].primary_zone_id invalide")
if not isinstance(secondary, list) or any(not isinstance(zone, str) or zone not in zones for zone in secondary):
raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide")
if len(secondary) != len(set(secondary)) or primary in secondary:
raise ImportFailure(f"exercises[{index}]: zones dupliquées")
if primary is None and secondary:
raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale")
if any(zones[zone]["kind"] == "group" for zone in ([primary] if primary else []) + secondary):
raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite")
seen.add(exercise_id)
parsed.append((exercise_id, primary, sorted(secondary)))
parsed = parse_payload(payload)
# A V2 snapshot is reconciliation evidence only when the caller selected
# and supplied that exact snapshot. Never discover a sibling implicitly:
@ -203,70 +220,9 @@ def main():
if args.mobile_export is not None else {})
connection = connect_database(args.database)
updated = skipped = kept_local = 0
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
raise ImportFailure("schema desktop v11-v20 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE")
grouped = {}
for exercise_id, primary, secondary in parsed:
row_id = resolve_exercise_row(connection, exercise_id, mobile_proof)
incoming = (primary, secondary)
group = grouped.get(row_id)
if group is None:
grouped[row_id] = (exercise_id, incoming)
elif group[1] != incoming:
# CONTRACT: secondary zones are direct ordered-set metadata, so
# alias coalescing may accept equality but must never union two
# companion claims or choose between primary/secondary roles.
raise ImportFailure(
f"données de zones incompatibles après résolution d'alias: "
f"{group[0]} / {exercise_id}"
)
# INVARIANT: resolve and compare the complete alias groups before the
# first write. This makes one reconciliation decision per canonical
# exercise and prevents input order from changing the chosen payload.
planned = []
for row_id, (exercise_id, incoming) in grouped.items():
primary, secondary = incoming
local_primary, local_secondary = current(connection, row_id)
local_state = state(local_primary, local_secondary)
incoming_state = state(primary, secondary)
baseline_row = connection.execute(
"SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?", (row_id,),
).fetchone()
baseline = None if baseline_row is None else baseline_row[0]
if local_state == incoming_state:
planned.append(("skip", row_id, primary, secondary, incoming_state))
elif baseline is not None and local_state == baseline:
planned.append(("update", row_id, primary, secondary, incoming_state))
elif baseline is not None and incoming_state == baseline:
planned.append(("keep", row_id, primary, secondary, incoming_state))
elif baseline is None and local_state == "|":
planned.append(("update", row_id, primary, secondary, incoming_state))
else:
raise ImportFailure(f"conflit zones simultané: {exercise_id}")
# WHY: aliases are compatibility identities, not additional exercises.
# Applying the plan once per resolved row avoids duplicate mutations and
# cannot recreate a retired source ID.
for action, row_id, primary, secondary, incoming_state in planned:
if action == "keep":
kept_local += 1
continue
if action == "update":
replace(connection, row_id, primary, secondary)
updated += 1
else:
skipped += 1
connection.execute(
"INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)",
(row_id, incoming_state),
)
updated,skipped,kept_local=apply_body_zones(connection,parsed,mobile_proof)
connection.commit()
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
print(f"zones_updated={updated}")

View file

@ -46,22 +46,18 @@ def validate(item):
revision_ids.add(rid);previous=rid
if any(history[-1][key]!=item[key] for key in keys): fail("pointe historique incohérente")
def main():
p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args()
try:
root=json.loads(a.artifact.read_text(encoding="utf-8"))
def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at")
con=connect_database(a.database);con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20): fail("schema desktop v17-v20 requis")
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21): fail("schema desktop v17-v21 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]
if eid in seen: fail("exercise_id profile-state dupliqué")
seen.add(eid)
row=con.execute("""SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?""",(eid,)).fetchone()
if row is None:
if a.allow_pending or a.mobile_export is not None: pending+=1;continue
if allow_pending: pending+=1;continue
fail(f"identité exercice inconnue: {eid}")
if any(row[k]!=item[k] for k in ADJ): fail(f"conflit identité/machine immutable: {eid}")
local_profile=(row["recording_mode"],row["tracking_mode"],row["data_fields"]);incoming=(item["recording_mode"],item["tracking_mode"],item["data_fields"])
@ -90,6 +86,14 @@ def main():
con.execute("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",(item["revision_id"],item["parent_revision_id"],int(item["legacy_seed"]),row["id"]))
con.execute("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",(*incoming,row["id"]))
applied+=1
return applied,equal,ancestor,pending
def main():
p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args()
try:
root=json.loads(a.artifact.read_text(encoding="utf-8"));con=connect_database(a.database)
con.execute("BEGIN IMMEDIATE")
applied,equal,ancestor,pending=apply_profile_state(con,root,a.allow_pending or a.mobile_export is not None)
con.commit();print(f"EXERCISE_PROFILE_STATE_IMPORT=PASS applied={applied} equal={equal} ancestor={ancestor} pending={pending}")
except (OSError,json.JSONDecodeError,sqlite3.Error,ValueError) as e:
try:

View file

@ -846,9 +846,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ImportFailure(
f"base desktop schema v5 à v20 attendue, version trouvée: {version}"
f"base desktop schema v5 à v21 attendue, version trouvée: {version}"
)
@ -1959,23 +1959,9 @@ def import_body(
report["body_imported"] += 1
def run_import(
payload,
database_path,
dry_run,
trace_exercises=False,
):
if not database_path.exists():
raise ImportFailure(
f"base desktop introuvable: {database_path}"
)
connection = connect_database(
database_path
)
def apply_payload(connection, payload, trace_exercises=False):
"""Apply a validated artifact without owning commit or rollback."""
connection.row_factory = sqlite3.Row
report = {
"exercises_imported": 0,
"exercises_reconciled": 0,
@ -1987,70 +1973,43 @@ def run_import(
"body_skipped": 0,
}
connection.execute("PRAGMA foreign_keys = ON;")
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21):
raise ImportFailure("mobile V4 exige le schéma desktop v19, v20 ou v21")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
incoming = ({("session", value["session_id"]) for value in payload["sessions"]} |
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
collision = protected & incoming
if collision:
kind, identity = sorted(collision)[0]
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
has_explicit_max = any("max_weight_kg" in entry for session in payload["sessions"] for entry in session["exercises"])
if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
has_explicit_zero_set_weight = any(set_item.get("weight_kg") == 0 for session in payload["sessions"] for entry in session["exercises"] for set_item in entry.get("sets", []) if "weight_kg" in set_item)
if has_explicit_zero_set_weight and schema_version < 10:
raise ImportFailure("weight_kg=0 exige le schéma desktop v10; import annulé")
mapping = import_exercises(connection, payload, report, trace_exercises)
import_sessions(connection, payload, mapping, report)
import_body(connection, payload, report)
return report
def run_import(payload, database_path, dry_run, trace_exercises=False):
if not database_path.exists():
raise ImportFailure(f"base desktop introuvable: {database_path}")
connection = connect_database(database_path)
try:
connection.execute(
"PRAGMA foreign_keys = ON;"
)
require_supported_schema(
connection
)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20):
raise ImportFailure("mobile V4 exige le schéma desktop v19 ou v20")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
incoming = ({("session", value["session_id"]) for value in payload["sessions"]} |
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
collision = protected & incoming
if collision:
kind, identity = sorted(collision)[0]
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
has_explicit_max = any(
"max_weight_kg" in entry
for session in payload["sessions"]
for entry in session["exercises"]
)
if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
has_explicit_zero_set_weight = any(
set_item.get("weight_kg") == 0
for session in payload["sessions"]
for entry in session["exercises"]
for set_item in entry.get("sets", [])
if "weight_kg" in set_item
)
if has_explicit_zero_set_weight and schema_version < 10:
raise ImportFailure(
"weight_kg=0 exige le schéma desktop v10; import annulé"
)
connection.execute(
"BEGIN IMMEDIATE;"
)
mapping = import_exercises(
connection,
payload,
report,
trace_exercises,
)
import_sessions(
connection,
payload,
mapping,
report,
)
import_body(
connection,
payload,
report,
)
connection.execute("BEGIN IMMEDIATE;")
report = apply_payload(connection, payload, trace_exercises)
if dry_run:
connection.rollback()

View file

@ -46,15 +46,13 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
else:fail("HARD CONFLICT "+revision[0])
current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode()))
return added,skipped,current[2]
def main():
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database)
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
try:
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20):fail("schema desktop v15-v20 requis")
if db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
db.execute("BEGIN IMMEDIATE");seen=set()
for index,item in enumerate(root["exercise_feedback"]):
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21):fail("schema desktop v15-v21 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}
if not isinstance(item,dict) or set(item)!=keys:fail(f"exercise_feedback[{index}] invalide")
fid=item["feedback_id"]
@ -68,8 +66,8 @@ def main():
elif old==expected:roots_skipped+=1
else:fail("HARD CONFLICT "+fid)
added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid))
seen=set()
for index,item in enumerate(root["session_followups"]):
seen=set()
for index,item in enumerate(root["session_followups"]):
keys={"followup_id","session_id","observed_at","raw_text"} if root["version"]==1 else {"followup_id","session_id","observed_at","revisions"}
if not isinstance(item,dict) or set(item)!=keys:fail(f"session_followups[{index}] invalide")
fid=item["followup_id"]
@ -81,6 +79,13 @@ def main():
elif old==expected:roots_skipped+=1
else:fail("HARD CONFLICT "+fid)
added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid))
return roots_added,roots_skipped,revisions_added,revisions_skipped
def main():
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database)
try:
db.execute("BEGIN IMMEDIATE")
roots_added,roots_skipped,revisions_added,revisions_skipped=apply_feedback(db,root)
db.commit()
except Exception:db.rollback();raise
finally:db.close()

View file

@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""Staged coherent-generation, manifest, consumption and ACK service.
The active V3 transport does not call this module. Filesystem publication is a
bounded object-store adapter used by explicit staged callers and tests.
"""
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import uuid
from contextlib import closing
from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database
import causal_delete_exchange
import execution_draft_exchange
import import_equipment_associations
import import_equipment_definitions
import import_exercise_aliases
import import_exercise_body_zones
import import_exercise_profile_state
import import_mobile_export
import import_training_feedback
import export_equipment_definitions
import export_exercise_body_zones
import export_pc_mobile
import export_training_feedback
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = "trainlog-sync-manifest"
ACK = "trainlog-sync-ack"
MAX_MANIFEST = 64 * 1024
MAX_ARTIFACTS = 32
MAX_ARTIFACT = 64 * 1024 * 1024
MAX_GENERATION = 256 * 1024 * 1024
MAX_NAME = 64
MAX_PATH = 240
MAX_DIAGNOSTIC = 1024
MAX_RETAINED_PER_PEER = 8
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
HEX = re.compile(r"^[0-9a-f]{64}$")
ARTIFACTS = (
("catalog", "trainlog-pc-catalog", 1, "catalog-v1.json", True,
"export_pc_catalog.py", ()),
("history", "trainlog-mobile-export", 4, "history-v4.json", True,
"export_pc_mobile.py", ("--version", "4")),
("execution-drafts", "trainlog-execution-drafts", 1, "execution-drafts-v1.json", True,
"execution_draft_exchange.py", ("export",)),
("exercise-aliases", "trainlog-exercise-aliases", 1, "exercise-aliases-v1.json", True,
"export_exercise_aliases.py", ()),
("exercise-profile-state", "trainlog-exercise-profile-state", 1, "exercise-profile-state-v1.json", True,
"export_exercise_profile_state.py", ()),
("equipment-definitions", "trainlog-equipment-definitions", 1, "equipment-definitions-v1.json", True,
"export_equipment_definitions.py", ()),
("equipment-associations", "trainlog-equipment-associations", 2, "equipment-associations-v2.json", True,
"export_equipment_associations.py", ()),
("body-zones", "trainlog-exercise-body-zones", 1, "body-zones-v1.json", True,
"export_exercise_body_zones.py", ()),
("feedback", "trainlog-training-feedback", 2, "feedback-v2.json", True,
"export_training_feedback.py", ()),
("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True,
"causal_delete_exchange.py", ("export",)),
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
"export_ai_session_drafts.py", ()),
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
class GenerationError(RuntimeError):
pass
def now() -> str:
return dt.datetime.now().astimezone().isoformat()
def canonical(value) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")).encode("utf-8")
def digest_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def digest_file(path: Path, declared: int | None = None) -> tuple[int, str]:
total = 0
checksum = hashlib.sha256()
with path.open("rb") as stream:
while True:
block = stream.read(1024 * 1024)
if not block:
break
total += len(block)
if total > MAX_ARTIFACT or declared is not None and total > declared:
raise GenerationError("artifact exceeds declared or 64 MiB bound")
checksum.update(block)
return total, checksum.hexdigest()
def strict_json(raw: bytes, maximum: int):
if len(raw) > maximum:
raise GenerationError("JSON exceeds byte bound")
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise GenerationError("duplicate JSON key: " + key)
result[key] = value
return result
try:
root = json.loads(raw.decode("utf-8"), object_pairs_hook=unique)
stack = [(root, 1)]; nodes = 0
while stack:
value, depth = stack.pop(); nodes += 1
if depth > 8 or nodes > 8192:
raise GenerationError("JSON nesting/node bound exceeded")
if isinstance(value, dict): stack.extend((child, depth + 1) for child in value.values())
elif isinstance(value, list): stack.extend((child, depth + 1) for child in value)
return root
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error:
raise GenerationError("invalid UTF-8/JSON") from error
def valid_id(value: object, prefix: str) -> bool:
return isinstance(value, str) and value.startswith(prefix + "_") and ID.fullmatch(value) is not None
def validate_relative(value: object, generation_id: str) -> str:
if not isinstance(value, str) or not value or len(value.encode()) > MAX_PATH or "\\" in value or "\x00" in value:
raise GenerationError("invalid artifact path")
path = PurePosixPath(value)
parts = path.parts
if path.is_absolute() or len(parts) > 3 or any(part in ("", ".", "..") for part in parts):
raise GenerationError("unsafe artifact path")
if len(parts) != 3 or parts[:2] != ("generations", generation_id):
raise GenerationError("artifact outside generation namespace")
return value
def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
root = strict_json(raw, MAX_MANIFEST)
keys = {"format", "version", "generation_id", "run_id", "producer",
"consumer_peer_id", "generated_at", "parent_generation_id", "artifacts"}
if not isinstance(root, dict) or set(root) != keys or root["format"] != MANIFEST or type(root["version"]) is not int or root["version"] != 1:
raise GenerationError("unsupported manifest shape/version")
generation = root["generation_id"]
if not valid_id(generation, "gen") or not valid_id(root["run_id"], "sy"):
raise GenerationError("invalid generation/run identity")
producer = root["producer"]
if not isinstance(producer, dict) or set(producer) != {"peer_id", "kind"} or not valid_id(producer["peer_id"], "peer") or producer["kind"] not in ("android", "desktop"):
raise GenerationError("invalid producer")
if not valid_id(root["consumer_peer_id"], "peer") or expected_consumer and root["consumer_peer_id"] != expected_consumer:
raise GenerationError("wrong consumer")
if root["consumer_peer_id"] == producer["peer_id"]:
raise GenerationError("producer and consumer must differ")
if root["parent_generation_id"] is not None and (not valid_id(root["parent_generation_id"], "gen") or root["parent_generation_id"] == generation):
raise GenerationError("invalid parent generation")
try:
parsed = dt.datetime.fromisoformat(root["generated_at"].replace("Z", "+00:00"))
if parsed.utcoffset() is None:
raise ValueError
except Exception as error:
raise GenerationError("invalid generated_at") from error
values = root["artifacts"]
if not isinstance(values, list) or not 1 <= len(values) <= MAX_ARTIFACTS:
raise GenerationError("artifact descriptor bound")
names, paths, total = set(), set(), 0
for item in values:
if not isinstance(item, dict) or set(item) != {"logical_name", "format", "version", "filename", "size", "sha256", "required"}:
raise GenerationError("invalid artifact descriptor")
name = item["logical_name"]
if not isinstance(name, str) or not name.isascii() or not 0 < len(name.encode()) <= MAX_NAME or name in names:
raise GenerationError("invalid/duplicate logical name")
names.add(name)
filename = validate_relative(item["filename"], generation)
if filename in paths:
raise GenerationError("duplicate artifact path")
paths.add(filename)
if type(item["version"]) is not int or item["version"] < 1 or type(item["size"]) is not int or not 0 <= item["size"] <= MAX_ARTIFACT or type(item["required"]) is not bool or not isinstance(item["format"], str) or not HEX.fullmatch(item["sha256"] or ""):
raise GenerationError("invalid artifact metadata")
if item["required"] and (item["format"], item["version"]) not in SUPPORTED:
raise GenerationError("unsupported required capability")
total += item["size"]
if total > MAX_GENERATION:
raise GenerationError("generation exceeds 256 MiB")
required = {row[0] for row in ARTIFACTS if row[4]}
if not required.issubset(names):
raise GenerationError("missing required domain")
return root
def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 21:
raise GenerationError("desktop schema v21 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
require_schema(db)
row = db.execute("SELECT peer_id,kind FROM sync_peer_identity WHERE singleton=1").fetchone()
if row:
if row[1] != kind:
raise GenerationError("peer kind conflict")
return row[0]
identity = "peer_" + str(uuid.uuid4())
db.execute("INSERT INTO sync_peer_identity VALUES(1,?,?)", (identity, kind))
return identity
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
command = [sys.executable, str(ROOT / "tools" / tool)]
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
command.extend(extra); command.append(str(output))
else:
command.append(str(output)); command.extend(extra)
command.extend(("--database", str(snapshot)))
generation_exporters = {
"export_pc_mobile.py": export_pc_mobile,
"export_equipment_definitions.py": export_equipment_definitions,
"export_exercise_body_zones.py": export_exercise_body_zones,
"export_training_feedback.py": export_training_feedback,
}
if tool in generation_exporters:
previous = sys.argv
try:
sys.argv = command[1:]
generation_exporters[tool].main(complete_causal_envelope=True)
return
finally:
sys.argv = previous
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode != 0:
raise GenerationError(f"export {tool} failed: {result.stdout}{result.stderr}")
def capture_desktop(database: Path, owned_root: Path, consumer: str,
run_id: str | None = None, generation_id: str | None = None):
if not valid_id(consumer, "peer"):
raise GenerationError("invalid expected consumer")
generation = generation_id or "gen_" + str(uuid.uuid4())
run = run_id or "sy_" + str(uuid.uuid4())
if not valid_id(generation, "gen") or not valid_id(run, "sy"):
raise GenerationError("invalid generation/run")
with closing(connect_database(database)) as admission:
require_schema(admission)
retained = admission.execute("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')", (consumer,)).fetchone()[0]
if retained >= MAX_RETAINED_PER_PEER:
raise GenerationError("generation retention capacity exhausted")
stage = owned_root / "staging" / generation
if stage.exists():
raise GenerationError("generation staging already exists")
stage.mkdir(parents=True, mode=0o700)
snapshot = stage / ".snapshot.sqlite"
try:
with closing(connect_database(database)) as source, closing(sqlite3.connect(snapshot)) as target:
require_schema(source)
producer = peer_identity(source, "desktop")
source.commit()
source.backup(target)
descriptors = []
total = 0
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
output = stage / filename
run_export(tool, extra, output, snapshot)
size, checksum = digest_file(output)
total += size
if total > MAX_GENERATION:
raise GenerationError("generation exceeds 256 MiB")
descriptors.append({"logical_name": logical, "format": fmt,
"version": version, "filename": f"generations/{generation}/{filename}",
"size": size, "sha256": checksum, "required": required})
parent = None
with closing(connect_database(database)) as db:
parent_rows = db.execute("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", (producer, consumer)).fetchall()
if len(parent_rows) > 1: raise GenerationError("outgoing generation lineage has multiple tips")
parent = parent_rows[0][0] if parent_rows else None
manifest = {"format": MANIFEST, "version": 1, "generation_id": generation,
"run_id": run, "producer": {"peer_id": producer, "kind": "desktop"},
"consumer_peer_id": consumer, "generated_at": now(),
"parent_generation_id": parent, "artifacts": descriptors}
raw = canonical(manifest)
validate_manifest_bytes(raw, consumer)
checksum = digest_bytes(raw)
(stage / "manifest.json").write_bytes(raw)
os.chmod(stage / "manifest.json", 0o600)
snapshot.unlink()
with closing(connect_database(database)) as db:
db.execute("BEGIN IMMEDIATE")
peer_identity(db, "desktop")
db.execute("INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL)",
(generation, run, producer, consumer, "desktop", manifest["generated_at"],
parent, checksum, "captured", raw.decode(), str(stage)))
db.executemany("INSERT INTO sync_generation_artifacts VALUES(?,?,?,?,?,?,?,?)",
[(generation, value["logical_name"], value["format"], value["version"],
value["filename"], value["size"], value["sha256"], int(value["required"]))
for value in descriptors])
causal_path = stage / "causal-deletions-v1.json"
if causal_path.is_file():
for operation in json.loads(causal_path.read_text())["operations"]:
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1))
db.commit()
return stage, manifest, checksum
except Exception:
if stage.exists():
shutil.rmtree(stage)
raise
def publish(database: Path, generation: str, object_root: Path) -> Path:
with closing(connect_database(database)) as db:
require_schema(db)
row = db.execute("SELECT manifest_json,manifest_sha256,staging_path,status FROM sync_generations WHERE generation_id=?", (generation,)).fetchone()
if not row:
raise GenerationError("unknown generation")
if row[3] not in ("captured", "published", "waiting_acknowledgement"):
raise GenerationError("generation is not publishable")
manifest = validate_manifest_bytes(row[0].encode())
destination = object_root / "generations" / generation
source = Path(row[2])
marker = destination / "manifest.json"
if marker.is_file():
existing = marker.read_bytes()
if digest_bytes(existing) != row[1]: raise GenerationError("same generation has different published bytes")
validate_published(destination, manifest["consumer_peer_id"])
else:
if destination.exists() and (not destination.is_dir() or destination.is_symlink()):
raise GenerationError("unsafe generation namespace")
destination.mkdir(parents=True, mode=0o700)
for item in manifest["artifacts"]:
source_file = source / PurePosixPath(item["filename"]).name
target = destination / source_file.name
if not source_file.is_file() or source_file.is_symlink() or source_file.resolve().parent != source.resolve():
raise GenerationError("unsafe staging substitution")
size, checksum = digest_file(source_file, item["size"])
if size != item["size"] or checksum != item["sha256"]:
raise GenerationError("staging artifact changed after capture")
if target.exists():
if target.is_symlink() or digest_file(target, item["size"]) != (item["size"], item["sha256"]):
raise GenerationError("conflicting partial publication")
else:
with source_file.open("rb") as incoming, target.open("xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
os.chmod(target, 0o600)
# Commit marker is deliberately last.
with marker.open("xb") as stream:
stream.write(row[0].encode())
os.chmod(marker, 0o600)
db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (generation,))
db.commit()
return destination
def validate_published(directory: Path, consumer: str):
marker = directory / "manifest.json"
if not marker.is_file() or marker.is_symlink():
raise GenerationError("complete manifest marker missing")
raw = marker.read_bytes() if marker.stat().st_size <= MAX_MANIFEST else b"x" * (MAX_MANIFEST + 1)
manifest = validate_manifest_bytes(raw, consumer)
total = 0
for item in manifest["artifacts"]:
path = directory / PurePosixPath(item["filename"]).name
if not path.is_file() or path.is_symlink() or path.resolve().parent != directory.resolve():
raise GenerationError("missing or unsafe listed artifact")
size, checksum = digest_file(path, item["size"])
if size != item["size"] or checksum != item["sha256"]:
raise GenerationError("artifact size/digest mismatch")
total += size
if total > MAX_GENERATION:
raise GenerationError("generation exceeds bound")
return manifest, digest_bytes(raw)
def ack_document(run: str, generation: str, producer: str, consumer: str,
manifest_digest: str, result: str, consumed_at: str,
diagnostic: str = ""):
if len(diagnostic.encode()) > MAX_DIAGNOSTIC:
raise GenerationError("diagnostic exceeds bound")
value = {"format": ACK, "version": 1, "ack_id": "ack_" + generation[4:],
"run_id": run, "generation_id": generation, "producer_peer_id": producer,
"consumer_peer_id": consumer, "manifest_sha256": manifest_digest,
"result": result, "durability": "sqlite-commit-full" if result == "consumed" else "sqlite-commit-rejection", "consumed_at": consumed_at,
"diagnostic": diagnostic}
value["payload_sha256"] = digest_bytes(canonical(value))
return value
def record_consumed(db: sqlite3.Connection, manifest: dict, manifest_digest: str,
diagnostic: str = "") -> dict:
"""Record consumption inside the caller-owned business transaction."""
require_schema(db)
generation = manifest["generation_id"]
known = db.execute("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?", (generation,)).fetchone()
if known:
if known[0] != manifest_digest:
raise GenerationError("generation identity reused with different manifest")
return json.loads(known[1])
consumer = peer_identity(db, "desktop")
if consumer != manifest["consumer_peer_id"]:
raise GenerationError("wrong local consumer")
consumed_at = now()
ack = ack_document(manifest["run_id"], generation, manifest["producer"]["peer_id"],
consumer, manifest_digest, "consumed", consumed_at, diagnostic)
db.execute("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(generation, manifest["run_id"], manifest["producer"]["peer_id"], consumer,
manifest["parent_generation_id"], manifest_digest, consumed_at, "consumed", "sqlite-commit-full", diagnostic,
canonical(ack).decode()))
db.execute("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(ack["ack_id"], generation, ack["run_id"], ack["producer_peer_id"], consumer,
manifest_digest, "consumed", ack["durability"], consumed_at, diagnostic,
ack["payload_sha256"]))
return ack
def record_rejected(database: Path, manifest: dict, manifest_digest: str, diagnostic: str) -> dict:
diagnostic = diagnostic.encode("utf-8")[:MAX_DIAGNOSTIC].decode("utf-8", "ignore")
with closing(connect_database(database)) as db:
require_schema(db); db.execute("BEGIN IMMEDIATE")
consumer = peer_identity(db, "desktop"); rejected_at = now()
ack = ack_document(manifest["run_id"], manifest["generation_id"], manifest["producer"]["peer_id"], consumer, manifest_digest, "rejected", rejected_at, diagnostic)
db.execute("INSERT OR IGNORE INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)", (manifest["generation_id"],manifest["run_id"],manifest["producer"]["peer_id"],consumer,manifest["parent_generation_id"],manifest_digest,rejected_at,"rejected",ack["durability"],diagnostic,canonical(ack).decode()))
db.execute("INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)", (ack["ack_id"],manifest["generation_id"],manifest["run_id"],manifest["producer"]["peer_id"],consumer,manifest_digest,"rejected",ack["durability"],rejected_at,diagnostic,ack["payload_sha256"]))
db.commit(); return ack
def consume_desktop(database: Path, directory: Path) -> dict:
"""Consume one complete Android generation in one desktop transaction."""
with closing(connect_database(database)) as identity_db:
consumer = peer_identity(identity_db, "desktop")
identity_db.commit()
manifest, manifest_digest = validate_published(directory, consumer)
listed = {item["logical_name"]: directory / PurePosixPath(item["filename"]).name
for item in manifest["artifacts"]}
def required(name):
try:
return listed[name]
except KeyError as error:
raise GenerationError("missing required domain: " + name) from error
# Parse and domain-validate bounded bytes before opening the write scope.
causal = causal_delete_exchange.load(required("causal-deletions"))
drafts = execution_draft_exchange.load(required("execution-drafts"))
aliases = import_exercise_aliases.load(required("exercise-aliases"))
profile = strict_json(required("exercise-profile-state").read_bytes(), MAX_ARTIFACT)
equipment_payload = strict_json(required("equipment-definitions").read_bytes(), MAX_ARTIFACT)
reserved = import_equipment_definitions.supplied_ids(ROOT / "catalog/equipment-v1.json")
definitions = import_equipment_definitions.validate(equipment_payload, reserved)
history = import_mobile_export.load_payload(required("history"))
known_equipment = set(reserved) | {item[0] for item in definitions}
import_mobile_export.validate_payload(history, known_equipment)
association_payload = strict_json(required("equipment-associations").read_bytes(), MAX_ARTIFACT)
zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
feedback = import_training_feedback.load(required("feedback"))
mobile_occurrences = import_equipment_associations.load_mobile_occurrences(required("history"))
with closing(connect_database(database)) as db:
require_schema(db)
db.execute("PRAGMA foreign_keys=ON")
db.execute("BEGIN IMMEDIATE")
try:
known = db.execute("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?", (manifest["generation_id"],)).fetchone()
if known:
if known[0] != manifest_digest:
raise GenerationError("generation identity reused with different manifest")
db.rollback()
return json.loads(known[1])
predecessors = db.execute("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2", (manifest["producer"]["peer_id"],)).fetchall()
if len(predecessors) > 1: raise GenerationError("consumed generation lineage has multiple tips")
expected_parent = predecessors[0][0] if predecessors else None
if manifest["parent_generation_id"] != expected_parent:
raise GenerationError("generation lineage is stale or unrelated")
# Causal state is applied first. Complete-envelope helpers retain
# item-level conflict rules while avoiding the legacy global guard.
for operation in causal["operations"]:
causal_delete_exchange.apply_operation(db, operation)
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True)
import_mobile_export.apply_payload(db, history)
import_exercise_profile_state.apply_profile_state(db, profile)
import_exercise_aliases.apply_aliases(db, aliases)
import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences)
import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True)
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
ack = record_consumed(db, manifest, manifest_digest)
db.commit()
return ack
except Exception as error:
db.rollback()
if isinstance(error, sqlite3.Error):
raise
return record_rejected(database, manifest, manifest_digest, str(error))
def validate_ack(raw: bytes, pending: sqlite3.Row | tuple) -> dict:
value = strict_json(raw, MAX_MANIFEST)
keys = {"format", "version", "ack_id", "run_id", "generation_id", "producer_peer_id",
"consumer_peer_id", "manifest_sha256", "result", "durability", "consumed_at",
"diagnostic", "payload_sha256"}
if not isinstance(value, dict) or set(value) != keys or value["format"] != ACK or type(value["version"]) is not int or value["version"] != 1:
raise GenerationError("invalid ACK")
if not valid_id(value["generation_id"], "gen") or value["ack_id"] != "ack_" + value["generation_id"][4:] or not valid_id(value["run_id"], "sy") or not valid_id(value["producer_peer_id"], "peer") or not valid_id(value["consumer_peer_id"], "peer") or not isinstance(value["manifest_sha256"], str) or not HEX.fullmatch(value["manifest_sha256"]) or not isinstance(value["diagnostic"], str):
raise GenerationError("invalid ACK identity/field")
try:
parsed = dt.datetime.fromisoformat(value["consumed_at"].replace("Z", "+00:00"))
if parsed.utcoffset() is None: raise ValueError
except Exception as error:
raise GenerationError("invalid ACK timestamp") from error
checksum = value["payload_sha256"]
payload = dict(value); del payload["payload_sha256"]
if not HEX.fullmatch(checksum or "") or digest_bytes(canonical(payload)) != checksum:
raise GenerationError("ACK digest mismatch")
expected = (pending[1], pending[0], pending[2], pending[3], pending[4])
actual = (value["run_id"], value["generation_id"], value["producer_peer_id"],
value["consumer_peer_id"], value["manifest_sha256"])
expected_durability = "sqlite-commit-full" if value["result"] == "consumed" else "sqlite-commit-rejection"
if actual != expected or value["result"] not in ("consumed", "rejected") or value["durability"] != expected_durability or len(value["diagnostic"].encode()) > MAX_DIAGNOSTIC:
raise GenerationError("ACK does not match pending generation")
return value
def accept_ack(database: Path, path: Path) -> str:
raw = path.read_bytes()
with closing(connect_database(database)) as db:
require_schema(db); db.execute("BEGIN IMMEDIATE")
value = strict_json(raw, MAX_MANIFEST)
row = db.execute("SELECT generation_id,run_id,producer_peer_id,consumer_peer_id,manifest_sha256,status FROM sync_generations WHERE generation_id=?", (value.get("generation_id"),)).fetchone()
if not row:
raise GenerationError("ACK has no pending generation")
ack = validate_ack(raw, row)
status = "acknowledged" if ack["result"] == "consumed" else "rejected"
if row[5] in ("acknowledged", "rejected"):
if row[5] != status:
raise GenerationError("conflicting ACK replay")
db.rollback(); return "unchanged"
if row[5] != "waiting_acknowledgement":
raise GenerationError("generation is not awaiting ACK")
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
(status, ack["consumed_at"], ack["generation_id"]))
db.commit(); return status
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="mode", required=True)
capture = sub.add_parser("capture-desktop"); capture.add_argument("--database", type=Path, required=True); capture.add_argument("--owned-root", type=Path, required=True); capture.add_argument("--consumer-peer", required=True); capture.add_argument("--run-id"); capture.add_argument("--generation-id")
publication = sub.add_parser("publish"); publication.add_argument("--database", type=Path, required=True); publication.add_argument("--generation-id", required=True); publication.add_argument("--object-root", type=Path, required=True)
validate = sub.add_parser("validate"); validate.add_argument("directory", type=Path); validate.add_argument("--consumer-peer", required=True)
consume = sub.add_parser("consume-desktop"); consume.add_argument("directory", type=Path); consume.add_argument("--database", type=Path, required=True); consume.add_argument("--ack-output", type=Path, required=True)
peer = sub.add_parser("peer-id"); peer.add_argument("--database", type=Path, required=True); peer.add_argument("--kind", choices=("desktop",), default="desktop")
accept = sub.add_parser("accept-ack"); accept.add_argument("ack", type=Path); accept.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
if args.mode == "capture-desktop":
stage, manifest, checksum = capture_desktop(args.database, args.owned_root,
args.consumer_peer, args.run_id, args.generation_id)
print(f"GENERATION_CAPTURE=PASS generation_id={manifest['generation_id']} manifest_sha256={checksum} path={stage}")
elif args.mode == "publish":
print("GENERATION_PUBLISH=PASS path=" + str(publish(args.database, args.generation_id, args.object_root)))
elif args.mode == "validate":
manifest, checksum = validate_published(args.directory, args.consumer_peer)
print(f"GENERATION_VALIDATE=PASS generation_id={manifest['generation_id']} manifest_sha256={checksum}")
elif args.mode == "consume-desktop":
ack = consume_desktop(args.database, args.directory)
args.ack_output.write_bytes(canonical(ack))
print("GENERATION_CONSUME=PASS generation_id=" + ack["generation_id"])
elif args.mode == "peer-id":
with closing(connect_database(args.database)) as db:
value = peer_identity(db, args.kind); db.commit()
print("PEER_ID=" + value)
else:
print("ACK_ACCEPT=" + accept_ack(args.database, args.ack))
if __name__ == "__main__":
try:
main()
except Exception as error:
print("SYNC_GENERATION=FAIL " + str(error), file=sys.stderr)
raise SystemExit(1)

View file

@ -16,7 +16,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 20
#define TRAINLOG_DATABASE_SCHEMA_VERSION 21
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -236,6 +236,12 @@ trainlog_core_dep = declare_dependency(
],
)
executable(
'sync-generation-fixture',
'tests/sync_generation_fixture.c',
dependencies: trainlog_core_dep,
)
trainlog_tui_sources = files(
'src/cli.c',
'src/main.c',
@ -718,6 +724,16 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_causal_delete_exchange.py'],
)
test(
'sync_generation_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120,
)
test(
'schema_v21_migration',
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),
)
test(
'equipment_associations_exchange',

View file

@ -856,6 +856,20 @@ static const char *const MIGRATE_V19_TO_V20_SQL =
"CREATE INDEX sync_causal_operations_target ON sync_causal_operations(target_kind,target_id);"
"PRAGMA user_version=20;COMMIT;";
/* WHY: publication and durable consumption are distinct from immutable domain
* operations. CONTRACT: v21 stores peer/generation/manifest/ACK identity and a
* separate operation-to-generation ledger. INVARIANT: migration invents no
* generation, acknowledgement, consumption, ancestry, or causal operation. */
static const char *const MIGRATE_V20_TO_V21_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN('desktop','android')));"
"CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,producer_kind TEXT NOT NULL,generated_at TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('captured','published','waiting_acknowledgement','acknowledged','rejected')),manifest_json TEXT NOT NULL,staging_path TEXT NOT NULL,acknowledged_at TEXT);"
"CREATE TABLE sync_generation_artifacts(generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE CASCADE,logical_name TEXT NOT NULL,format TEXT NOT NULL,version INTEGER NOT NULL,filename TEXT NOT NULL,size_bytes INTEGER NOT NULL,sha256 TEXT NOT NULL,required INTEGER NOT NULL CHECK(required IN(0,1)),PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));"
"CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,consumed_at TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,diagnostic TEXT NOT NULL,ack_json TEXT NOT NULL,UNIQUE(producer_peer_id,generation_id));"
"CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT NOT NULL,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,manifest_sha256 TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,created_at TEXT NOT NULL,diagnostic TEXT NOT NULL,payload_sha256 TEXT NOT NULL);"
"CREATE TABLE sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));"
"PRAGMA user_version=21;COMMIT;";
/* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */
@ -1551,7 +1565,7 @@ static TrainlogStatus initialize_or_validate_schema(
status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20) {
version == 19 || version == 20 || version == 21) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -1721,6 +1735,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 20) {
status = execute_sql(database, MIGRATE_V19_TO_V20_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 21) {
status = execute_sql(database, MIGRATE_V20_TO_V21_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -1736,7 +1753,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v20" : "migrate database to schema v20",
version == 0 ? "create schema v21" : "migrate database to schema v21",
database->connection,
SQLITE_ERROR
);

View file

@ -0,0 +1,47 @@
/* Test-only producer fixture through the public desktop persistence API. */
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
int main(int argc, char **argv)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput set = { .reps = 9, .has_weight = true, .weight_kg = 32.5 };
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
return 2;
}
if (trainlog_database_insert_exercise_profiled(
database, "ex_77777777-7777-4777-8777-777777777777",
"Desktop generation fixture", "desktop generation fixture",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 3;
}
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s",
"sxe_88888888-8888-4888-8888-888888888888");
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s",
"ex_77777777-7777-4777-8777-777777777777");
exercise.recording_mode = TRAINLOG_RECORDING_SETS;
exercise.tracking_mode = TRAINLOG_TRACKING_REPS;
exercise.sets = &set;
exercise.set_count = 1U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_99999999-9999-4999-8999-999999999999");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-17T10:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
"2026-09-17T10:30:00+02:00");
session.exercises = &exercise;
session.exercise_count = 1U;
if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 4;
}
trainlog_database_close(database);
(void)puts("SYNC_GENERATION_FIXTURE=PASS");
return 0;
}

View file

@ -0,0 +1,42 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "trainlog/database.h"
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK failed at %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0)
static int scalar(sqlite3 *database, const char *sql)
{
sqlite3_stmt *statement=NULL;int value=-1;
if(sqlite3_prepare_v2(database,sql,-1,&statement,NULL)==SQLITE_OK && sqlite3_step(statement)==SQLITE_ROW)value=sqlite3_column_int(statement,0);
(void)sqlite3_finalize(statement);return value;
}
int main(void)
{
char path[]="/tmp/trainlog-schema-v21-XXXXXX";
int fd=mkstemp(path);TrainlogDatabase *database=NULL;sqlite3 *raw=NULL;size_t exercises=0U;
CHECK(fd>=0);CHECK(close(fd)==0);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","Migration sentinel","migration sentinel",
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==21);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
return 0;
}

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v20: SQLite rc=",
strlen("migrate database to schema v20: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic, "migrate database to schema v21: SQLite rc=",
strlen("migrate database to schema v21: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=21;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=22;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 21 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 22 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v20") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v21") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);