From 70cb7a116b2c3ef99deaa6e6f0430958ba12ca81 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 10:40:26 +0200 Subject: [PATCH] fix(sync): close causal deletion guarantees --- CHANGELOG.md | 9 ++ .../trainlog/data/TrainlogRepository.kt | 140 +++++++++++++++--- .../data/TrainlogRepositoryDraftTest.kt | 89 +++++++++++ docs/android.md | 7 + docs/current_state.md | 8 +- docs/database.md | 6 + docs/design/sync_gap_contract_v1.md | 19 +++ docs/exchange_format.md | 10 ++ docs/roadmap.md | 6 + docs/sync_exchange.md | 7 + docs/tests.md | 12 +- tests/test_causal_delete_exchange.py | 81 +++++++++- tools/causal_delete_exchange.py | 85 +++++++++-- tools/export_equipment_definitions.py | 2 + tools/export_exercise_body_zones.py | 2 + tools/export_training_feedback.py | 1 + tools/import_equipment_definitions.py | 2 + tools/import_exercise_body_zones.py | 2 + tools/import_mobile_export.py | 10 ++ tools/import_training_feedback.py | 1 + tui/src/database.c | 32 +++- tui/tests/test_sync_body_zone_wiring.c | 12 +- 22 files changed, 501 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ad58a..b67d3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 576141f..66d2f92 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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 diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 2f35cf0..cb3202e 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -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() diff --git a/docs/android.md b/docs/android.md index 969e3e0..f9f3160 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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. diff --git a/docs/current_state.md b/docs/current_state.md index 55da84a..fb6338a 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/database.md b/docs/database.md index 4debfe8..48e64ea 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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 diff --git a/docs/design/sync_gap_contract_v1.md b/docs/design/sync_gap_contract_v1.md index f6b0670..d764854 100644 --- a/docs/design/sync_gap_contract_v1.md +++ b/docs/design/sync_gap_contract_v1.md @@ -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 diff --git a/docs/exchange_format.md b/docs/exchange_format.md index 7d19447..364a21e 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5725f52..ae7f7ab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 470a009..1ba5da2 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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, diff --git a/docs/tests.md b/docs/tests.md index 8c438ff..326fece 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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. diff --git a/tests/test_causal_delete_exchange.py b/tests/test_causal_delete_exchange.py index 33ac2ac..5fbe27a 100644 --- a/tests/test_causal_delete_exchange.py +++ b/tests/test_causal_delete_exchange.py @@ -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() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 41cfa2b..109e406 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -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: diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 4328194..8f1873a 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -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")] diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 80e40ed..6724881 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -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: diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index ba3551a..0db9ad1 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -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") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 6e73508..faf8cd2 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -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: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 27a4b40..905ddc6 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -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 = {} diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 970dfc3..03533ac 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -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( diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 83b4ed9..00ef080 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -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"} diff --git a/tui/src/database.c b/tui/src/database.c index b307eb3..ab93454 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -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) { diff --git a/tui/tests/test_sync_body_zone_wiring.c b/tui/tests/test_sync_body_zone_wiring.c index 14deae2..5e16e4d 100644 --- a/tui/tests/test_sync_body_zone_wiring.c +++ b/tui/tests/test_sync_body_zone_wiring.c @@ -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));