Compare commits

..

No commits in common. "dd6d2a180d73ca61761c64ef499c352fb0b2fd74" and "e6e5e76f68e5912ee31da13bc4b6434f91c0890a" have entirely different histories.

17 changed files with 18 additions and 706 deletions

View file

@ -2,17 +2,6 @@
## Unreleased — 0.1.4 ## Unreleased — 0.1.4
- Fixed Program progress for sessions started from a delivered manual
preparation. The preparation companion now carries its stable Program and
Program-session provenance through Android start, finalization, restart and
the existing execution companion; legacy stored deliveries are safely
enriched only when every pre-existing field and execution identity matches.
Android and Web consequently present the completed session and progress from
the same persisted identity without fabricating history.
Historical acknowledged deliveries now complete that recovery on the
original session even when the preparation contained planned continuous
activities, and current sleep snapshots can initialize a fresh peer from a
non-root revision without discarding causal identity.
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one - Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
the previous entry immediately, adopts only an exact-date result and rejects the previous entry immediately, adopts only an exact-date result and rejects

View file

@ -541,12 +541,6 @@ internal class SyncGenerationCoordinator(
phase(currentRunId, "cancelled", result = "interrupted") phase(currentRunId, "cancelled", result = "interrupted")
ForegroundGenerationResult.Cancelled(currentRunId) ForegroundGenerationResult.Cancelled(currentRunId)
} catch (error: Exception) { } catch (error: Exception) {
Log.e(
"TrainlogSyncGeneration",
"run_id=${currentRunId ?: "pending"} phase=error " +
"type=${error.javaClass.name} message=${error.message}",
error,
)
if (error.message == "generation retention capacity exhausted") { if (error.message == "generation retention capacity exhausted") {
try { try {
val request = JSONObject(File(directory, "request-v1.json").readText()) val request = JSONObject(File(directory, "request-v1.json").readText())

View file

@ -841,11 +841,7 @@ class TrainlogRepository(
continue continue
} }
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision") if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
/* CONTRACT: this companion is a current-state snapshot, not a if (local == null && parent != null) return SleepDiaryImportResult.Rejected("unknown medication parent")
* revision log. A fresh peer may therefore seed the current
* revision while retaining an unavailable parent identity;
* subsequent advances must still name the locally current
* revision exactly. */
if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection") if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection")
val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value") val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value")
val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit") val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit")
@ -933,9 +929,8 @@ class TrainlogRepository(
} }
if (local != null && parent != local.first) if (local != null && parent != local.first)
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision") return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
/* The snapshot may start at a non-root current revision on a if (local == null && parent != null)
* fresh peer. Preserve its causal parent identity even though return SleepDiaryImportResult.Rejected("unknown sleep diary parent")
* the historical parent payload is intentionally absent. */
val deleted = item.getBoolean("deleted") val deleted = item.getBoolean("deleted")
if (local?.second == 1 && !deleted) if (local?.second == 1 && !deleted)
return SleepDiaryImportResult.Rejected("sleep diary resurrection") return SleepDiaryImportResult.Rejected("sleep diary resurrection")
@ -2548,14 +2543,10 @@ class TrainlogRepository(
return try { return try {
for (deliveryIndex in 0 until values.length()) { for (deliveryIndex in 0 until values.length()) {
val delivery = values.getJSONObject(deliveryIndex) val delivery = values.getJSONObject(deliveryIndex)
val legacyKeys = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id", val required = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id", "state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
"source_payload_sha256", "occurrences") "source_payload_sha256", "occurrences")
val provenanceKeys = setOf("source_program_id", "source_program_session_id") require(delivery.hasExactKeys(required)) { "Forme de préparation invalide." }
require(delivery.hasExactKeys(legacyKeys) ||
delivery.hasExactKeys(legacyKeys + provenanceKeys)) {
"Forme de préparation invalide."
}
val deliveryId = delivery.getString("delivery_id") val deliveryId = delivery.getString("delivery_id")
val preparationId = delivery.getString("preparation_id") val preparationId = delivery.getString("preparation_id")
val revisionId = delivery.getString("revision_id") val revisionId = delivery.getString("revision_id")
@ -2564,17 +2555,6 @@ class TrainlogRepository(
stableUuidV4Id(preparationId, "sp_") && stableUuidV4Id(preparationId, "sp_") &&
stableUuidV4Id(revisionId, "spr_") && stableUuidV4Id(revisionId, "spr_") &&
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." } stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
if (delivery.has("source_program_id")) {
val programId = if (delivery.isNull("source_program_id")) null else
delivery.getString("source_program_id")
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
delivery.getString("source_program_session_id")
require((programId == null) == (programSessionId == null) &&
(programId == null || (stableUuidV4Id(programId, "pg_") &&
stableUuidV4Id(programSessionId!!, "pgs_")))) {
"Provenance de programme invalide."
}
}
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." } require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
val occurrences = delivery.getJSONArray("occurrences") val occurrences = delivery.getJSONArray("occurrences")
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." } require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
@ -2601,83 +2581,9 @@ class TrainlogRepository(
}) })
imported++ imported++
} else { } else {
val existingPayload = JSONObject(existing.second) require(existing.first == revisionId && JSONObject(existing.second).toString() == canonical) {
/* WHY: the PC delivery lifecycle can advance from pending
* to acknowledged before a legacy Android row receives
* newly exported Program provenance. CONTRACT: recovery
* ignores only that transport state and the two missing
* provenance fields; every preparation fact and identity
* must remain byte-for-byte equivalent. */
val legacyEnrichment =
existing.first == revisionId &&
!existingPayload.has("source_program_id") &&
delivery.has("source_program_id") &&
JSONObject(delivery.toString()).apply {
remove("source_program_id")
remove("source_program_session_id")
remove("state")
}.toString() == JSONObject(existingPayload.toString()).apply {
remove("state")
}.toString()
require(existing.first == revisionId &&
(existingPayload.toString() == canonical || legacyEnrichment)) {
"Identité de livraison réutilisée avec un contenu différent." "Identité de livraison réutilisée avec un contenu différent."
} }
if (legacyEnrichment) {
val programId = if (delivery.isNull("source_program_id")) null else
delivery.getString("source_program_id")
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
delivery.getString("source_program_session_id")
if (programId != null && programSessionId != null) {
val currentProvenance = db.rawQuery(
"SELECT source_program_id,source_program_session_id FROM sessions " +
"WHERE session_id=? UNION ALL SELECT source_program_id," +
"source_program_session_id FROM active_session_draft WHERE session_id=?",
arrayOf(executionId, executionId),
).use { cursor ->
if (!cursor.moveToFirst()) {
null
} else {
Pair(
if (cursor.isNull(0)) null else cursor.getString(0),
if (cursor.isNull(1)) null else cursor.getString(1),
).also {
require(!cursor.moveToNext()) {
"Identité d'exécution dupliquée."
}
}
}
}
require(currentProvenance == null ||
(currentProvenance.first == null &&
currentProvenance.second == null) ||
currentProvenance == Pair(programId, programSessionId)) {
"Conflit de provenance du programme."
}
/* CONTRACT: old deliveries omitted Program
* provenance. A later canonical delivery may fill
* only those missing identities on the exact
* reserved execution session; performed facts and
* lifecycle state remain untouched. */
db.execSQL(
"UPDATE sessions SET source_program_id=?," +
"source_program_session_id=? WHERE session_id=? AND " +
"source_program_id IS NULL AND source_program_session_id IS NULL",
arrayOf(programId, programSessionId, executionId),
)
db.execSQL(
"UPDATE active_session_draft SET source_program_id=?," +
"source_program_session_id=? WHERE session_id=? AND " +
"source_program_id IS NULL AND source_program_session_id IS NULL",
arrayOf(programId, programSessionId, executionId),
)
}
db.execSQL(
"UPDATE session_preparation_deliveries SET payload_json=? " +
"WHERE delivery_id=? AND revision_id=?",
arrayOf(canonical, deliveryId, revisionId),
)
}
skipped++ skipped++
} }
} }
@ -2848,24 +2754,9 @@ class TrainlogRepository(
val payload = JSONObject(row.second) val payload = JSONObject(row.second)
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences")) val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
val sourceProgramId = if (!payload.has("source_program_id") || /* INVARIANT: the desktop-reserved execution identity survives
payload.isNull("source_program_id")) null else payload.getString("source_program_id") * start and replay; no performed value is manufactured. */
val sourceProgramSessionId = if (!payload.has("source_program_session_id") || persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type))
payload.isNull("source_program_session_id")) null else
payload.getString("source_program_session_id")
/* INVARIANT: the desktop-reserved execution and Program
* identities survive start, edits, finalization and replay;
* no performed value is manufactured by this transition. */
persistActiveSessionDraft(
db,
ActiveSessionDraft(
sessionId = row.first,
exercises = entries,
sessionType = type,
sourceProgramId = sourceProgramId,
sourceProgramSessionId = sourceProgramSessionId,
),
)
db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'", db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'",
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId)) arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
db.setTransactionSuccessful() db.setTransactionSuccessful()
@ -4883,27 +4774,7 @@ class TrainlogRepository(
val sessionExerciseRowId = exerciseCursor.getLong(0) val sessionExerciseRowId = exerciseCursor.getLong(0)
val recording = exerciseCursor.getString(3) val recording = exerciseCursor.getString(3)
val tracking = exerciseCursor.getString(4) val tracking = exerciseCursor.getString(4)
val storedPlan = readSessionPlan(exerciseCursor, 10) val exportPlan = readSessionPlan(exerciseCursor, 10)
/* WHY: a delivered continuous occurrence can retain a
* local duration target, but mobile history represents
* its completed fact exclusively through `continuous`.
* CONTRACT: validate that local-only plan without
* inventing a set-based target the Desktop schema
* forbids. INVARIANT: performed duration, speed and
* distance are still exported unchanged below. */
if (recording == "continuous" && storedPlan != null) {
check(
tracking == "duration" &&
exerciseCursor.isNull(9) &&
storedPlan.sets == 0 &&
storedPlan.reps == null &&
storedPlan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS &&
storedPlan.weightKg == null &&
storedPlan.loadMode == SessionLoadMode.NONE &&
storedPlan.restSeconds == 0,
) { "Plan continu SQLite incohérent" }
}
val exportPlan = if (recording == "continuous") null else storedPlan
val hasPlan = exportPlan != null val hasPlan = exportPlan != null
if (version < 3 && exportPlan != null) { if (version < 3 && exportPlan != null) {
error("Un plan de séance exige l'export mobile V3.") error("Un plan de séance exige l'export mobile V3.")

View file

@ -70,57 +70,6 @@ class ProgramsProjectionRepositoryTest {
.put("deletions", deletions) .put("deletions", deletions)
.toString() .toString()
private fun preparationArtifact(
includeProgramProvenance: Boolean = true,
deliveryState: String = "pending",
): String {
val occurrence =
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL)
val delivery =
JSONObject()
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
.put("state", deliveryState)
.put("title", "Séance A")
.put("session_type", "training")
.put("planned_for", JSONObject.NULL)
.put("notes", JSONObject.NULL)
.put("source_proposal_id", JSONObject.NULL)
.put("source_payload_sha256", JSONObject.NULL)
.put("occurrences", JSONArray().put(occurrence))
if (includeProgramProvenance) {
delivery
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put(
"source_program_session_id",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
}
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 2)
.put("generated_at", "2026-09-18T12:05:00Z")
.put("deliveries", JSONArray().put(delivery))
.put("withdrawals", JSONArray())
.toString()
}
private fun deletion(): JSONObject = private fun deletion(): JSONObject =
JSONObject() JSONObject()
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff") .put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
@ -245,103 +194,4 @@ class ProgramsProjectionRepositoryTest {
context.deleteDatabase(name) context.deleteDatabase(name)
} }
} }
@Test
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
val name = "program-delivery-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray().put(
JSONObject()
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("name", "Exercise fixture")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(
preparationArtifact(includeProgramProvenance = false),
),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(null, active.sourceProgramId)
assertEquals(null, active.sourceProgramSessionId)
val performed =
active.copy(
exercises =
active.exercises.map { occurrence ->
occurrence.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
)
},
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(performed),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramSessionExecutionState.TODO,
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
.sessions.single().executionState,
)
assertEquals(
AiSessionDraftImportResult.Applied(0, 1),
repository.applySessionPreparationsJson(
preparationArtifact(deliveryState = "remote_unknown"),
),
)
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
val execution =
JSONObject(repository.buildProgramExecutionsV1Json())
.getJSONArray("executions")
.getJSONObject(0)
assertEquals("completed", execution.getString("state"))
assertEquals(
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
execution.getString("program_session_id"),
)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(1, detail.sessions.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
})
assertEquals(
StartProgramSessionResult.AlreadyCompleted,
repository.startSyncedProgramSession(
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
} }

