diff --git a/CHANGELOG.md b/CHANGELOG.md index f519526..50ad58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased — 0.1.2 +- 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 + conflict and replay handling, and legacy snapshot non-resurrection gates. + Android and desktop both produce and consume the artifact in isolated tests; + automatic transport, generation acknowledgement and deployment remain + inactive. + - Completed `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` with desktop schema v19 and Android schema v18. Added explicit staged V4 enriched-history codecs, causal notes, body-observation session links, stable execution-draft diff --git a/README.md b/README.md index 7b1ca49..53f68f3 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ not contain cloud credentials and does not run `rclone`. As of 2026-09-17: - `TRAINLOG_FORMAT_V1=PASS/FROZEN`; -- desktop SQLite schema v19 and Android SQLite schema v18; +- desktop SQLite schema v20 and Android SQLite schema v19; - Notcurses is the only active desktop terminal backend; - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's all-files access setting; 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 c871bdd..576141f 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 @@ -340,6 +340,15 @@ sealed interface FinalizeActiveDraftResult { ) : FinalizeActiveDraftResult } +sealed interface CausalDeleteResult { + data class Applied(val operationId: String) : CausalDeleteResult + data class Unchanged(val operationId: String) : CausalDeleteResult + data class Conflict(val targetId: String) : CausalDeleteResult + data object NotFound : CausalDeleteResult + data object Invalid : CausalDeleteResult + data object DatabaseError : CausalDeleteResult +} + data class ExerciseOccurrenceCursor( val startedAt: String, val sessionId: String, @@ -800,10 +809,11 @@ class TrainlogRepository( fun listEquipment(): List { val output = mutableListOf() - database.readableDatabase.query( - "equipment", - arrayOf("equipment_id", "label_name", "display_name", "equipment_type", "load_semantics"), - null, null, null, null, "display_name COLLATE NOCASE, equipment_id", + database.readableDatabase.rawQuery( + "SELECT equipment_id,label_name,display_name,equipment_type,load_semantics FROM equipment e " + + "WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' " + + "AND s.target_id=e.equipment_id AND s.deleted=1) ORDER BY display_name COLLATE NOCASE,equipment_id", + null, ).use { cursor -> while (cursor.moveToNext()) { val id = cursor.getString(0) @@ -922,6 +932,8 @@ class TrainlogRepository( (if (primaryOnly) " AND selected.role='primary'" else "") + ")" arguments += accepted } + selection += "NOT EXISTS(SELECT 1 FROM sync_causal_state retired WHERE " + + "retired.target_kind='exercise' AND retired.target_id=e.exercise_id AND retired.deleted=1)" val sql = "SELECT e.exercise_id,e.name,e.normalized_name,e.recording_mode," + "e.tracking_mode,e.data_fields FROM exercises e" + (if (selection.isEmpty()) "" else " WHERE " + selection.joinToString(" AND ")) + @@ -3311,6 +3323,188 @@ class TrainlogRepository( } } + private val causalKinds = setOf("session", "execution_draft", "exercise", + "body_observation", "custom_equipment", "feedback", "body_zone_relation") + + private fun canonicalCausal(value: Any?): String = when (value) { + null, JSONObject.NULL -> "null" + is JSONObject -> value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") { + JSONObject.quote(it) + ":" + canonicalCausal(value.get(it)) + } + is JSONArray -> (0 until value.length()).joinToString(",", "[", "]") { canonicalCausal(value.get(it)) } + is String -> JSONObject.quote(value) + is Boolean, is Number -> value.toString() + else -> error("Unsupported causal value") + } + + private fun causalDigest(value: Any): String = MessageDigest.getInstance("SHA-256") + .digest(canonicalCausal(value).toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private fun causalSnapshot(db: SQLiteDatabase, kind: String, targetId: String): JSONArray? { + 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)) + "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)) + "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" -> { + val parts = targetId.split('|'); if (parts.size != 3) return null + Pair("SELECT e.exercise_id,z.zone_id,z.role FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?", parts.toTypedArray()) + } + else -> return null + } + return 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)) { + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index) + Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index) + else -> cursor.getString(index) + }) + } + } + } + } + + private fun causalLiveRevision(db: SQLiteDatabase, kind: String, targetId: String): String? = + causalSnapshot(db, kind, targetId)?.let { "lv_${causalDigest(it)}" } + + private fun applyCausalEffect(db: SQLiteDatabase, kind: String, targetId: String, operationId: String) { + when (kind) { + "session" -> { + db.execSQL("UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id FROM sessions WHERE session_id=?)", arrayOf(targetId)) + db.delete("sessions", "session_id=?", arrayOf(targetId)) + db.insertWithOnConflict("execution_draft_finalizations", null, ContentValues().apply { + put("session_id", targetId); put("final_revision_id", "deleted:$operationId") + put("finalized_at", OffsetDateTime.now().toString()) + }, SQLiteDatabase.CONFLICT_IGNORE) + } + "execution_draft" -> { + if (db.rawQuery("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", arrayOf(targetId)).use { it.moveToFirst() }) + throw SyncLifecycleConflict(targetId) + db.delete("execution_draft_candidates", "session_id=?", arrayOf(targetId)) + db.delete("active_session_draft", "session_id=?", arrayOf(targetId)) + } + "body_observation" -> db.delete("body_observations", "observation_id=?", arrayOf(targetId)) + "body_zone_relation" -> { + val parts = targetId.split('|') + db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?) AND zone_id=? AND role=?", parts.toTypedArray()) + } + // Retirement/withdrawal retains the historic row and immutable revisions. + "exercise", "custom_equipment", "feedback" -> Unit + } + } + + 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 || + !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)) { + val text = value.optString(key); if (text.isEmpty() || text.toByteArray().size > maximum) return false + } + val hash = value.optString("payload_sha256") + if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return false + val payload = JSONObject(value.toString()).apply { remove("payload_sha256") } + return causalDigest(payload) == hash + } + + private fun applyCausalOperation(db: SQLiteDatabase, operation: JSONObject): CausalDeleteResult { + if (!validateCausalOperation(operation)) return CausalDeleteResult.Invalid + val operationId = operation.getString("operation_id") + val known = db.rawQuery("SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations WHERE operation_id=?", arrayOf(operationId)).use { cursor -> + if (!cursor.moveToFirst()) null else (0 until 6).map { cursor.getString(it) } + } + 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") + 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) + 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")) + }) + 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) + put("deleted", 1); put("operation_id", operationId) + }, SQLiteDatabase.CONFLICT_REPLACE) + return CausalDeleteResult.Applied(operationId) + } + + /** 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 (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) + return CausalDeleteResult.NotFound + } + val operation = JSONObject().put("operation_id", "del_${UUID.randomUUID()}") + .put("target_kind", kind).put("target_id", targetId).put("creator_id", creatorId) + .put("predecessor_revision_id", predecessor).put("created_at", OffsetDateTime.now().toString()) + .put("publication_context", JSONObject.NULL) + operation.put("payload_sha256", causalDigest(operation)) + val result = applyCausalOperation(db, operation) + if (result is CausalDeleteResult.Applied) db.setTransactionSuccessful() + result + } catch (_: SyncLifecycleConflict) { CausalDeleteResult.Conflict(targetId) + } catch (_: Exception) { CausalDeleteResult.DatabaseError + } finally { db.endTransaction() } + } + + fun buildCausalDeletionExportV1Json(): String { + val db = database.readableDatabase + 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()) { + check(operations.length() < MAX_CAUSAL_OPERATIONS) { "Causal protection set exceeds artifact bound." } + operations.put(JSONObject().put("operation_id", cursor.getString(0)).put("target_kind", cursor.getString(1)) + .put("target_id", cursor.getString(2)).put("creator_id", cursor.getString(3)) + .put("predecessor_revision_id", cursor.getString(4)).put("created_at", cursor.getString(5)) + .put("payload_sha256", cursor.getString(6)).put("publication_context", JSONObject.NULL)) + } + } + return JSONObject().put("format", "trainlog-causal-deletions").put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()).put("operations", operations).toString() + } + + fun applyCausalDeletionExportV1Json(json: String): CausalDeleteResult { + if (json.toByteArray().size > 4 * 1024 * 1024 || !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) || + TrainlogTimestamp.parse(root.optString("generated_at")) == null) return CausalDeleteResult.Invalid + val values = root.optJSONArray("operations") ?: return CausalDeleteResult.Invalid + if (values.length() > MAX_CAUSAL_OPERATIONS) return CausalDeleteResult.Invalid + val ids = mutableSetOf(); val db = database.writableDatabase + return try { + db.beginTransaction(); var last: CausalDeleteResult = CausalDeleteResult.Unchanged("") + repeat(values.length()) { index -> + val operation = values.getJSONObject(index) + if (!ids.add(operation.optString("operation_id"))) return CausalDeleteResult.Invalid + last = applyCausalOperation(db, operation) + if (last !is CausalDeleteResult.Applied && last !is CausalDeleteResult.Unchanged) return last + } + db.setTransactionSuccessful(); last + } catch (_: Exception) { CausalDeleteResult.DatabaseError + } finally { db.endTransaction() } + } + fun buildMobileExportJson(): String = buildMobileExport(1) /** Create one direct causal note revision for a stable history owner. */ @@ -3450,6 +3644,11 @@ class TrainlogRepository( private fun buildMobileExport(version: Int): String { require(version in 1..4) + if (database.readableDatabase.rawQuery( + "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1", null, + ).use { it.moveToFirst() }) { + error("Causal protection refuses a mobile snapshot that omits tombstones.") + } val root = JSONObject() root.put("format", "trainlog-mobile-export") root.put("version", version) @@ -3785,6 +3984,8 @@ class TrainlogRepository( db.beginTransaction() for ((payload, draft) in parsed) { val sessionId = checkNotNull(draft.sessionId); val encoded = payload.toString() + if (db.rawQuery("SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=? AND deleted=1", + arrayOf(sessionId)).use { it.moveToFirst() }) { stale += 1; continue } if (db.rawQuery("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", arrayOf(sessionId)).use { it.moveToFirst() }) { stale += 1; continue } val current = db.rawQuery( @@ -3960,6 +4161,21 @@ class TrainlogRepository( validatePcMobileExport(root, version)?.let { return MobileSessionImportResult.Invalid(it) } val sessions = root.getJSONArray("sessions") val db = database.writableDatabase + fun protected(kind: String, identity: String): Boolean = db.rawQuery( + "SELECT 1 FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", + arrayOf(kind, identity), + ).use { it.moveToFirst() } + for (index in 0 until sessions.length()) if (protected( + "session", sessions.getJSONObject(index).getString("session_id"))) + return MobileSessionImportResult.Invalid("Causal protection refuses a deleted session replay.") + val bodies = root.getJSONArray("body_observations") + for (index in 0 until bodies.length()) if (protected( + "body_observation", bodies.getJSONObject(index).getString("observation_id"))) + return MobileSessionImportResult.Invalid("Causal protection refuses a deleted observation replay.") + val catalog = root.getJSONArray("exercises") + for (index in 0 until catalog.length()) if (protected( + "exercise", catalog.getJSONObject(index).getString("exercise_id"))) + return MobileSessionImportResult.Invalid("Causal protection refuses a retired exercise replay.") var sessionsAdded = 0 var sessionsSkipped = 0 var sessionsUpdated = 0 @@ -7472,6 +7688,7 @@ private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 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_PLAN_SETS = 64 private const val MAX_PLAN_REPS = 10000 private const val MAX_PLAN_DURATION_SECONDS = 86400 @@ -7513,7 +7730,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 18, + 19, ) { override fun onConfigure( db: SQLiteDatabase, @@ -7556,6 +7773,7 @@ private class TrainlogDatabaseHelper( createExerciseProfileStateTable(db, seedLegacy = false) createAiSessionDraftTables(db) createSyncDataLifecycleTables(db) + createCausalDeleteTables(db) seedEquipment(db) } @@ -7707,6 +7925,15 @@ private class TrainlogDatabaseHelper( version = 18 } + if (version < 19 && newVersion >= 19) { + /* WHY: old peer snapshots can otherwise recreate intentionally + * deleted identities. CONTRACT: v19 is additive and stores only + * immutable causal operations/current state. INVARIANT: migration + * invents no deletion, ancestry, peer or publication context. */ + createCausalDeleteTables(db) + version = 19 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -7770,6 +7997,31 @@ private class TrainlogDatabaseHelper( ) } + private fun createCausalDeleteTables(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS sync_causal_operations(" + + "operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL," + + "creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,created_at TEXT NOT NULL," + + "payload_sha256 TEXT NOT NULL,publication_context TEXT," + + "CHECK(length(operation_id) BETWEEN 1 AND 128)," + + "CHECK(length(target_id) BETWEEN 1 AND 512)," + + "CHECK(length(creator_id) BETWEEN 1 AND 128)," + + "CHECK(length(predecessor_revision_id) BETWEEN 1 AND 128)," + + "CHECK(length(payload_sha256)=64));", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS sync_causal_state(" + + "target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL," + + "deleted INTEGER NOT NULL CHECK(deleted IN(0,1)),operation_id TEXT," + + "PRIMARY KEY(target_kind,target_id)," + + "FOREIGN KEY(operation_id) REFERENCES sync_causal_operations(operation_id));", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS sync_causal_operations_target " + + "ON sync_causal_operations(target_kind,target_id);", + ) + } + private fun createAiSessionDraftTables(db: SQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS ai_session_drafts(" + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 04cb76f..a5199af 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(18, scalar(db, "PRAGMA user_version")) + assertEquals(19, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index c69d1ae..85622bb 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(18, scalar(db, "PRAGMA user_version")) + assertEquals(19, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 53129d3..970830d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(18, scalar(db, "PRAGMA user_version")) + assertEquals(19, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 2104bf6..b34e65c 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(18, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(19, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) 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 6311bf3..2f35cf0 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 @@ -649,6 +649,81 @@ class TrainlogRepositoryDraftTest { } } + @Test + fun causalSessionDeletionRoundTripsWithBothRealProducersAndBlocksReplay() { + val sourceName = "delete-source-${UUID.randomUUID()}.db" + val destinationName = "delete-destination-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + val root = Files.createTempDirectory("trainlog-causal-delete-").toFile() + try { + val exercise = createExercise(source, "Delete bridge", RecordingMode.SETS, TrackingMode.REPS) + assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft( + entryId = "sxe_60000000-0000-4000-8000-000000000001", + exercise = exercise, sets = listOf(SessionSetDraft(reps = 9)), + )))) is SaveSessionResult.Saved) + val live = File(root, "live-v4.json").apply { writeText(source.buildMobileExportV4Json()) } + val sessionId = JSONObject(live.readText()).getJSONArray("sessions") + .getJSONObject(0).getString("session_id") + + val androidDelete = source.deleteCausally("session", sessionId, "peer_android_test") + assertTrue(androidDelete is CausalDeleteResult.Applied) + val androidCausal = File(root, "android-causal.json").apply { + writeText(source.buildCausalDeletionExportV1Json()) + } + try { source.buildMobileExportV4Json(); fail("protected legacy export must fail") + } catch (_: IllegalStateException) { } + + val desktop = File(root, "desktop.sqlite") + runLifecycleBridge("history", live, desktop, File(root, "desktop-live-v4.json")) + runDesktopCausal("import", androidCausal, desktop) + val desktopCausal = File(root, "desktop-causal.json") + runDesktopCausal("export", desktopCausal, desktop) + + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", JSONObject(live.readText()).getJSONArray("exercises")) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied) + assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Applied) + assertTrue(destination.listSessions().isEmpty()) + assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Unchanged) + assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Invalid) + destination.close() + val reopened = TrainlogRepository(context, destinationName) + assertTrue(reopened.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Unchanged) + assertTrue(reopened.listSessions().isEmpty()) + reopened.close() + + val desktopProducer = File(root, "desktop-producer.sqlite") + runLifecycleBridge("history", live, desktopProducer, File(root, "desktop-producer-live.json")) + val localOperation = File(root, "desktop-local-operation.json") + runDesktopCausal("delete", localOperation, desktopProducer, "session", sessionId) + val desktopProduced = File(root, "desktop-produced.json") + runDesktopCausal("export", desktopProduced, desktopProducer) + val reverseName = "delete-reverse-${UUID.randomUUID()}.db" + val reverse = TrainlogRepository(context, reverseName) + assertTrue(reverse.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertTrue(reverse.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied) + assertTrue(reverse.applyCausalDeletionExportV1Json(desktopProduced.readText()) is CausalDeleteResult.Applied) + val returned = File(root, "android-returned-causal.json").apply { + writeText(reverse.buildCausalDeletionExportV1Json()) + } + val freshDesktop = File(root, "desktop-fresh.sqlite") + runLifecycleBridge("history", live, freshDesktop, File(root, "desktop-fresh-live.json")) + runDesktopCausal("import", returned, freshDesktop) + val finalArtifact = File(root, "desktop-final-causal.json") + runDesktopCausal("export", finalArtifact, freshDesktop) + assertEquals(semanticCausalProjection(JSONObject(desktopProduced.readText())), + semanticCausalProjection(JSONObject(finalArtifact.readText()))) + reverse.close(); context.deleteDatabase(reverseName) + } finally { + try { source.close() } catch (_: Exception) { } + try { destination.close() } catch (_: Exception) { } + context.deleteDatabase(sourceName); context.deleteDatabase(destinationName) + root.deleteRecursively() + } + } + @Test fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() { val repo = openRepository() @@ -1936,7 +2011,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(18, cursor.getInt(0)) + assertEquals(19, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2063,7 +2138,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(18, cursor.getInt(0)) + assertEquals(19, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) @@ -2738,6 +2813,32 @@ class TrainlogRepositoryDraftTest { return canonicalJson(copy).toString() } + private fun semanticCausalProjection(value: JSONObject): String { + val copy = JSONObject(value.toString()); copy.remove("generated_at") + val source = copy.getJSONArray("operations") + val sorted = (0 until source.length()).map { source.getJSONObject(it) } + .sortedBy { it.getString("operation_id") } + copy.put("operations", JSONArray().also { output -> sorted.forEach(output::put) }) + return canonicalJson(copy).toString() + } + + private fun runDesktopCausal( + mode: String, + artifact: File, + database: File, + kind: String? = null, + targetId: String? = null, + ) { + val tool = findRepositoryFile("tools/causal_delete_exchange.py") + val arguments = mutableListOf("python3", tool.absolutePath, mode, artifact.absolutePath, + "--database", database.absolutePath) + if (kind != null && targetId != null) arguments += listOf( + "--kind", kind, "--target-id", targetId, "--creator-id", "peer_desktop_test") + val process = ProcessBuilder(arguments).redirectErrorStream(true).start() + val text = process.inputStream.bufferedReader().readText() + assertEquals("desktop causal tool failed: $text", 0, process.waitFor()) + } + private fun runLifecycleBridge( kind: String, input: File, diff --git a/docs/android.md b/docs/android.md index 70adebc..969e3e0 100644 --- a/docs/android.md +++ b/docs/android.md @@ -910,3 +910,7 @@ Cancel and Back are safe, outside taps cannot confirm, and the destructive button is explicit. Draft occurrence warnings name nonzero performed-set, continuous, MAX and feedback children; series deletion and whole-draft abandon use the same gate. +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. diff --git a/docs/current_state.md b/docs/current_state.md index 81dc511..55da84a 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -25,8 +25,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v19 | -| Android SQLite | schema v18 | +| Desktop SQLite | schema v20 | +| Android SQLite | schema v19 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | @@ -289,6 +289,19 @@ smoke test are not automated. The latter is why ## Active limitations +`TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` provides isolated Android and +desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted +domains are durably protected; exact replay is idempotent and unprovable or +concurrent ancestry conflicts before mutation. Protected legacy snapshots are +refused before resurrection. Active synchronization still selects V3: no +generation acknowledgement, service activation, deployment, or user-data +migration occurred. + +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 +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. + - `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes, Séances and Exercices remain shell placeholders until their Core/API ownership and implementation order are explicitly contracted. diff --git a/docs/database.md b/docs/database.md index b5158ef..4debfe8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -16,8 +16,8 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=19 -DATABASE_SCHEMA_V19=IMPLEMENTED +TRAINLOG_DATABASE_SCHEMA_VERSION=20 +DATABASE_SCHEMA_V20=IMPLEMENTED TRAINLOG_FORMAT_V1=FROZEN ``` @@ -36,7 +36,7 @@ PRAGMA user_version; Current value: ```text -19 +20 ``` The independent actual-set loads documented in the current desktop, Android @@ -49,6 +49,14 @@ Supported historical databases are migrated explicitly through the implemented migration chain. A database newer than the running binary understands is rejected. +Schema v20 additively introduces `sync_causal_operations` and +`sync_causal_state`. Operations are immutable and identify their target, +creator, causal predecessor, timestamp, payload digest, and an unassigned +publication context. Target state records the durable deleted revision. The +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. + 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/exchange_format.md b/docs/exchange_format.md index 4e57e3e..7d19447 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -645,3 +645,17 @@ Canonical validation requires both: python tools/validate_json.py python tools/validate_import_contract.py ``` +## Staged causal deletion V1 + +`trainlog-causal-deletions` V1 is independent of every published snapshot. +Its root contains `format`, integer `version`, `generated_at`, and at most 4096 +`operations`; the complete UTF-8 artifact is limited to 4 MiB. Each operation +contains `operation_id`, `target_kind`, `target_id`, `creator_id`, +`predecessor_revision_id`, `created_at`, `payload_sha256`, and a null +`publication_context`. Unknown fields, duplicate keys/IDs, invalid timestamps, +invalid hashes, unknown ancestry, and concurrent ancestry are rejected +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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 60da70b..5725f52 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,7 +19,7 @@ distinction between measured values and estimates. ## Current baseline -Desktop schema v19, Android schema v18, Notcurses, direct +Desktop schema v20, Android schema v19, Notcurses, direct `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft @@ -33,7 +33,7 @@ visualizations are implemented; the other four Web routes remain placeholders. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_CAUSAL_DELETE_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_GENERATION_ACK_V1 ``` `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` @@ -125,9 +125,9 @@ characterization, data/lifecycles, causal deletion, generation/consumption/ack, orchestrator/report, Web API, then button/refresh. The environment/test and characterization and data/lifecycle lots are complete. Enriched V4 history and the separate execution-draft codec are implemented behind explicit entry -points, while active transport remains V3. Causal deletion, -generation/consumption acknowledgement, orchestration/reporting and Web sync -remain unimplemented. +points, while active transport remains V3. Causal deletion is implemented and +validated through explicit staged entry points; generation/consumption +acknowledgement, orchestration/reporting and Web sync remain unimplemented. `WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged. ### Gym catalog V1 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 67193cf..470a009 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1,8 +1,17 @@ # Synchronization exchange +## Causal deletion safety gate + +Android and desktop expose staged producers and consumers for the independent +`trainlog-causal-deletions` V1 artifact; it is not in the active MTP bundle. A +protected store refuses legacy mobile snapshot export, and legacy mobile import +rejects protected session, observation, or exercise identities before mutation. +Deleted execution-draft replay is stale. Tombstone-free V1/V2/V3 behavior is +unchanged. + ## Machine-exercise Phase 1 compatibility -Desktop schema v19 and Android schema v18 retain mobile export V3, readable V1/V2 imports, +Desktop schema v20 and Android schema v19 retain mobile export V3, readable V1/V2 imports, equipment definitions V1, equipment associations V2, exercise aliases V1, and the BODY ZONES companion without wire-format changes. Machine metadata is not silently added to a frozen artifact: stable exercise IDs and canonical names diff --git a/docs/tests.md b/docs/tests.md index 83d7d6c..8c438ff 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1190,3 +1190,20 @@ Documents and Download roots while failing on any legacy access. Android unit tests and any instrumented tests run on an emulator; the primary personal phone is reserved for manual, non-destructive install-and-sync smoke validation and must not run `connectedDebugAndroidTest`. +## Causal deletion V1 + +The normal Meson inventory includes `causal_delete_exchange`, which invokes +the production desktop service and proves all target kinds, relationship +safety, exact replay, conflicts, finalization protection, and rollback. Android +`causalSessionDeletionRoundTripsWithBothRealProducersAndBlocksReplay` uses the +real Android repository and desktop service in both producer directions with +fresh destinations, reopen/replay, and legacy non-resurrection. + +Strict compilation uses Clang 22.1.8. GCC 16.2.1 retains the compared +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 +database fixtures skipped. `assembleDebug`, JSON validation, import-contract +validation, both frontend tests, and the isolated characterization all pass. diff --git a/tests/support/roundtrip_desktop_lifecycle.py b/tests/support/roundtrip_desktop_lifecycle.py index c5986b6..3a5d151 100644 --- a/tests/support/roundtrip_desktop_lifecycle.py +++ b/tests/support/roundtrip_desktop_lifecycle.py @@ -24,7 +24,9 @@ CREATE TABLE sync_note_state(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,rev CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL,source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,state TEXT NOT NULL,payload_json TEXT NOT NULL); CREATE TABLE execution_draft_revisions(session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL,PRIMARY KEY(session_id,revision_id)); CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL); -PRAGMA user_version=19; +CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT); +CREATE TABLE sync_causal_state(target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL,operation_id TEXT,PRIMARY KEY(target_kind,target_id)); +PRAGMA user_version=20; """ diff --git a/tests/test_causal_delete_exchange.py b/tests/test_causal_delete_exchange.py new file mode 100644 index 0000000..33ac2ac --- /dev/null +++ b/tests/test_causal_delete_exchange.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Production-service regressions for staged causal deletion.""" + +import importlib.util +import json +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +SPEC = importlib.util.spec_from_file_location("causal", ROOT / "tools/causal_delete_exchange.py") +causal = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(causal) + +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 custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT,label_name TEXT,equipment_type TEXT,load_semantics 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_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; +""" + + +class CausalDeleteTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(); self.path = Path(self.tmp.name) / "db.sqlite" + 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 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')") + self.db.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','primary')") + self.db.execute("INSERT INTO execution_drafts VALUES('se_draft','dr_one',NULL,'{}')") + self.db.commit() + + def tearDown(self): self.db.close(); self.tmp.cleanup() + + def delete(self, kind, target): + self.db.execute("BEGIN IMMEDIATE") + operation, outcome = causal.local_delete(self.db, kind, target, "peer_desktop_test") + self.db.commit(); self.assertEqual("applied", outcome); return operation + + def test_domain_effects_replay_conflict_and_parent_safety(self): + feedback = self.delete("feedback", "fb_one") + self.assertEqual("root", self.db.execute("SELECT raw_text FROM exercise_feedback").fetchone()[0]) + self.assertEqual(1, self.db.execute("SELECT count(*) FROM exercise_feedback_revisions").fetchone()[0]) + self.delete("body_zone_relation", "ex_user|arms|primary") + self.assertEqual(0, self.db.execute("SELECT count(*) FROM exercise_body_zones").fetchone()[0]) + self.delete("exercise", "ex_user"); self.delete("custom_equipment", "eq_one") + self.assertEqual(1, self.db.execute("SELECT count(*) FROM exercises").fetchone()[0]) + self.assertEqual(1, self.db.execute("SELECT count(*) FROM custom_equipment").fetchone()[0]) + self.delete("execution_draft", "se_draft") + self.assertEqual(0, self.db.execute("SELECT count(*) FROM execution_drafts").fetchone()[0]) + session = self.delete("session", "se_one") + self.assertEqual(0, self.db.execute("SELECT count(*) FROM sessions").fetchone()[0]) + self.assertEqual((1, None), self.db.execute("SELECT count(*),session_row_id FROM body_observations").fetchone()) + self.assertIsNotNone(self.db.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id='se_one'").fetchone()) + self.assertEqual("unchanged", causal.apply_operation(self.db, session)) + conflicting = dict(session); conflicting["operation_id"] = "del_" + "1" * 36 + conflicting["payload_sha256"] = causal.digest({k:v for k,v in conflicting.items() if k != "payload_sha256"}) + with self.assertRaisesRegex(causal.CausalError, "concurrent deletion"): + causal.apply_operation(self.db, conflicting) + divergent = dict(feedback); divergent["creator_id"] = "other" + divergent["payload_sha256"] = causal.digest({k:v for k,v in divergent.items() if k != "payload_sha256"}) + with self.assertRaisesRegex(causal.CausalError, "reused"): + causal.apply_operation(self.db, divergent) + + def test_observation_delete_and_finalized_draft_conflict_roll_back(self): + self.delete("body_observation", "bo_one") + self.assertEqual(0, self.db.execute("SELECT count(*) FROM body_observations").fetchone()[0]) + self.db.execute("INSERT OR REPLACE INTO execution_draft_finalizations VALUES('se_draft','dr_final','2030-01-01T12:00:00Z')") + self.db.commit(); before = self.db.execute("SELECT count(*) FROM sync_causal_operations").fetchone()[0] + self.db.execute("BEGIN IMMEDIATE") + with self.assertRaisesRegex(causal.CausalError, "finalization"): + causal.local_delete(self.db, "execution_draft", "se_draft", "peer") + self.db.rollback() + self.assertEqual(before, self.db.execute("SELECT count(*) FROM sync_causal_operations").fetchone()[0]) + self.assertIsNotNone(self.db.execute("SELECT 1 FROM execution_drafts WHERE session_id='se_draft'").fetchone()) + + def test_builtin_exercise_cannot_be_retired(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.commit() + with self.assertRaisesRegex(causal.CausalError, "built-in"): + causal.local_delete(self.db, "exercise", built_in, "peer") + + +if __name__ == "__main__": unittest.main() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py new file mode 100644 index 0000000..41cfa2b --- /dev/null +++ b/tools/causal_delete_exchange.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Staged causal-deletion service; automatic synchronization remains V3.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import sqlite3 +import sys +import uuid +from pathlib import Path + +from trainlog_sqlite import connect_database +from validate_json import parse_timestamp +from exercise_names import load_exercise_names + +FORMAT = "trainlog-causal-deletions" +VERSION = 1 +MAX_BYTES = 4 * 1024 * 1024 +MAX_OPERATIONS = 4096 +KINDS = {"session", "execution_draft", "exercise", "body_observation", + "custom_equipment", "feedback", "body_zone_relation"} + + +class CausalError(RuntimeError): + pass + + +def canonical(value) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def digest(value) -> str: + return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest() + + +def require_schema(db: sqlite3.Connection) -> None: + version = db.execute("PRAGMA user_version").fetchone()[0] + if version != 20: + raise CausalError(f"desktop schema v20 required, found v{version}") + + +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() + 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() + 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() + 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() + else: + parts = target.split("|") + if len(parts) != 3: + raise CausalError("body-zone target must be exercise_id|zone_id|role") + row = db.execute("SELECT e.exercise_id,z.zone_id,z.role FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?", parts).fetchone() + return None if row is None else list(row) + + +def live_revision(db: sqlite3.Connection, kind: str, target: str) -> str | None: + snapshot = target_snapshot(db, kind, target) + return None if snapshot is None else "lv_" + digest(snapshot) + + +def apply_effect(db: sqlite3.Connection, kind: str, target: str, operation_id: str) -> None: + if kind == "session": + db.execute("UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id FROM sessions WHERE session_id=?)", (target,)) + db.execute("DELETE FROM sessions WHERE session_id=?", (target,)) + db.execute("INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?,?,?)", (target, "deleted:" + operation_id, dt.datetime.now().astimezone().isoformat())) + elif kind == "execution_draft": + if db.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (target,)).fetchone(): + raise CausalError("draft finalization conflicts with deletion") + db.execute("DELETE FROM execution_drafts WHERE session_id=?", (target,)) + elif kind == "body_observation": + db.execute("DELETE FROM body_observations WHERE observation_id=?", (target,)) + elif kind == "body_zone_relation": + exercise, zone, role = target.split("|") + db.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?) AND zone_id=? AND role=?", (exercise, zone, role)) + # Exercise/equipment retirement and feedback withdrawal retain the row and + # immutable history; availability/current-state readers consult the causal state. + + +def validate_operation(value: object) -> dict: + keys = {"operation_id", "target_kind", "target_id", "creator_id", + "predecessor_revision_id", "created_at", "payload_sha256", + "publication_context"} + if not isinstance(value, dict) or set(value) != keys: + raise CausalError("operation has unknown or missing fields") + for key, maximum in (("operation_id", 128), ("target_id", 512), + ("creator_id", 128), ("predecessor_revision_id", 128)): + if not isinstance(value[key], str) or not 0 < len(value[key].encode()) <= maximum: + raise CausalError(f"invalid {key}") + if value["target_kind"] not in KINDS or value["publication_context"] is not None: + raise CausalError("invalid target kind or publication context") + if not isinstance(value["payload_sha256"], str) or len(value["payload_sha256"]) != 64 or any(c not in "0123456789abcdef" for c in value["payload_sha256"]): + raise CausalError("invalid payload digest") + parse_timestamp(value["created_at"], "created_at") + payload = {k: value[k] for k in keys - {"payload_sha256"}} + if digest(payload) != value["payload_sha256"]: + raise CausalError("operation payload digest mismatch") + return value + + +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" + 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]: + raise CausalError("concurrent deletion conflict") + if current is None: + raise CausalError("unknown target or ancestry") + if current != op["predecessor_revision_id"]: + raise CausalError("causal predecessor conflict") + 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"])) + 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 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) + 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: + 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).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" + raise CausalError("target not found") + operation = {"operation_id": "del_" + str(uuid.uuid4()), "target_kind": kind, + "target_id": target, "creator_id": creator, + "predecessor_revision_id": current, + "created_at": dt.datetime.now().astimezone().isoformat(), + "publication_context": None} + operation["payload_sha256"] = digest(operation) + return operation, apply_operation(db, operation) + + +def load(path: Path) -> dict: + raw = path.read_bytes() + if len(raw) > MAX_BYTES: + raise CausalError("artifact exceeds 4 MiB") + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: raise CausalError("duplicate JSON key") + result[key] = value + return result + try: root = json.loads(raw.decode("utf-8"), object_pairs_hook=unique) + except (UnicodeError, json.JSONDecodeError) as error: raise CausalError("invalid UTF-8/JSON") from error + if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "operations"} or root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + raise CausalError("unsupported causal-deletion artifact") + parse_timestamp(root["generated_at"], "generated_at") + if not isinstance(root["operations"], list) or len(root["operations"]) > MAX_OPERATIONS: + raise CausalError("operations bound exceeded") + ids = set() + for value in root["operations"]: + op = validate_operation(value) + if op["operation_id"] in ids: raise CausalError("duplicate operation_id") + ids.add(op["operation_id"]) + return root + + +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() + if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound") + return {"format": FORMAT, "version": VERSION, + "generated_at": dt.datetime.now().astimezone().isoformat(), + "operations": [dict(zip(keys, row)) for row in rows]} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("delete", "import", "export")) + parser.add_argument("path", type=Path) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--kind", choices=sorted(KINDS)); parser.add_argument("--target-id") + parser.add_argument("--creator-id", default="peer_desktop_local") + args = parser.parse_args() + db = connect_database(args.database); require_schema(db) + try: + db.execute("BEGIN IMMEDIATE") + if args.mode == "delete": + if not args.kind or not args.target_id: raise CausalError("delete requires --kind and --target-id") + op, outcome = local_delete(db, args.kind, args.target_id, args.creator_id) + args.path.write_text(canonical(op), encoding="utf-8"); print(outcome) + elif args.mode == "import": + for op in load(args.path)["operations"]: print(op["operation_id"] + "=" + apply_operation(db, op)) + else: + args.path.write_text(canonical(export_document(db)), encoding="utf-8"); print("CAUSAL_DELETE_EXPORT=PASS") + db.commit() + except Exception: + db.rollback(); raise + finally: db.close() + + +if __name__ == "__main__": + try: main() + except Exception as error: + print("CAUSAL_DELETE=FAIL " + str(error)); raise SystemExit(1) diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 659fa7e..0a05bda 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -169,8 +169,8 @@ def canonical(value): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version != 19: - raise LifecycleError(f"desktop schema v19 required, found v{version}") + if version not in (19, 20): + raise LifecycleError(f"desktop schema v19/v20 required, found v{version}") def import_document(connection, document): @@ -181,6 +181,8 @@ def import_document(connection, document): for draft in document["drafts"]: session_id, revision = draft["session_id"], draft["revision_id"] payload = canonical(draft) + if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=? AND deleted=1", (session_id,)).fetchone(): + outcomes.append((session_id, "stale")); continue if connection.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (session_id,)).fetchone(): outcomes.append((session_id, "stale")); continue current = connection.execute( diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 6e07904..54c2625 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 4e79205..e49d8b3 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,8 +20,8 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19): - raise ValueError("schema desktop v18 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20): + raise ValueError("schema desktop v18-v20 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index fc0a2c2..7432856 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,7 +28,7 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): + 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") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index f1a1662..4328194 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,7 +24,7 @@ def main(): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): + 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") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 60dee35..48c5ff8 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -16,7 +16,7 @@ def main(): args = parser.parse_args() con = connect_database(args.database) try: - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19): + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20): raise ValueError("schema desktop v12-v16 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 01f0b7f..80e40ed 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,7 +41,7 @@ def main(): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19): + 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") exercises = [] for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index e6f7ee3..c8badfc 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19): + if version not in (17, 18, 19, 20): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 974450d..72428ab 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): + if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index eaa3f0c..939ea72 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,10 +83,13 @@ def main(): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version != 19: - raise ValueError("mobile V4 exige le schéma desktop v19") - if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19) and not (args.version == 2 and schema_version == 10): + if args.version == 4 and schema_version not in (19, 20): + raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") + if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20) and not (args.version == 2 and schema_version == 10): raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") + if schema_version >= 20 and con.execute( + "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): + raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") known_equipment = supplied_equipment_ids() canonical_names = load_exercise_names() known_equipment.update(row[0] for row in con.execute( diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index b25df7f..ba3551a 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,7 +10,7 @@ def main(): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19): raise ValueError("schema desktop v15-v19 requis") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20): raise ValueError("schema desktop v15-v20 requis") 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_ai_session_draft.py b/tools/import_ai_session_draft.py index eb33cd6..97b93a8 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19): - raise ImportFailure("schema desktop v18 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20): + raise ImportFailure("schema desktop v18-v20 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index e9a7892..4311fc3 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -173,8 +173,8 @@ def main(): fail("clés extension équipement invalides") connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): - fail("schema desktop v8 à v16 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): + fail("schema desktop v8 à v20 requis") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 0fd62c6..6e73508 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -61,7 +61,7 @@ def main(): definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): + 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") imported = skipped = 0 # Validate every same-ID row before inserting any definition. diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 5b44bc3..703abbe 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -63,7 +63,7 @@ def main(): con = connect_database(args.database) try: con.execute("PRAGMA foreign_keys=ON") - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19): + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20): fail("schema desktop v12-v16 requis") con.execute("BEGIN IMMEDIATE") for source, canonical in aliases: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index e2dc234..27a4b40 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -205,8 +205,8 @@ def main(): connection = connect_database(args.database) updated = skipped = kept_local = 0 try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19): - raise ImportFailure("schema desktop v11-v16 requis") + 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") connection.execute("PRAGMA foreign_keys=ON") connection.execute("BEGIN IMMEDIATE") grouped = {} diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 0d82e86..6836770 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -53,7 +53,7 @@ def main(): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at") con=connect_database(a.database);con.row_factory=sqlite3.Row - if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19): fail("schema desktop v17/v18/v19 requis") + if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20): fail("schema desktop v17-v20 requis") applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE") for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 4a7fda2..970dfc3 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -845,9 +845,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): + if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): raise ImportFailure( - f"base desktop schema v5 à v19 attendue, version trouvée: {version}" + f"base desktop schema v5 à v20 attendue, version trouvée: {version}" ) @@ -1987,8 +1987,18 @@ def run_import( ) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version != 19: - raise ImportFailure("mobile V4 exige le schéma desktop v19") + if payload["version"] == 4 and schema_version not in (19, 20): + raise ImportFailure("mobile V4 exige le schéma desktop v19 ou v20") + if schema_version >= 20: + protected = {(row[0], row[1]) for row in connection.execute( + "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} + incoming = ({("session", value["session_id"]) for value in payload["sessions"]} | + {("body_observation", value["observation_id"]) for value in payload["body_observations"]} | + {("exercise", value["exercise_id"]) for value in payload["exercises"]}) + collision = protected & incoming + if collision: + kind, identity = sorted(collision)[0] + raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}") has_explicit_max = any( "max_weight_kg" in entry for session in payload["sessions"] diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 72067c6..83b4ed9 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -51,7 +51,7 @@ def main(): roots_added=roots_skipped=revisions_added=revisions_skipped=0 try: db.execute("PRAGMA foreign_keys=ON") - if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19):fail("schema desktop v15-v19 requis") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20):fail("schema desktop v15-v20 requis") 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/include/trainlog/database.h b/tui/include/trainlog/database.h index 531d04f..5b0d6bf 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -16,7 +16,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 19 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 20 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index 50379fc..0dbff07 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -713,6 +713,11 @@ test( python3_trainlog_tests, args: [meson.project_source_root() / 'tests/test_validate_sync_isolated.py'], ) +test( + 'causal_delete_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_causal_delete_exchange.py'], +) test( 'equipment_associations_exchange', diff --git a/tui/src/database.c b/tui/src/database.c index 8dc5c0c..b307eb3 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -832,6 +832,30 @@ static const char *const MIGRATE_V18_TO_V19_SQL = "session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);" "PRAGMA user_version=19;COMMIT;"; +/* WHY: offline peers can replay live snapshots after an intentional deletion. + * CONTRACT: v20 records immutable causal deletion operations separately from + * future publication generations. INVARIANT: no legacy row is marked deleted + * and no ancestry, peer acknowledgement, or emission context is fabricated. */ +static const char *const MIGRATE_V19_TO_V20_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE sync_causal_operations(" + "operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL," + "creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL," + "created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT," + "CHECK(length(operation_id) BETWEEN 1 AND 128)," + "CHECK(length(target_id) BETWEEN 1 AND 512)," + "CHECK(length(creator_id) BETWEEN 1 AND 128)," + "CHECK(length(predecessor_revision_id) BETWEEN 1 AND 128)," + "CHECK(length(payload_sha256)=64)," + "UNIQUE(target_kind,target_id,operation_id));" + "CREATE TABLE sync_causal_state(" + "target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL," + "deleted INTEGER NOT NULL CHECK(deleted IN(0,1)),operation_id TEXT," + "PRIMARY KEY(target_kind,target_id)," + "FOREIGN KEY(operation_id) REFERENCES sync_causal_operations(operation_id));" + "CREATE INDEX sync_causal_operations_target ON sync_causal_operations(target_kind,target_id);" + "PRAGMA user_version=20;COMMIT;"; + /* CONTRACT: v18 was already deployed before bounded draft publication was * added. Keep its version number and add only the nullable lifecycle cursor; * NULL deliberately means retryable/not yet published. */ @@ -1525,7 +1549,7 @@ static TrainlogStatus initialize_or_validate_schema( status = TRAINLOG_STATUS_OK; } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || version == 16 || version == 17 || version == 18 || - version == 19) { + version == 19 || version == 20) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -1692,6 +1716,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK && version < 19) { status = execute_sql(database, MIGRATE_V18_TO_V19_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 20) { + status = execute_sql(database, MIGRATE_V19_TO_V20_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -1707,7 +1734,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v19" : "migrate database to schema v19", + version == 0 ? "create schema v20" : "migrate database to schema v20", database->connection, SQLITE_ERROR ); diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 0ee2cf1..bd29f0f 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v19: SQLite rc=", - strlen("migrate database to schema v19: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v20: SQLite rc=", + strlen("migrate database to schema v20: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void) CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=20;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=21;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 20 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 21 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 473d101..74e92d3 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v19") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v20") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);