feat(sync): add causal deletion protection

This commit is contained in:
fy59 2026-09-17 10:02:16 +02:00
parent 4b8f5b02b8
commit 98cb40749f
42 changed files with 860 additions and 61 deletions

View file

@ -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

View file

@ -308,7 +308,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-17:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema 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;

View file

@ -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<EquipmentCatalogEntry> {
val output = mutableListOf<EquipmentCatalogEntry>()
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<String>(); 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(" +

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(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"))

View file

@ -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") }

View file

@ -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"),

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(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))

View file

@ -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,

View file

@ -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.

View file

@ -25,8 +25,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema 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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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;
"""

View file

@ -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()

View file

@ -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)

View file

@ -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(

View file

@ -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",

View file

@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19):
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:

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19):
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(

View file

@ -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 "

View file

@ -16,7 +16,7 @@ def main():
args = parser.parse_args()
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19):
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 "

View file

@ -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"):

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19):
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,

View file

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

View file

@ -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(

View file

@ -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")

View file

@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19):
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")

View file

@ -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"))

View file

@ -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.

View file

@ -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:

View file

@ -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 = {}

View file

@ -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"]

View file

@ -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"]

View file

@ -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"}

View file

@ -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;

View file

@ -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',

View file

@ -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
);

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema 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;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema 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);