View file

@ -72,93 +72,4 @@ class SleepDiaryRepositoryTest {
assertTrue(repository.listSleepDiary().isEmpty()) assertTrue(repository.listSleepDiary().isEmpty())
} finally { repository.close(); context.deleteDatabase(name) } } finally { repository.close(); context.deleteDatabase(name) }
} }
@Test
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
val destinationName = "sleep-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val time = "2026-09-21T20:00:00+02:00"
val createdMedication =
source.saveSleepMedication(
SleepMedication("", "", time, time, "Medication", 5.0, "mg", "", "", true),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val medication = source.listSleepMedications().single()
source.saveSleepMedication(
medication.copy(name = "Medication revised", updatedAt = "2026-09-21T20:05:00+02:00"),
)
val createdEntry =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-20",
nightEndDate = "2026-09-21",
createdAt = time,
updatedAt = time,
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(
SleepDiaryEvent(
"",
SleepEventType.SLEEP,
"2026-09-20T23:00:00+02:00",
"2026-09-21T07:00:00+02:00",
),
),
intakes = listOf(
MedicationIntake(
"",
createdMedication.entryId,
"Medication",
"2026-09-20T22:00:00+02:00",
5.0,
"mg",
"",
time,
),
),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val current = source.listSleepDiary().single()
val edited =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = createdEntry.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-09-21T20:10:00+02:00",
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = SleepQuality.B,
treatmentAndNotes = current.treatmentAndNotes,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
current.entryId,
edited.revisionId,
"2026-09-21T20:15:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
assertEquals(SleepQuality.B, destination.listSleepDiary().single().dayForm)
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
}
}
} }

