fix(sync): close causal deletion guarantees

This commit is contained in:
fy59 2026-09-17 10:40:26 +02:00
parent 98cb40749f
commit 70cb7a116b
22 changed files with 501 additions and 42 deletions

View file

@ -2,6 +2,15 @@
## Unreleased — 0.1.2
- Closed the causal-deletion correctness review: deletion predecessors now
cover affected session children, measurements, links, notes and immutable
feedback revisions; supported corrections preserve an intervening mutation
marker even after a content revert. Imported operations share built-in
authorization with local deletion, execution drafts use their stable
cross-platform revision identity, protected legacy companions refuse unsafe
replay, and causal artifact reads/exports enforce count and byte bounds
before destructive effects.
- Completed `TRAINLOG_SYNC_CAUSAL_DELETE_V1` with desktop schema v20 and
Android schema v19. Added a strict bounded staged causal-deletion artifact,
durable operation/state records, domain-specific deletion effects, causal

View file

@ -1223,6 +1223,7 @@ class TrainlogRepository(
* 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." }
val exercises = JSONArray()
listExercises().sortedBy { it.exerciseId }.forEach { exercise ->
exercises.put(JSONObject()
@ -1422,6 +1423,8 @@ class TrainlogRepository(
}
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
if (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.")
}
@ -2404,6 +2407,8 @@ class TrainlogRepository(
return try {
db.beginTransaction()
insertCompletedSession(db, draft, sessionId, generalCorrection = true)
if (!markCausalMutation(db, "session", sessionId))
return CorrectCompletedSessionResult.DatabaseError("The session is causally deleted.")
db.setTransactionSuccessful()
CorrectCompletedSessionResult.Saved
} catch (error: Exception) {
@ -3342,11 +3347,11 @@ class TrainlogRepository(
.joinToString("") { "%02x".format(it) }
private fun causalSnapshot(db: SQLiteDatabase, kind: String, targetId: String): JSONArray? {
if (kind == "session") return causalSessionSnapshot(db, targetId)
val query = when (kind) {
"session" -> Pair("SELECT session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(targetId))
"execution_draft" -> Pair("SELECT session_id,revision_id,parent_revision_id,payload_json FROM execution_draft_candidates WHERE session_id=? UNION ALL SELECT session_id,revision_id,parent_revision_id,'' FROM active_session_draft WHERE session_id=?", arrayOf(targetId, targetId))
"execution_draft" -> Pair("SELECT session_id,revision_id FROM execution_draft_candidates WHERE session_id=? UNION ALL SELECT session_id,revision_id FROM active_session_draft WHERE session_id=?", arrayOf(targetId, targetId))
"exercise" -> Pair("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", arrayOf(targetId))
"body_observation" -> Pair("SELECT observation_id,observed_at,notes FROM body_observations WHERE observation_id=?", arrayOf(targetId))
"body_observation" -> Pair("SELECT bo.observation_id,bo.observed_at,bo.body_weight_kg,bo.neck_cm,bo.shoulders_cm,bo.chest_cm,bo.waist_cm,bo.hips_cm,bo.left_arm_cm,bo.right_arm_cm,bo.left_forearm_cm,bo.right_forearm_cm,bo.left_thigh_cm,bo.right_thigh_cm,bo.left_calf_cm,bo.right_calf_cm,bo.notes,s.session_id,(SELECT revision_id FROM sync_note_state WHERE owner_kind='observation' AND owner_id=bo.observation_id) FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id WHERE bo.observation_id=?", arrayOf(targetId))
"custom_equipment" -> Pair("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM equipment WHERE equipment_id=? AND equipment_type='custom_machine'", arrayOf(targetId))
"feedback" -> Pair("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", arrayOf(targetId))
"body_zone_relation" -> {
@ -3355,7 +3360,7 @@ class TrainlogRepository(
}
else -> return null
}
return db.rawQuery(query.first, query.second).use { cursor ->
val root = db.rawQuery(query.first, query.second).use { cursor ->
if (!cursor.moveToFirst()) null else JSONArray().also { result ->
repeat(cursor.columnCount) { index ->
result.put(if (cursor.isNull(index)) JSONObject.NULL else when (cursor.getType(index)) {
@ -3366,11 +3371,83 @@ class TrainlogRepository(
}
}
}
if (kind == "feedback" && root != null) {
val revisions = JSONArray()
db.rawQuery("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", arrayOf(targetId)).use { cursor ->
while (cursor.moveToNext()) revisions.put(causalCursorRow(cursor))
}
root.put(revisions)
}
return root
}
private fun causalCursorRow(cursor: Cursor): JSONArray = JSONArray().also { row ->
repeat(cursor.columnCount) { index -> row.put(if (cursor.isNull(index)) JSONObject.NULL else when (cursor.getType(index)) {
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index)
Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index)
else -> cursor.getString(index)
}) }
}
private fun causalSessionSnapshot(db: SQLiteDatabase, sessionId: String): JSONArray? {
val header = db.rawQuery("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { cursor ->
if (!cursor.moveToFirst()) null else Pair(cursor.getLong(0), causalCursorRow(cursor).also { it.remove(0) })
} ?: return null
val occurrences = JSONArray()
db.rawQuery("SELECT se.id,se.entry_id,e.exercise_id,se.position,se.recording_mode,se.tracking_mode,se.data_fields,eq.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.notes FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id WHERE se.session_row_id=? ORDER BY se.position,se.entry_id", arrayOf(header.first.toString())).use { cursor ->
while (cursor.moveToNext()) {
val rowId = cursor.getLong(0); val occurrence = causalCursorRow(cursor).also { it.remove(0) }
val sets = JSONArray()
db.rawQuery("SELECT position,reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", arrayOf(rowId.toString())).use { values -> while (values.moveToNext()) sets.put(causalCursorRow(values)) }
val continuous = db.rawQuery("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", arrayOf(rowId.toString())).use { if (it.moveToFirst()) causalCursorRow(it) else JSONObject.NULL }
val maximum = db.rawQuery("SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?", arrayOf(rowId.toString())).use { if (it.moveToFirst()) it.getDouble(0) else JSONObject.NULL }
val feedback = JSONArray()
db.rawQuery("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE session_exercise_row_id=? ORDER BY observed_at,feedback_id", arrayOf(rowId.toString())).use { roots -> while (roots.moveToNext()) {
val item = causalCursorRow(roots); val revisions = JSONArray()
db.rawQuery("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", arrayOf(roots.getString(0))).use { values -> while (values.moveToNext()) revisions.put(causalCursorRow(values)) }
item.put(revisions); feedback.put(item)
} }
val note = db.rawQuery("SELECT revision_id FROM sync_note_state WHERE owner_kind='occurrence' AND owner_id=?", arrayOf(cursor.getString(1))).use { if (it.moveToFirst()) it.getString(0) else JSONObject.NULL }
occurrence.put(sets).put(continuous).put(maximum).put(feedback).put(note); occurrences.put(occurrence)
}
}
val sessionNote = db.rawQuery("SELECT revision_id FROM sync_note_state WHERE owner_kind='session' AND owner_id=?", arrayOf(sessionId)).use { if (it.moveToFirst()) it.getString(0) else JSONObject.NULL }
val links = JSONArray()
db.rawQuery("SELECT observation_id FROM body_observations WHERE session_row_id=? ORDER BY observation_id", arrayOf(header.first.toString())).use { while (it.moveToNext()) links.put(it.getString(0)) }
return header.second.put(occurrences).put(sessionNote).put(links)
}
private fun causalLiveRevision(db: SQLiteDatabase, kind: String, targetId: String): String? =
causalSnapshot(db, kind, targetId)?.let { "lv_${causalDigest(it)}" }
private fun hasCausalProtection(kind: String): Boolean = database.readableDatabase.rawQuery(
"SELECT 1 FROM sync_causal_state WHERE target_kind=? AND deleted=1 LIMIT 1", arrayOf(kind),
).use { it.moveToFirst() }
/* WHY: a supported edit followed by a byte-identical revert is still an
* intervening causal mutation. CONTRACT: writers advance this revision in
* their business transaction. INVARIANT: deleted state is never revived. */
private fun markCausalMutation(db: SQLiteDatabase, kind: String, targetId: String): Boolean {
val deleted = db.rawQuery("SELECT deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, targetId)).use { it.moveToFirst() && it.getInt(0) != 0 }
if (deleted) return false
db.insertWithOnConflict("sync_causal_state", null, ContentValues().apply {
put("target_kind", kind); put("target_id", targetId)
put("current_revision_id", "mu_${UUID.randomUUID()}"); put("deleted", 0); putNull("operation_id")
}, SQLiteDatabase.CONFLICT_REPLACE)
return true
}
private fun causalTargetAuthorized(db: SQLiteDatabase, kind: String, targetId: String): Boolean {
if (kind == "exercise") {
val canonical = resolveExerciseId(db, targetId)
if (trainingKnowledge.getExerciseKnowledge(targetId) != null ||
trainingKnowledge.getExerciseKnowledge(canonical) != null) return false
}
if (kind == "custom_equipment" && trainingKnowledge.getEquipmentKnowledge(targetId) != null)
return false
return true
}
private fun applyCausalEffect(db: SQLiteDatabase, kind: String, targetId: String, operationId: String) {
when (kind) {
"session" -> {
@ -3400,7 +3477,8 @@ class TrainlogRepository(
private fun validateCausalOperation(value: JSONObject): Boolean {
val keys = setOf("operation_id", "target_kind", "target_id", "creator_id",
"predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
if (!value.hasExactKeys(keys) || value.getString("target_kind") !in causalKinds ||
if (!value.hasExactKeys(keys) || keys.filter { it != "publication_context" }.any { value.value(it) !is String } ||
value.getString("target_kind") !in causalKinds ||
!value.isNull("publication_context") || TrainlogTimestamp.parse(value.getString("created_at")) == null) return false
for ((key, maximum) in listOf("operation_id" to 128, "target_id" to 512,
"creator_id" to 128, "predecessor_revision_id" to 128)) {
@ -3421,17 +3499,22 @@ class TrainlogRepository(
val expected = listOf("target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256").map(operation::getString)
if (known != null) return if (known == expected) CausalDeleteResult.Unchanged(operationId) else CausalDeleteResult.Conflict(operation.getString("target_id"))
val kind = operation.getString("target_kind"); val target = operation.getString("target_id")
if (!causalTargetAuthorized(db, kind, target)) return CausalDeleteResult.Invalid
val state = db.rawQuery("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, target)).use {
if (!it.moveToFirst()) null else Pair(it.getString(0), it.getInt(1) != 0)
}
if (state?.second == true) return CausalDeleteResult.Conflict(target)
val current = state?.first ?: causalLiveRevision(db, kind, target) ?: return CausalDeleteResult.NotFound
if (current != operation.getString("predecessor_revision_id")) return CausalDeleteResult.Conflict(target)
val count = db.rawQuery("SELECT COUNT(*) FROM sync_causal_operations", null).use { it.moveToFirst(); it.getInt(0) }
if (count >= MAX_CAUSAL_OPERATIONS) return CausalDeleteResult.Invalid
db.insertOrThrow("sync_causal_operations", null, ContentValues().apply {
put("operation_id", operationId); put("target_kind", kind); put("target_id", target)
put("creator_id", operation.getString("creator_id")); put("predecessor_revision_id", current)
put("created_at", operation.getString("created_at")); put("payload_sha256", operation.getString("payload_sha256"))
})
if (buildCausalDeletionExportV1Json(db).toByteArray(StandardCharsets.UTF_8).size > MAX_CAUSAL_ARTIFACT_BYTES)
return CausalDeleteResult.Invalid
applyCausalEffect(db, kind, target, operationId)
db.insertWithOnConflict("sync_causal_state", null, ContentValues().apply {
put("target_kind", kind); put("target_id", target); put("current_revision_id", operationId)
@ -3443,12 +3526,15 @@ class TrainlogRepository(
/** Create one durable local deletion without selecting any transport. */
fun deleteCausally(kind: String, targetId: String, creatorId: String): CausalDeleteResult {
if (kind !in causalKinds || targetId.isBlank() || creatorId.isBlank()) return CausalDeleteResult.Invalid
if (kind == "exercise" && trainingKnowledge.getExerciseKnowledge(targetId) != null)
return CausalDeleteResult.Invalid
val db = database.writableDatabase
return try {
db.beginTransaction()
val predecessor = causalLiveRevision(db, kind, targetId)
if (!causalTargetAuthorized(db, kind, targetId)) return CausalDeleteResult.Invalid
val state = db.rawQuery("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, targetId)).use {
if (it.moveToFirst()) Pair(it.getString(0), it.getInt(1) != 0) else null
}
if (state?.second == true) return CausalDeleteResult.Unchanged(state.first)
val predecessor = if (state != null && !state.second) state.first else causalLiveRevision(db, kind, targetId)
if (predecessor == null) {
val existing = db.rawQuery("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", arrayOf(kind, targetId)).use { if (it.moveToFirst()) it.getString(0) else null }
if (existing != null) return CausalDeleteResult.Unchanged(existing)
@ -3467,8 +3553,14 @@ class TrainlogRepository(
} finally { db.endTransaction() }
}
fun buildCausalDeletionExportV1Json(): String {
val db = database.readableDatabase
fun buildCausalDeletionExportV1Json(): String =
buildCausalDeletionExportV1Json(database.readableDatabase).also {
check(it.toByteArray(StandardCharsets.UTF_8).size <= MAX_CAUSAL_ARTIFACT_BYTES) {
"Causal protection set exceeds artifact byte bound."
}
}
private fun buildCausalDeletionExportV1Json(db: SQLiteDatabase): String {
val operations = JSONArray()
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations ORDER BY operation_id", null).use { cursor ->
while (cursor.moveToNext()) {
@ -3484,7 +3576,7 @@ class TrainlogRepository(
}
fun applyCausalDeletionExportV1Json(json: String): CausalDeleteResult {
if (json.toByteArray().size > 4 * 1024 * 1024 || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
if (json.toByteArray().size > MAX_CAUSAL_ARTIFACT_BYTES || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
val root = try { JSONObject(json) } catch (_: Exception) { return CausalDeleteResult.Invalid }
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "operations")) ||
root.optString("format") != "trainlog-causal-deletions" || !root.value("version").isJsonInt(1, 1) ||
@ -3538,11 +3630,17 @@ class TrainlogRepository(
fun linkBodyObservationToSession(observationId: String, sessionId: String?): Boolean {
val db = database.writableDatabase
val parent = if (sessionId == null) null else db.rawQuery("SELECT id FROM sessions WHERE session_id=?",
arrayOf(sessionId)).use { if (it.moveToFirst()) it.getLong(0) else return false }
return db.update("body_observations", ContentValues().apply {
if (parent == null) putNull("session_row_id") else put("session_row_id", parent)
}, "observation_id=?", arrayOf(observationId)) == 1
return try {
db.beginTransaction()
val parent = if (sessionId == null) null else db.rawQuery("SELECT id FROM sessions WHERE session_id=?",
arrayOf(sessionId)).use { if (it.moveToFirst()) it.getLong(0) else return false }
val changed = db.update("body_observations", ContentValues().apply {
if (parent == null) putNull("session_row_id") else put("session_row_id", parent)
}, "observation_id=?", arrayOf(observationId)) == 1
if (!changed || !markCausalMutation(db, "body_observation", observationId)) return false
db.setTransactionSuccessful(); true
} catch (_: Exception) { false
} finally { if (db.inTransaction()) db.endTransaction() }
}
private fun syncNotePayload(
@ -4829,6 +4927,7 @@ 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." }
val supplied = EquipmentCatalog.load(applicationContext).map { it.equipmentId }.toSet()
val items = JSONArray()
listEquipment().filter { it.equipmentId !in supplied }.forEach { entry ->
@ -4859,6 +4958,8 @@ class TrainlogRepository(
}
fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult {
if (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.")
}
@ -5640,7 +5741,8 @@ class TrainlogRepository(
"(SELECT COUNT(*) FROM exercise_feedback_revisions r WHERE r.feedback_id=f.feedback_id) " +
"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 " +
"WHERE s.session_id=?;", arrayOf(sessionId),
"WHERE s.session_id=? AND NOT EXISTS(SELECT 1 FROM sync_causal_state cs " +
"WHERE cs.target_kind='feedback' AND cs.target_id=f.feedback_id AND cs.deleted=1);", arrayOf(sessionId),
).use { c -> while (c.moveToNext()) add(ExerciseFeedback(c.getString(0),c.getString(1),c.getString(2),c.getString(3),c.getString(4),c.getString(5),c.getInt(6)>1)) }
}.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!)
.takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.feedbackId,b.feedbackId) }
@ -5659,6 +5761,7 @@ class TrainlogRepository(
/** Direction-neutral V2 companion: roots and every immutable revision. */
fun buildTrainingFeedbackJson(): String {
check(!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()
@ -5696,6 +5799,8 @@ class TrainlogRepository(
}
fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult {
if (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.")
val root = try { JSONObject(json) } catch (_: Exception) {
@ -7689,6 +7794,7 @@ private const val MAX_SYNC_NOTE_UTF8_BYTES = 4096
/* The wire document is bounded to sixteen drafts total, including the singleton active draft. */
private const val MAX_PENDING_EXECUTION_DRAFTS = 15
private const val MAX_CAUSAL_OPERATIONS = 4096
private const val MAX_CAUSAL_ARTIFACT_BYTES = 4 * 1024 * 1024
private const val MAX_PLAN_SETS = 64
private const val MAX_PLAN_REPS = 10000
private const val MAX_PLAN_DURATION_SECONDS = 86400

View file

@ -38,6 +38,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
@ -682,6 +683,20 @@ class TrainlogRepositoryDraftTest {
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONObject(live.readText()).getJSONArray("exercises"))
val conflictName = "delete-conflict-${UUID.randomUUID()}.db"
val conflict = TrainlogRepository(context, conflictName)
assertTrue(conflict.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(conflict.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
val conflictExercise = conflict.listExercises().first { it.exerciseId == exercise.exerciseId }
fun corrected(reps: Int) = SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_60000000-0000-4000-8000-000000000001",
exercise = conflictExercise, sets = listOf(SessionSetDraft(reps = reps)),
)))
assertTrue(conflict.correctCompletedSession(sessionId, corrected(10)) is CorrectCompletedSessionResult.Saved)
assertTrue(conflict.correctCompletedSession(sessionId, corrected(9)) is CorrectCompletedSessionResult.Saved)
assertTrue(conflict.applyCausalDeletionExportV1Json(androidCausal.readText()) is CausalDeleteResult.Conflict)
assertEquals(1, conflict.listSessions().size)
conflict.close(); context.deleteDatabase(conflictName)
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Applied)
@ -724,6 +739,80 @@ class TrainlogRepositoryDraftTest {
}
}
@Test
fun causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas() {
val sourceName = "causal-draft-source-${UUID.randomUUID()}.db"
val destinationName = "causal-draft-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-causal-draft-").toFile()
try {
val exercise = createExercise(source, "Causal draft bridge", RecordingMode.SETS, TrackingMode.REPS)
assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = "sxe_61000000-0000-4000-8000-000000000001",
exercise = exercise, plan = SessionExercisePlan(2, reps = 6),
sets = listOf(SessionSetDraft(reps = 6)),
)), form = SessionDraftForm(weightText = "local unfinished input")),
))
val live = File(root, "draft-live.json").apply { writeText(source.buildExecutionDraftExportV1Json()) }
val sessionId = JSONObject(live.readText()).getJSONArray("drafts").getJSONObject(0).getString("session_id")
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises"))
val desktop = File(root, "desktop.sqlite")
runLifecycleBridge("draft", live, desktop, File(root, "desktop-live.json"))
assertTrue(source.deleteCausally("execution_draft", sessionId, "peer_android_test") is CausalDeleteResult.Applied)
val androidDelete = File(root, "android-delete.json").apply { writeText(source.buildCausalDeletionExportV1Json()) }
runDesktopCausal("import", androidDelete, desktop)
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(destination.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
val desktopProducer = File(root, "desktop-producer.sqlite")
runLifecycleBridge("draft", live, desktopProducer, File(root, "desktop-producer-live.json"))
runDesktopCausal("delete", File(root, "desktop-operation.json"), desktopProducer,
"execution_draft", sessionId)
val desktopDelete = File(root, "desktop-delete.json")
runDesktopCausal("export", desktopDelete, desktopProducer)
assertTrue(destination.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Applied)
assertTrue(destination.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
destination.close()
val reopened = TrainlogRepository(context, destinationName)
assertTrue(reopened.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged)
assertTrue(reopened.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
reopened.close()
} finally {
try { source.close() } catch (_: Exception) { }
try { destination.close() } catch (_: Exception) { }
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun importedCausalOperationsCannotRetireBuiltInIdentities() {
val repo = openRepository()
val builtInId = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
assertNotNull(repo.getExerciseKnowledge(builtInId))
fun hostile(kind: String, target: String, predecessor: String): String {
val operation = JSONObject().put("operation_id", "del_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("target_kind", kind).put("target_id", target).put("creator_id", "hostile_fixture")
.put("predecessor_revision_id", predecessor).put("created_at", "2030-01-01T12:00:00Z")
.put("publication_context", JSONObject.NULL)
val encoded = canonicalJson(operation).toString()
operation.put("payload_sha256", MessageDigest.getInstance("SHA-256")
.digest(encoded.toByteArray()).joinToString("") { "%02x".format(it) })
return JSONObject().put("format", "trainlog-causal-deletions").put("version", 1)
.put("generated_at", "2030-01-01T12:00:00Z").put("operations", JSONArray().put(operation)).toString()
}
assertTrue(repo.deleteCausally("exercise", builtInId, "peer") is CausalDeleteResult.Invalid)
assertTrue(repo.applyCausalDeletionExportV1Json(hostile("exercise", builtInId, "lv_unknown")) is CausalDeleteResult.Invalid)
assertTrue(repo.applyCausalDeletionExportV1Json(hostile("custom_equipment", "rear_delt_pec_fly", "lv_unknown")) is CausalDeleteResult.Invalid)
val malformed = JSONObject(hostile("exercise", builtInId, "lv_unknown"))
malformed.getJSONArray("operations").getJSONObject(0).put("creator_id", 7)
assertTrue(repo.applyCausalDeletionExportV1Json(malformed.toString()) is CausalDeleteResult.Invalid)
assertEquals(0, JSONObject(repo.buildCausalDeletionExportV1Json()).getJSONArray("operations").length())
}
@Test
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
val repo = openRepository()

View file

@ -914,3 +914,10 @@ Android schema v19 additively stores the same immutable causal-deletion
operations and current deleted-target state as desktop v20. Existing data is
preserved and no migration-time ancestry is invented. Repository entry points
create, validate, apply, export, and import the staged causal artifact.
Completed-session correction and body-observation relinking advance durable
non-deleted causal state in their mutation transaction. Session deletion
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.

View file

@ -297,8 +297,14 @@ refused before resurrection. Active synchronization still selects V3: no
generation acknowledgement, 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.
The isolated closeout passed 76/76 normal Meson tests, 76/76 ASan/UBSan
tests, Android debug assembly, and 208 Android tests (204 passed, four optional
tests, Android debug assembly, and 210 Android tests (206 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.

View file

@ -57,6 +57,12 @@ v19→v20 migration creates empty protection tables and does not fabricate
ancestry for existing rows. Applying an operation, recording its protection,
and performing dependent changes share one transaction.
Non-deleted rows in `sync_causal_state` are also the durable revision boundary
for supported corrections whose content can later return to an earlier value.
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.
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

@ -1,5 +1,24 @@
# TRAINLOG_SYNC_GAP_CONTRACT_V1
## Causal deletion closeout implementation note
The staged causal-deletion implementation derives deletion admission from the
complete affected business projection, not only the parent row. Session
projection includes ordered occurrences, plans, confirmed facts, MAX,
occurrence/session note revisions, immutable feedback revisions and linked
observation identities. Observation projection includes every measurement,
its session identity and note revision. Feedback projection includes its full
immutable revision history. Supported corrections also persist a non-deleted
mutation revision, so a change followed by a content revert does not erase
evidence of the intervening edit. Unknown or incomparable ancestry conflicts;
timestamps never arbitrate it.
Execution-draft deletion uses `(session_id, revision_id)` on Android active and
pending storage and on desktop storage. Serialized JSON order, whitespace,
local row IDs and unfinished Android form strings are not causal identity.
Built-in exercise/equipment authorization is identical for local and imported
operations, including aliases that resolve to a built-in exercise.
Statut : `CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
Ce document définit la cible produit d'une synchronisation complète sans

View file

@ -659,3 +659,13 @@ 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.
`predecessor_revision_id` identifies either the canonical complete business
projection or a durable mutation revision created by a supported correction.
For execution drafts the projection is exactly the stable session and draft
revision identities; raw JSON serialization is not identity. Imported
operations undergo the same built-in/alias authorization as local operations.
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.

View file

@ -36,6 +36,12 @@ visualizations are implemented; the other four Web routes remain placeholders.
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_GENERATION_ACK_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_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
defines the complete-sync target and its bounded dependency order.
`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` supplies the reproducible isolated

View file

@ -9,6 +9,13 @@ rejects protected session, observation, or exercise identities before mutation.
Deleted execution-draft replay is stale. Tombstone-free V1/V2/V3 behavior is
unchanged.
Legacy feedback, BODY ZONES and custom-equipment companions are also refused
when their domain has deleted causal state. Retired exercises and custom
equipment remain resolvable for history but are excluded from new-work lists;
withdrawn feedback keeps its immutable revisions while disappearing from the
current feedback view. Profile and alias state cannot make a retired exercise
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,

View file

@ -1204,6 +1204,16 @@ pre-existing `-Werror=misleading-indentation` failure in unchanged Web baseline
sources; it is not reported as green and warning policy is unchanged.
The causal closeout full harness result is 76/76 normal Meson, 76/76
ASan/UBSan, and 208 Android tests: 204 passed and four optional historical
ASan/UBSan, and 210 Android tests: 206 passed and four optional historical
database fixtures skipped. `assembleDebug`, JSON validation, import-contract
validation, both frontend tests, and the isolated characterization all pass.
Focused closeout assertions are `test_child_mutations_invalidate_session_observation_and_feedback_deletes`,
`test_imported_builtin_and_alias_retirement_are_refused_without_writes`,
`test_draft_identity_is_semantic_revision_not_json_serialization`, and
`test_operation_count_and_input_byte_bounds_precede_effects` in the desktop
production-service suite. Android adds
`causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas` and
`importedCausalOperationsCannotRetireBuiltInIdentities`; the existing session
round trip now also changes and reverts a confirmed set before proving the
remote deletion conflicts without losing business or causal state.

View file

@ -18,17 +18,21 @@ SCHEMA = """
PRAGMA foreign_keys=ON;
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER);
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
CREATE TABLE max_results(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE,position INTEGER,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,equipment_id TEXT,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT,session_row_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT,label_name TEXT,equipment_type TEXT,load_semantics TEXT);
CREATE TABLE equipment(equipment_id TEXT PRIMARY KEY,equipment_type TEXT);
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);
CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,payload_json TEXT);
CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT,finalized_at TEXT);
CREATE TABLE sync_note_revisions(owner_kind TEXT,owner_id TEXT,revision_id TEXT,parent_revision_id TEXT,note TEXT,PRIMARY KEY(owner_kind,owner_id,revision_id));
CREATE TABLE sync_note_state(owner_kind TEXT,owner_id TEXT,revision_id TEXT,PRIMARY KEY(owner_kind,owner_id));
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_causal_state(target_kind TEXT,target_id TEXT,current_revision_id TEXT,deleted INTEGER,operation_id TEXT,PRIMARY KEY(target_kind,target_id));
PRAGMA user_version=20;
@ -41,8 +45,9 @@ class CausalDeleteTest(unittest.TestCase):
self.db = sqlite3.connect(self.path); self.db.executescript(SCHEMA)
self.db.execute("INSERT INTO exercises VALUES(1,'ex_user','User','sets','reps',0)")
self.db.execute("INSERT INTO sessions VALUES(1,'se_one','2030-01-01T10:00:00Z',NULL,'training','note')")
self.db.execute("INSERT INTO session_exercises VALUES(1,1,1,'sxe_one')")
self.db.execute("INSERT INTO body_observations VALUES(1,'bo_one','2030-01-01T11:00:00Z',1,'body')")
self.db.execute("INSERT INTO session_exercises VALUES(1,1,1,'sxe_one',0,'sets','reps',0,NULL,'external',60,1,8,NULL,32.5,'occurrence')")
self.db.execute("INSERT INTO performed_sets VALUES(1,1,0,8,NULL,32.5)")
self.db.execute("INSERT INTO body_observations VALUES(1,'bo_one','2030-01-01T11:00:00Z',80.0,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'body',1)")
self.db.execute("INSERT INTO custom_equipment VALUES('eq_one','Custom','','custom','external')")
self.db.execute("INSERT INTO exercise_feedback VALUES(1,'fb_one',1,'2030-01-01T10:30:00Z','root')")
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_one','fb_one','2030-01-01T10:30:00Z','root')")
@ -101,5 +106,67 @@ class CausalDeleteTest(unittest.TestCase):
with self.assertRaisesRegex(causal.CausalError, "built-in"):
causal.local_delete(self.db, "exercise", built_in, "peer")
def hostile_operation(self, kind, target):
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
"target_id": target, "creator_id": "hostile_fixture",
"predecessor_revision_id": causal.live_revision(self.db, kind, target) or "lv_unknown",
"created_at": "2030-01-01T12:00:00Z", "publication_context": None}
operation["payload_sha256"] = causal.digest(operation)
return operation
def test_imported_builtin_and_alias_retirement_are_refused_without_writes(self):
built_in = next(iter(causal.load_exercise_names()))
self.db.execute("INSERT INTO exercises VALUES(2,?,'Built in','sets','reps',0)", (built_in,))
self.db.execute("INSERT INTO equipment VALUES('barbell','barbell')")
alias = "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
self.db.execute("INSERT INTO exercises VALUES(3,?,'Alias row','sets','reps',0)", (alias,))
self.db.execute("INSERT INTO exercise_aliases VALUES(?,?)", (alias, built_in))
self.db.commit()
for operation in (self.hostile_operation("exercise", built_in),
self.hostile_operation("exercise", alias),
self.hostile_operation("custom_equipment", "barbell")):
with self.assertRaisesRegex(causal.CausalError, "built-in"):
causal.apply_operation(self.db, operation)
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0])
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_state").fetchone()[0])
def test_child_mutations_invalidate_session_observation_and_feedback_deletes(self):
session = self.hostile_operation("session", "se_one")
self.db.execute("UPDATE performed_sets SET weight_kg=35.0 WHERE id=1")
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
causal.apply_operation(self.db, session)
self.assertEqual(35.0, self.db.execute("SELECT weight_kg FROM performed_sets WHERE id=1").fetchone()[0])
observation = self.hostile_operation("body_observation", "bo_one")
self.db.execute("UPDATE body_observations SET body_weight_kg=79.5,session_row_id=NULL WHERE observation_id='bo_one'")
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
causal.apply_operation(self.db, observation)
feedback = self.hostile_operation("feedback", "fb_one")
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_two','fb_one','2030-01-02T10:30:00Z','revised')")
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
causal.apply_operation(self.db, feedback)
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0])
def test_draft_identity_is_semantic_revision_not_json_serialization(self):
first = causal.live_revision(self.db, "execution_draft", "se_draft")
self.db.execute("UPDATE execution_drafts SET payload_json=? WHERE session_id='se_draft'", ('{ "b": 2, "a": 1 }',))
self.assertEqual(first, causal.live_revision(self.db, "execution_draft", "se_draft"))
self.db.execute("UPDATE execution_drafts SET revision_id='dr_two' WHERE session_id='se_draft'")
self.assertNotEqual(first, causal.live_revision(self.db, "execution_draft", "se_draft"))
def test_operation_count_and_input_byte_bounds_precede_effects(self):
row = ("exercise", "x", "peer", "lv_x", "2030-01-01T12:00:00Z", "0" * 64, None)
self.db.executemany("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)",
[(f"op_{index:04d}", *row) for index in range(causal.MAX_OPERATIONS)])
self.db.commit()
document = causal.export_document(self.db)
self.assertEqual(causal.MAX_OPERATIONS, len(document["operations"]))
self.db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)", ("op_over", *row)); self.db.commit()
with self.assertRaisesRegex(causal.CausalError, "artifact bound"):
causal.export_document(self.db)
oversized = Path(self.tmp.name) / "oversized.json"
oversized.write_bytes(b" " * (causal.MAX_BYTES + 1))
with self.assertRaisesRegex(causal.CausalError, "4 MiB"):
causal.load(oversized)
if __name__ == "__main__": unittest.main()

View file

@ -44,17 +44,55 @@ def require_schema(db: sqlite3.Connection) -> None:
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
if kind == "session":
row = db.execute("SELECT session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", (target,)).fetchone()
row = db.execute("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", (target,)).fetchone()
if row is None:
return None
session_row = row[0]
occurrences = []
for occurrence in db.execute(
"SELECT se.id,se.entry_id,e.exercise_id,se.position,se.recording_mode,se.tracking_mode,se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.notes "
"FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position,se.entry_id", (session_row,)
):
occurrence_row = occurrence[0]
sets = [list(value) for value in db.execute(
"SELECT position,reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", (occurrence_row,))]
continuous = db.execute(
"SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (occurrence_row,)).fetchone()
maximum = db.execute(
"SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?", (occurrence_row,)).fetchone()
feedback = []
for root in db.execute(
"SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE session_exercise_row_id=? ORDER BY observed_at,feedback_id", (occurrence_row,)):
revisions = [list(value) for value in db.execute(
"SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", (root[0],))]
feedback.append([*root, revisions])
occurrence_note = db.execute(
"SELECT revision_id FROM sync_note_state WHERE owner_kind='occurrence' AND owner_id=?", (occurrence[1],)).fetchone()
occurrences.append([*occurrence[1:], sets,
None if continuous is None else list(continuous),
None if maximum is None else maximum[0], feedback,
None if occurrence_note is None else occurrence_note[0]])
session_note = db.execute(
"SELECT revision_id FROM sync_note_state WHERE owner_kind='session' AND owner_id=?", (target,)).fetchone()
links = [value[0] for value in db.execute(
"SELECT observation_id FROM body_observations WHERE session_row_id=? ORDER BY observation_id", (session_row,))]
return [*row[1:], occurrences, None if session_note is None else session_note[0], links]
elif kind == "execution_draft":
row = db.execute("SELECT session_id,revision_id,parent_revision_id,payload_json FROM execution_drafts WHERE session_id=?", (target,)).fetchone()
row = db.execute("SELECT session_id,revision_id FROM execution_drafts WHERE session_id=?", (target,)).fetchone()
elif kind == "exercise":
row = db.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", (target,)).fetchone()
elif kind == "body_observation":
row = db.execute("SELECT observation_id,observed_at,notes FROM body_observations WHERE observation_id=?", (target,)).fetchone()
row = db.execute("SELECT bo.observation_id,bo.observed_at,bo.body_weight_kg,bo.neck_cm,bo.shoulders_cm,bo.chest_cm,bo.waist_cm,bo.hips_cm,bo.left_arm_cm,bo.right_arm_cm,bo.left_forearm_cm,bo.right_forearm_cm,bo.left_thigh_cm,bo.right_thigh_cm,bo.left_calf_cm,bo.right_calf_cm,bo.notes,s.session_id FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id WHERE bo.observation_id=?", (target,)).fetchone()
if row is not None:
note = db.execute("SELECT revision_id FROM sync_note_state WHERE owner_kind='observation' AND owner_id=?", (target,)).fetchone()
return [*row, None if note is None else note[0]]
elif kind == "custom_equipment":
row = db.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (target,)).fetchone()
elif kind == "feedback":
row = db.execute("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", (target,)).fetchone()
if row is not None:
revisions = [list(value) for value in db.execute("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", (target,))]
return [*row, revisions]
else:
parts = target.split("|")
if len(parts) != 3:
@ -110,13 +148,13 @@ def validate_operation(value: object) -> dict:
def apply_operation(db: sqlite3.Connection, operation: dict) -> str:
op = validate_operation(operation)
identity = op["operation_id"]
encoded = canonical(op)
known = db.execute("SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (identity,)).fetchone()
expected = (op["target_kind"], op["target_id"], op["creator_id"], op["predecessor_revision_id"], op["created_at"], op["payload_sha256"], None)
if known:
if tuple(known) != expected:
raise CausalError("operation identity reused with different content")
return "unchanged"
authorize_target(db, op["target_kind"], op["target_id"])
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (op["target_kind"], op["target_id"])).fetchone()
current = state[0] if state else live_revision(db, op["target_kind"], op["target_id"])
if state and state[1]:
@ -125,19 +163,42 @@ def apply_operation(db: sqlite3.Connection, operation: dict) -> str:
raise CausalError("unknown target or ancestry")
if current != op["predecessor_revision_id"]:
raise CausalError("causal predecessor conflict")
count = db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0]
if count >= MAX_OPERATIONS:
raise CausalError("protection set exceeds operation bound")
db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,NULL)",
(identity, op["target_kind"], op["target_id"], op["creator_id"],
op["predecessor_revision_id"], op["created_at"], op["payload_sha256"]))
if len(canonical(export_document(db)).encode("utf-8")) > MAX_BYTES:
raise CausalError("protection set exceeds artifact byte bound")
apply_effect(db, op["target_kind"], op["target_id"], identity)
db.execute("INSERT OR REPLACE INTO sync_causal_state VALUES(?,?,?,?,?)",
(op["target_kind"], op["target_id"], identity, 1, identity))
return "applied"
def authorize_target(db: sqlite3.Connection, kind: str, target: str) -> None:
if kind == "exercise":
canonical = db.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (target,)).fetchone() if _table_exists(db, "exercise_aliases") else None
if target in load_exercise_names() or canonical is not None and canonical[0] in load_exercise_names():
raise CausalError("built-in exercise identity cannot be retired")
if kind == "custom_equipment" and _table_exists(db, "equipment") and db.execute(
"SELECT 1 FROM equipment WHERE equipment_id=? AND equipment_type<>'custom_machine'", (target,)).fetchone():
raise CausalError("built-in equipment identity cannot be retired")
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
return db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() is not None
def local_delete(db: sqlite3.Connection, kind: str, target: str, creator: str) -> tuple[dict, str]:
if kind == "exercise" and target in load_exercise_names():
raise CausalError("built-in exercise identity cannot be retired")
current = live_revision(db, kind, target)
authorize_target(db, kind, target)
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (kind, target)).fetchone()
if state and state[1]:
row = db.execute("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (state[0],)).fetchone()
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
return dict(zip(keys, row)), "unchanged"
current = state[0] if state and not state[1] else live_revision(db, kind, target)
if current is None:
state = db.execute("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", (kind, target)).fetchone()
if state:
@ -155,7 +216,8 @@ def local_delete(db: sqlite3.Connection, kind: str, target: str, creator: str) -
def load(path: Path) -> dict:
raw = path.read_bytes()
with path.open("rb") as source:
raw = source.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise CausalError("artifact exceeds 4 MiB")
def unique(pairs):
@ -181,11 +243,14 @@ def load(path: Path) -> dict:
def export_document(db: sqlite3.Connection) -> dict:
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id").fetchall()
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,)).fetchall()
if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound")
return {"format": FORMAT, "version": VERSION,
document = {"format": FORMAT, "version": VERSION,
"generated_at": dt.datetime.now().astimezone().isoformat(),
"operations": [dict(zip(keys, row)) for row in rows]}
if len(canonical(document).encode("utf-8")) > MAX_BYTES:
raise CausalError("protection set exceeds artifact byte bound")
return document
def main() -> None:

View file

@ -26,6 +26,8 @@ def main():
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
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():
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 "
"FROM custom_equipment ORDER BY equipment_id")]

View file

@ -43,6 +43,8 @@ def main():
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
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():
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"):
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:

View file

@ -11,6 +11,7 @@ def main():
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")
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

@ -63,6 +63,8 @@ def main():
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:

View file

@ -207,6 +207,8 @@ def main():
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 = {}

View file

@ -9,6 +9,7 @@ import os
import sqlite3
import sys
import unicodedata
import uuid
from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
@ -1648,6 +1649,15 @@ def import_sessions(
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 9:
connection.execute("DELETE FROM max_results WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 20:
existing_causal = connection.execute(
"SELECT deleted FROM sync_causal_state WHERE target_kind='session' AND target_id=?",
(session["session_id"],),).fetchone()
if existing_causal is not None and existing_causal[0]:
raise ImportFailure("session protégée causalement: " + session["session_id"])
connection.execute(
"INSERT OR REPLACE INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) VALUES('session',?,?,0,NULL)",
(session["session_id"], "mu_" + str(uuid.uuid4())),)
report["sessions_reconciled"] += 1
else:
cursor = connection.execute(

View file

@ -52,6 +52,7 @@ def main():
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"]):
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"}

View file

@ -1444,7 +1444,9 @@ TrainlogStatus trainlog_database_list_training_feedback(
TrainlogStatus status = list_feedback_query(database,
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text 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 "
"WHERE s.session_id=?1 ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
"WHERE s.session_id=?1 AND NOT EXISTS(SELECT 1 FROM sync_causal_state cs "
"WHERE cs.target_kind='feedback' AND cs.target_id=f.feedback_id AND cs.deleted=1) "
"ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
session_id, exercise_feedback, exercise_capacity, exercise_count, true);
if (status != TRAINLOG_STATUS_OK) return status;
qsort(exercise_feedback, *exercise_count, sizeof(*exercise_feedback), compare_feedback_view);
@ -1965,7 +1967,9 @@ TrainlogStatus trainlog_database_list_custom_equipment(
return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count = 0U;
rc = sqlite3_prepare_v2(database->connection,
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id;",
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ce "
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) "
"ORDER BY display_name COLLATE NOCASE,equipment_id;",
-1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
@ -2011,7 +2015,7 @@ TrainlogStatus trainlog_database_list_custom_equipment_page(
{
static const char *const SQL =
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id "
"FROM custom_equipment ce WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) ORDER BY display_name COLLATE NOCASE,equipment_id "
"LIMIT ?1 OFFSET ?2;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
@ -3154,7 +3158,7 @@ TrainlogStatus trainlog_database_list_exercises_filtered(
{
static const char *const SQL =
"SELECT id,exercise_id,name,tracking_mode,recording_mode,data_fields,normalized_name "
"FROM exercises ORDER BY name COLLATE NOCASE,exercise_id;";
"FROM exercises e WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' AND s.target_id=e.exercise_id AND s.deleted=1) ORDER BY name COLLATE NOCASE,exercise_id;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
size_t prefix_length;
@ -4352,6 +4356,26 @@ TrainlogStatus trainlog_database_replace_session_exercises(
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* WHY: content can return to its earlier bytes after a real correction.
* CONTRACT: a successful replacement advances a durable non-deleted
* causal revision in the same transaction. INVARIANT: a deleted session
* cannot be modified or silently reactivated by this writer. */
{
sqlite3_stmt *causal = NULL;
rc = sqlite3_prepare_v2(database->connection,
"INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) "
"VALUES('session',?1,'mu_'||lower(hex(randomblob(16))),0,NULL) "
"ON CONFLICT(target_kind,target_id) DO UPDATE SET current_revision_id=excluded.current_revision_id "
"WHERE sync_causal_state.deleted=0;", -1, &causal, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(causal, 1, session_id, -1, SQLITE_TRANSIENT);
if (rc == SQLITE_OK) rc = sqlite3_step(causal);
if (causal != NULL && sqlite3_finalize(causal) != SQLITE_OK && rc == SQLITE_DONE) rc = SQLITE_ERROR;
if (rc != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
(void)trainlog_database_rollback(database);
return rc == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
}
}
status = trainlog_database_commit(database);
if (status != TRAINLOG_STATUS_OK) {

View file

@ -1115,7 +1115,8 @@ static bool run_ai_inbound_diagnostic_case(
static bool run_all(void)
{
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
char temporary[4096];
const char *temporary_root = getenv("TMPDIR");
char case_a[4096];
char case_b[4096];
char case_c[4096];
@ -1135,7 +1136,14 @@ static bool run_all(void)
char case_q[4096];
char case_r[4096];
char fake_tools[4096];
char *root = mkdtemp(temporary);
char *root;
int temporary_written;
if (temporary_root == NULL || temporary_root[0] == '\0') temporary_root = "/tmp";
temporary_written = snprintf(temporary, sizeof(temporary),
"%s/trainlog-sync-body-zone-wiring-XXXXXX", temporary_root);
CHECK(temporary_written > 0 && (size_t)temporary_written < sizeof(temporary));
root = mkdtemp(temporary);
CHECK(root != NULL);
CHECK(install_fake_rclone(root));