View file

@ -2695,46 +2695,6 @@ class TrainlogRepositoryDraftTest {
} catch (_: IllegalStateException) { } } catch (_: IllegalStateException) { }
} }
@Test
fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() {
val repo = openRepository()
val exercise = createExercise(
repo,
"Marche planifiée",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
)
val plan = SessionExercisePlan(sets = 0, durationSeconds = 600)
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
ActiveSessionDraft(
exercises = listOf(
SessionExerciseDraft(
exercise = exercise,
plan = plan,
continuousDurationSeconds = 540,
),
),
),
),
)
val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
val detail = repo.getSessionDetail(result.sessionId)!!
assertEquals(plan, detail.exercises.single().plan)
val exported =
JSONObject(repo.buildMobileExportV4Json())
.getJSONArray("sessions")
.getJSONObject(0)
.getJSONArray("exercises")
.getJSONObject(0)
assertTrue(exported.isNull("target"))
assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds"))
assertTrue(!exported.getJSONObject("continuous").has("speed_kmh"))
assertTrue(!exported.getJSONObject("continuous").has("distance_km"))
}
@Test @Test
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() { fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
val repo = openRepository() val repo = openRepository()

View file

@ -41,16 +41,7 @@ Android schema v22 adds a pending manual-preparation store which is separate
from AI proposals and the exactly-one active-session singleton. A received from AI proposals and the exactly-one active-session singleton. A received
preparation is inert until explicit start; an occupied singleton is never preparation is inert until explicit start; an occupied singleton is never
overwritten. Starting preserves the desktop-reserved execution `session_id`, overwritten. Starting preserves the desktop-reserved execution `session_id`,
copies targets only and creates no performed facts. A Program-owned preparation copies targets only and creates no performed facts.
also preserves `source_program_id` and `source_program_session_id` through the
active draft and completed session. A legacy stored delivery may receive those
two omitted fields later only when its revision, complete prior payload and
reserved execution identity match exactly; this repairs provenance without
altering performed workout facts.
Acknowledged historical deliveries use the same exact-identity recovery. A
local continuous duration target is validated during generation capture while
the completed duration, speed and distance remain represented by the existing
`continuous` history payload rather than an invented set target.
Android schema v23 adds the durable manual-preparation withdrawal ledger. The Android schema v23 adds the durable manual-preparation withdrawal ledger. The
schema is unchanged by Web proposal withdrawal: the existing durable AI draft schema is unchanged by Web proposal withdrawal: the existing durable AI draft

View file

@ -430,17 +430,6 @@ ACK without duplicating the execution or resurrecting the Program. A final
correlated exchange remained completed with no missing capability. The real correlated exchange remained completed with no missing capability. The real
Program remains active and unique with 24 sessions and 164 ordered entries. Program remains active and unique with 24 sessions and 164 ordered entries.
Program-owned manual preparations now carry their stable Program provenance to
Android. Starting and finalizing such a delivery retains both identities, and a
strict same-delivery enrichment repairs completed sessions produced from the
earlier omission without changing workout facts. Android and Web therefore
derive the completed count and per-session state from the same execution
identity after synchronization and restart.
The historical S1 A delivery was recovered onto its original completed
`session_id`; Android and Web now both report one completed session out of 24.
Generation capture also accepts its valid local continuous duration plans
without projecting them as unsupported set targets.
The private Programs rollout migrated desktop schema v25 to v26 and Android The private Programs rollout migrated desktop schema v25 to v26 and Android
schema v23 to v24 after verified backups, then installed the signed schema v23 to v24 after verified backups, then installed the signed
non-debuggable versionCode 16 APK without uninstalling or clearing data. The non-debuggable versionCode 16 APK without uninstalling or clearing data. The

View file

@ -71,11 +71,7 @@ full run, generation, peer, hash and ACK envelope and never starts a workout.
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android `trainlog-session-preparations` V2 is a separate optional desktop-to-Android
generation artifact. It carries immutable ready revision snapshots and stable generation artifact. It carries immutable ready revision snapshots and stable
delivery, preparation, occurrence and reserved execution identities, plus delivery, preparation, occurrence and reserved execution identities, plus
immutable preparation-withdrawal operations. Program-owned deliveries also immutable preparation-withdrawal operations. It never
carry the paired stable Program and Program-session identities. Android keeps
reading legacy deliveries that omitted this pair and accepts a later enrichment
only when all legacy fields and the reserved execution identity are identical.
It never
overloads mobile export, AI proposals or execution-drafts V1. Android validates overloads mobile export, AI proposals or execution-drafts V1. Android validates
the complete artifact in the generation transaction and stores it as pending; the complete artifact in the generation transaction and stores it as pending;
start remains an explicit foreground action. Desktop records the exact start remains an explicit foreground action. Desktop records the exact
@ -250,16 +246,6 @@ is idempotent; identity reuse or regression is a hard conflict. Because the
descriptor is optional, peers predating this companion keep exchanging all descriptor is optional, peers predating this companion keep exchanging all
existing required domains without a capability failure. existing required domains without a capability failure.
For Program-owned manual preparations, an acknowledged delivery is republished
while its matching canonical execution evidence is absent on the PC. This
bounded recovery lets Android enrich legacy deliveries with Program provenance
and stops automatically when the execution returns through this companion.
The recovery emission retains the delivery's original acknowledged generation
relation and requires exact delivery, revision, execution and Program
identities. A current-state Sleep Diary snapshot may initialize a fresh peer
from a non-root current revision while retaining the unavailable parent
identity for subsequent causal advances.
Desktop schema v28 adds terminal `deleted` provenance for a completed session Desktop schema v28 adds terminal `deleted` provenance for a completed session
removed through causal history deletion. A retained older removed through causal history deletion. A retained older
`program-executions-v1` fact is skipped only when the matching session has a `program-executions-v1` fact is skipped only when the matching session has a

View file

@ -15,11 +15,6 @@ removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4
path. A real Firefox run through the production C server and `trainlog.perf` path. A real Firefox run through the production C server and `trainlog.perf`
creates events, medications and intakes, reloads their durable state, checks a creates events, medications and intakes, reloads their durable state, checks a
responsive viewport and captures console errors and unhandled rejections. responsive viewport and captures console errors and unhandled rejections.
Program recovery coverage starts from an acknowledged legacy preparation,
repairs the same completed Android `session_id`, publishes one completed
execution and verifies Web `1 / 24` presentation. Related regressions cover
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
non-root current revisions.
`web_analysis` covers empty history, every 7/30/90/all period selector, `web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit loaded and unloaded repetitions, continuous duration/distance/speed, explicit
@ -135,13 +130,6 @@ through Program deletion, restart and old-generation replay without duplication
or resurrection. The desktop populated migration test now continues through or resurrection. The desktop populated migration test now continues through
schema v27 and proves the new execution ledger starts empty. schema v27 and proves the new execution ledger starts empty.
The delivered-Program regression covers the previously omitted provenance
pair end to end: legacy preparation receipt, start, performed capture,
finalization, exact-payload enrichment, completed Android projection, execution
companion output and restart. Export tests assert both stable IDs, the C Web API
asserts `completed`, and the Web component verifies `1/24`, `Effectuée` and the
absence of a preparation action on the completed session.
`schema_v28_migration` migrates a populated v27 fixture, preserves Program, `schema_v28_migration` migrates a populated v27 fixture, preserves Program,
session and execution identities byte-for-byte, admits the terminal deleted session and execution identities byte-for-byte, admits the terminal deleted
state, reports `integrity_check=ok` and an empty foreign-key check, and proves state, reports `integrity_check=ok` and an empty foreign-key check, and proves

View file

@ -22,8 +22,6 @@ class SessionPreparationExportTest(unittest.TestCase):
current_revision_id TEXT NOT NULL, current_revision_id TEXT NOT NULL,
source_proposal_id TEXT, source_proposal_id TEXT,
source_payload_sha256 TEXT, source_payload_sha256 TEXT,
source_program_id TEXT,
source_program_session_id TEXT,
withdrawn_at TEXT withdrawn_at TEXT
); );
CREATE TABLE session_preparation_revisions( CREATE TABLE session_preparation_revisions(
@ -58,13 +56,6 @@ class SessionPreparationExportTest(unittest.TestCase):
state TEXT NOT NULL, state TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE program_session_executions(
program_session_id TEXT PRIMARY KEY,
program_id TEXT NOT NULL,
session_id TEXT NOT NULL UNIQUE,
state TEXT NOT NULL,
observed_at TEXT NOT NULL
);
CREATE TABLE session_preparation_withdrawals( CREATE TABLE session_preparation_withdrawals(
withdrawal_id TEXT PRIMARY KEY, withdrawal_id TEXT PRIMARY KEY,
preparation_id TEXT NOT NULL, preparation_id TEXT NOT NULL,
@ -86,14 +77,12 @@ class SessionPreparationExportTest(unittest.TestCase):
delivery = f"spd_{suffix}" delivery = f"spd_{suffix}"
execution = f"se_{suffix}" execution = f"se_{suffix}"
self.database.execute( self.database.execute(
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?,?)", "INSERT INTO session_preparations VALUES(?,?,?,?,?)",
( (
preparation, preparation,
revision, revision,
"aid_11111111-1111-4111-8111-111111111111", "aid_11111111-1111-4111-8111-111111111111",
"a" * 64, "a" * 64,
"pg_11111111-1111-4111-8111-111111111111",
"pgs_22222222-2222-4222-8222-222222222222",
"2026-09-18T12:00:00Z" if withdrawn else None, "2026-09-18T12:00:00Z" if withdrawn else None,
), ),
) )
@ -131,14 +120,6 @@ class SessionPreparationExportTest(unittest.TestCase):
self.assertEqual("trainlog-session-preparations", payload["format"]) self.assertEqual("trainlog-session-preparations", payload["format"])
self.assertEqual(2, payload["version"]) self.assertEqual(2, payload["version"])
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]]) self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
self.assertEqual(
"pg_11111111-1111-4111-8111-111111111111",
payload["deliveries"][0]["source_program_id"],
)
self.assertEqual(
"pgs_22222222-2222-4222-8222-222222222222",
payload["deliveries"][0]["source_program_session_id"],
)
self.assertEqual(1, len(payload["withdrawals"])) self.assertEqual(1, len(payload["withdrawals"]))
removal = payload["withdrawals"][0] removal = payload["withdrawals"][0]
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"]) self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
@ -166,35 +147,6 @@ class SessionPreparationExportTest(unittest.TestCase):
self.assertEqual([], payload["deliveries"]) self.assertEqual([], payload["deliveries"])
self.assertEqual([], payload["withdrawals"]) self.assertEqual([], payload["withdrawals"])
def test_acknowledged_program_delivery_is_republished_until_execution_returns(self):
suffix = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
self.add_preparation(suffix, withdrawn=False)
self.database.execute(
"UPDATE session_preparation_deliveries SET state='acknowledged'"
)
self.database.commit()
payload = export_session_preparations.build_export(self.database)
self.assertEqual([f"spd_{suffix}"], [row["delivery_id"] for row in payload["deliveries"]])
self.assertEqual("remote_unknown", payload["deliveries"][0]["state"])
self.database.execute(
"INSERT INTO program_session_executions VALUES(?,?,?,?,?)",
(
"pgs_22222222-2222-4222-8222-222222222222",
"pg_11111111-1111-4111-8111-111111111111",
f"se_{suffix}",
"completed",
"2026-09-18T12:10:00Z",
),
)
self.database.commit()
payload = export_session_preparations.build_export(self.database)
self.assertEqual([], payload["deliveries"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -168,60 +168,6 @@ class GenerationTest(unittest.TestCase):
"SELECT acknowledged_at FROM session_preparation_withdrawals", "SELECT acknowledged_at FROM session_preparation_withdrawals",
).fetchone()[0], ).fetchone()[0],
) )
def test_acknowledged_program_recovery_keeps_original_generation_relation(self):
delivery = "spd_55555555-5555-4555-8555-555555555555"
preparation = "sp_66666666-6666-4666-8666-666666666666"
revision = "spr_77777777-7777-4777-8777-777777777777"
execution = "se_88888888-8888-4888-8888-888888888888"
program = "pg_99999999-9999-4999-8999-999999999999"
program_session = "pgs_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
original_generation = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
with closing(sqlite3.connect(self.database)) as db:
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_id TEXT")
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_session_id TEXT")
db.execute(
"CREATE TABLE program_session_executions(program_session_id TEXT PRIMARY KEY,"
"program_id TEXT,session_id TEXT UNIQUE,state TEXT,observed_at TEXT)"
)
db.execute(
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?)",
(preparation, revision, "acknowledged", None, program, program_session),
)
db.execute(
"INSERT INTO session_preparation_deliveries VALUES(?,?,?,?,?,?,?,?)",
(delivery, preparation, revision, execution, "acknowledged",
"2026-09-21T12:00:00Z", original_generation, "2026-09-21T12:05:00Z"),
)
db.commit()
artifacts = (("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "controlled.py", ()),)
payload = {
"format": "trainlog-session-preparations", "version": 2,
"generated_at": "2026-09-21T12:10:00Z", "withdrawals": [],
"deliveries": [{
"delivery_id": delivery, "preparation_id": preparation,
"revision_id": revision, "execution_session_id": execution,
"state": "remote_unknown", "source_program_id": program,
"source_program_session_id": program_session,
}],
}
def exporter(_tool, _extra, output, _snapshot):
output.write_text(json.dumps(payload))
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \
mock.patch.object(generation, "run_export", exporter):
generation.capture_desktop(
self.database, self.root / "program-recovery", PEER_B, RUN, GEN,
)
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual(
("acknowledged", original_generation),
db.execute(
"SELECT state,generation_id FROM session_preparation_deliveries "
"WHERE delivery_id=?", (delivery,),
).fetchone(),
)
def test_substitution_and_transaction_failure_leave_no_consumption(self): def test_substitution_and_transaction_failure_leave_no_consumption(self):
_,manifest,checksum=self.capture();published=self.publish() _,manifest,checksum=self.capture();published=self.publish()
(published/"history.json").write_text("{}") (published/"history.json").write_text("{}")

View file

@ -22,21 +22,12 @@ def build_export(connection: sqlite3.Connection) -> dict:
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required") raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
deliveries = [] deliveries = []
rows = connection.execute( rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id," "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
"CASE WHEN d.state='acknowledged' THEN 'remote_unknown' ELSE d.state END," "r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256,"
"p.source_program_id,p.source_program_session_id "
"FROM session_preparation_deliveries d " "FROM session_preparation_deliveries d "
"JOIN session_preparations p ON p.preparation_id=d.preparation_id " "JOIN session_preparations p ON p.preparation_id=d.preparation_id "
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id " "JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
"WHERE p.withdrawn_at IS NULL AND (" "WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
"d.state IN('pending','remote_unknown') OR ("
"d.state='acknowledged' AND p.source_program_id IS NOT NULL AND "
"p.source_program_session_id IS NOT NULL AND NOT EXISTS("
"SELECT 1 FROM program_session_executions x "
"WHERE x.program_session_id=p.source_program_session_id "
"AND x.program_id=p.source_program_id AND x.session_id=d.execution_session_id"
"))) "
"ORDER BY d.created_at,d.delivery_id LIMIT ?", "ORDER BY d.created_at,d.delivery_id LIMIT ?",
(MAX_DELIVERIES + 1,), (MAX_DELIVERIES + 1,),
).fetchall() ).fetchall()
@ -57,7 +48,6 @@ def build_export(connection: sqlite3.Connection) -> dict:
"execution_session_id": row[3], "state": row[4], "title": row[5], "execution_session_id": row[3], "state": row[4], "title": row[5],
"session_type": row[6], "planned_for": row[7], "notes": row[8], "session_type": row[6], "planned_for": row[7], "notes": row[8],
"source_proposal_id": row[9], "source_payload_sha256": row[10], "source_proposal_id": row[9], "source_payload_sha256": row[10],
"source_program_id": row[11], "source_program_session_id": row[12],
"occurrences": [{ "occurrences": [{
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2], "entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
"equipment_id": entry[3], "recording_mode": entry[4], "equipment_id": entry[3], "recording_mode": entry[4],

View file

@ -430,34 +430,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
(generation, delivery["delivery_id"], delivery["revision_id"]), (generation, delivery["delivery_id"], delivery["revision_id"]),
) )
if cursor.rowcount != 1: if cursor.rowcount != 1:
# WHY: Program-provenance recovery republishes an raise GenerationError("preparation delivery changed during capture")
# already acknowledged legacy delivery as
# remote_unknown until its canonical execution comes
# back. CONTRACT: this exception is allowed only for
# the exact immutable delivery/revision/execution and
# Program identities captured in the artifact.
# INVARIANT: no delivery lifecycle or generation
# relation is rewritten by the recovery emission.
recovery = db.execute(
"SELECT 1 FROM session_preparation_deliveries d "
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
"WHERE d.delivery_id=? AND d.revision_id=? "
"AND d.execution_session_id=? AND d.state='acknowledged' "
"AND p.source_program_id=? AND p.source_program_session_id=? "
"AND NOT EXISTS(SELECT 1 FROM program_session_executions x "
"WHERE x.program_id=p.source_program_id "
"AND x.program_session_id=p.source_program_session_id "
"AND x.session_id=d.execution_session_id)",
(
delivery["delivery_id"],
delivery["revision_id"],
delivery["execution_session_id"],
delivery.get("source_program_id"),
delivery.get("source_program_session_id"),
),
).fetchone()
if recovery is None:
raise GenerationError("preparation delivery changed during capture")
for withdrawal in captured_preparations["withdrawals"]: for withdrawal in captured_preparations["withdrawals"]:
cursor = db.execute( cursor = db.execute(
"UPDATE session_preparation_withdrawals SET generation_id=? " "UPDATE session_preparation_withdrawals SET generation_id=? "

View file

@ -317,26 +317,6 @@ static bool import_archive_and_prepare(void) {
free(response); free(response);
response = NULL; response = NULL;
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions("
"program_session_id,program_id,session_id,state,observed_at) VALUES("
"'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',"
"'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',"
"'se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee','completed',"
"'2026-09-21T12:00:00Z')",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_detail_json(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"execution_state\":\"completed\"") != NULL);
CHECK(strstr(response,
"\"execution_session_id\":"
"\"se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee\"") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_archive_json(database, CHECK(trainlog_web_programs_archive_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision, revision,

View file

@ -206,56 +206,4 @@ describe('Programs tab', () => {
expect(fetchMock.mock.calls.filter(([, options]) => expect(fetchMock.mock.calls.filter(([, options]) =>
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1) (options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
}) })
it('counts a completed program session and no longer offers to prepare it', async () => {
const sessions = Array.from({ length: 24 }, (_, index) => ({
program_session_id: `pgs_${index}`,
position: index,
title: index === 0 ? 'S1 A — Ceinture abdominale + jambes' : `Séance ${index + 1}`,
session_type: 'training',
planned_for: null,
note: null,
execution_state: index === 0 ? 'completed' : 'todo',
execution_session_id: index === 0 ? 'se_completed' : null,
occurrences: [],
}))
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/v1/sessions/program/pg_test') {
return jsonResponse({
api_version: 1,
program_id: 'pg_test',
title: 'Programme 24 séances',
note: null,
state: 'active',
start_date: null,
end_date: null,
created_at: '2026-09-18T12:00:00Z',
updated_at: '2026-09-21T12:00:00Z',
revision_id: 'revision',
source_format: 'trainlog-program',
source_version: 1,
source_payload_sha256: 'a'.repeat(64),
sessions,
})
}
throw new Error(`unexpected request ${url}`)
}))
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
expect(await screen.findByText('1/24')).toBeInTheDocument()
expect(screen.getByText('4% effectué')).toBeInTheDocument()
const completed = screen
.getByText(/S1 A — Ceinture abdominale \+ jambes/)
.closest('li')
expect(completed).not.toBeNull()
expect(within(completed as HTMLElement).getByText('Effectuée')).toBeInTheDocument()
expect(
within(completed as HTMLElement).queryByRole('button', {
name: 'Créer une préparation',
}),
).toBeNull()
expect(screen.getAllByRole('button', { name: 'Créer une préparation' })).toHaveLength(23)
})
}) })

View file

@ -5,7 +5,7 @@ import {
waitFor, waitFor,
within, within,
} from "@testing-library/react"; } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { import type {
SleepEntry, SleepEntry,
SleepEntryInput, SleepEntryInput,
@ -170,8 +170,6 @@ const dayAEntry = (): SleepEntry => ({
describe("SleepDiaryWorkspace", () => { describe("SleepDiaryWorkspace", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
vi.clearAllMocks(); vi.clearAllMocks();
api.nextId = 0; api.nextId = 0;
api.fetchSleepDiary.mockResolvedValue(emptySnapshot); api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
@ -183,10 +181,6 @@ describe("SleepDiaryWorkspace", () => {
})); }));
}); });
afterEach(() => {
vi.useRealTimers();
});
it.each([ it.each([
["bed_time", "Mise au lit", "22:30", null], ["bed_time", "Mise au lit", "22:30", null],
["sleep", "Sommeil", "23:00", "06:30"], ["sleep", "Sommeil", "23:00", "06:30"],