diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index 4cab9ce..fa099ed 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -294,7 +294,20 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) repository.inSyncGenerationTransaction { db -> val retained = db.rawQuery( - "SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)", + /* WHY: early deployed generation builds could advance a + * producer row to acknowledged without retaining the ACK + * ledger row. Such terminal rows cannot be archived + * safely because Trainlog must not fabricate evidence, + * but they must not exhaust the active admission window + * forever. Current acknowledgements always have durable + * ledger evidence and remain counted until archived. + * INVARIANT: captured, published and unacknowledged rows + * are always retained and never pruned for capacity. */ + "SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " + + "(status IN('captured','published','waiting_acknowledgement') OR " + + "(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " + + "WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" + + "SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)", arrayOf(consumerPeerId), ) .use { diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 4b36f86..a6faded 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -872,6 +872,13 @@ class SyncGenerationServiceTest { events = emptyList(), ), ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + sleepEntry.entryId, + sleepEntry.revisionId, + "2026-09-23T07:00:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved, + ) val heartCapture = source.startHeartRateCapture( HeartRateContextKind.SLEEP, @@ -932,6 +939,10 @@ class SyncGenerationServiceTest { "acknowledged", sourceService.acceptAcknowledgement(androidAckFile.readBytes()), ) + assertEquals( + "unchanged", + sourceService.acceptAcknowledgement(androidAckFile.readBytes()), + ) assertEquals( 0, JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(), @@ -958,6 +969,69 @@ class SyncGenerationServiceTest { .trim(), ) + /* A second Sleep-only mutation must advance the same diary entry + * after the first correlated ACK. No new workout is created, and + * replaying either generation remains strictly idempotent. */ + val revisedSleep = + source.saveSleepDiary( + SleepDiaryDraft( + entryId = sleepEntry.entryId, + expectedRevision = sleepEntry.revisionId, + nightStartDate = "2026-09-22", + nightEndDate = "2026-09-23", + createdAt = "2026-09-22T22:00:00+02:00", + updatedAt = "2026-09-23T08:00:00+02:00", + sleepQuality = null, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "second sleep-only revision", + events = emptyList(), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + revisedSleep.entryId, + revisedSleep.revisionId, + "2026-09-23T08:01:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved, + ) + val secondSleepGeneration = sourceService.capture(root, desktopPeer) + val secondSleepPublished = + sourceService.publish( + secondSleepGeneration, + java.io.File(root, "android-desktop-objects"), + ) + val secondAckFile = java.io.File(root, "android-desktop-second-ack.json") + run( + "python3", + java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath, + "consume-desktop", + secondSleepPublished.absolutePath, + "--database", + desktopConsumerDb.absolutePath, + "--ack-output", + secondAckFile.absolutePath, + ) + assertEquals( + "acknowledged", + sourceService.acceptAcknowledgement(secondAckFile.readBytes()), + ) + assertEquals( + "unchanged", + sourceService.acceptAcknowledgement(secondAckFile.readBytes()), + ) + assertEquals( + "1|${revisedSleep.revisionId}|1", + run( + "sqlite3", + desktopConsumerDb.absolutePath, + "SELECT count(*) || '|' || current_revision_id || '|' || " + + "(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " + + "FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';", + ) + .trim(), + ) + run( java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath, desktopDb.absolutePath, diff --git a/tui/src/web_session_deletions.c b/tui/src/web_session_deletions.c index 940d877..98d9d37 100644 --- a/tui/src/web_session_deletions.c +++ b/tui/src/web_session_deletions.c @@ -126,7 +126,10 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database, char revision[TRAINLOG_ID_MAX + 1U], bool *active_draft) { static const char DRAFT_SQL[] = - "SELECT revision_id,state FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS(" + "SELECT revision_id,CASE WHEN state='active' AND " + "COALESCE(json_array_length(payload_json,'$.exercises')," + "json_array_length(payload_json,'$.entries'),0)>0 THEN 'active' ELSE '' END " + "FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS(" "SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 " "AND deleted=1)"; static const char HISTORY_SQL[] = @@ -156,6 +159,11 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database, return TRAINLOG_STATUS_DATABASE_ERROR; } (void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current); + /* WHY: an imported zero-occurrence active row is an orphaned projection, + * not a resumable Android workout. CONTRACT: Web may causally delete that + * exact canonical row, while a genuinely active draft with occurrences + * remains protected from concurrent deletion. INVARIANT: the tombstone is + * stored by the existing transaction and prevents replay resurrection. */ *active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0; return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c index c17e894..56d8352 100644 --- a/tui/src/web_sessions.c +++ b/tui/src/web_sessions.c @@ -46,7 +46,8 @@ static const ListDefinition LISTS[] = { {"draft", "SELECT " "d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d." - "payload_json,'$.exercises'),0),COALESCE(d.started_at,''),NULL,NULL " + "payload_json,'$.exercises'),json_array_length(d.payload_json,'$.entries'),0)," + "COALESCE(d.started_at,''),NULL,NULL " "FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " "f.session_id=d.session_id) " "AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND " diff --git a/tui/tests/test_web_session_deletions.c b/tui/tests/test_web_session_deletions.c index f7e19c5..3cdcb5b 100644 --- a/tui/tests/test_web_session_deletions.c +++ b/tui/tests/test_web_session_deletions.c @@ -158,7 +158,8 @@ static bool active_draft_conflicts_without_mutation(void) { CHECK(sqlite3_exec(database->connection, "INSERT INTO execution_drafts VALUES(" "'se_55555555-5555-4555-8555-555555555555','training',NULL," - "'2026-09-18T10:00:00Z','mu_active',NULL,'active','{}');", + "'2026-09-18T10:00:00Z','mu_active',NULL,'active'," + "'{\"exercises\":[{\"entry_id\":\"entry_live\"}]}');", NULL, NULL, NULL) == SQLITE_OK); @@ -177,6 +178,53 @@ static bool active_draft_conflicts_without_mutation(void) { return true; } +static bool empty_active_draft_is_causally_deleted_and_replay_safe(void) { + char path[] = "/tmp/trainlog-web-delete-empty-active-XXXXXX"; + TrainlogDatabase *database = NULL; + char *response = NULL; + char *replayed = NULL; + size_t response_size = 0U; + size_t replayed_size = 0U; + int descriptor = mkstemp(path); + + CHECK(descriptor >= 0); + CHECK(close(descriptor) == 0); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO execution_drafts VALUES(" + "'se_56565656-5656-4565-8565-565656565656','training',NULL," + "'2026-09-20T10:00:00Z','mu_empty',NULL,'active'," + "'{\"exercises\":[]}');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_session_delete_json(database, + "draft", + "se_56565656-5656-4565-8565-565656565656", + "mu_empty", + "request-delete-empty", + &response, + &response_size) == TRAINLOG_STATUS_OK); + CHECK(trainlog_web_session_delete_json(database, + "draft", + "se_56565656-5656-4565-8565-565656565656", + "mu_empty", + "request-delete-empty", + &replayed, + &replayed_size) == TRAINLOG_STATUS_OK); + CHECK(response_size == replayed_size && memcmp(response, replayed, response_size) == 0); + CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 0); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sync_causal_state WHERE target_kind='execution_draft' " + "AND target_id='se_56565656-5656-4565-8565-565656565656' AND deleted=1") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 1); + free(replayed); + free(response); + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return true; +} + static bool completed_program_execution_deletion_is_atomic_and_idempotent(void) { char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX"; TrainlogDatabase *database = NULL; @@ -276,6 +324,7 @@ int main(void) { return proposal_deletion_is_durable_and_preserves_derivatives() && draft_and_history_use_causal_deletion() && active_draft_conflicts_without_mutation() && + empty_active_draft_is_causally_deleted_and_replay_safe() && completed_program_execution_deletion_is_atomic_and_idempotent() ? EXIT_SUCCESS : EXIT_FAILURE; diff --git a/web/src/api/equipment.test.ts b/web/src/api/equipment.test.ts index 39bf3fc..6882149 100644 --- a/web/src/api/equipment.test.ts +++ b/web/src/api/equipment.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { parseEquipment } from './equipment' +import { describe, expect, it, vi } from 'vitest' +import { mergeEquipment, parseEquipment } from './equipment' describe('equipment API', () => { it('accepts bounded factual merge consequences', () => { @@ -19,4 +19,33 @@ describe('equipment API', () => { historical_occurrences: 0, preparation_references: 0, program_references: 0, }] })).toThrow('equipment_invalid') }) + + it('merges on an insecure origin without crypto.randomUUID', async () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => { + bytes.fill(0x11) + return bytes + }, + randomUUID: undefined, + }) + const token = 'a'.repeat(64) + const response = (body: unknown, headers: Record = {}) => ({ + ok: true, + headers: new Headers(headers), + json: async () => body, + }) + const fetch = vi.fn() + .mockResolvedValueOnce(response({ phase: 'idle', result: 'ready' }, { + 'X-Trainlog-CSRF-Token': token, + })) + .mockResolvedValueOnce(response({ api_version: 1, items: [] })) + vi.stubGlobal('fetch', fetch) + + await expect(mergeEquipment('canonical', 'duplicate')).resolves.toEqual({ + api_version: 1, + items: [], + }) + expect(fetch.mock.calls[1][1].headers['X-Trainlog-Request-ID']) + .toBe('11111111-1111-4111-9111-111111111111') + }) }) diff --git a/web/src/api/equipment.ts b/web/src/api/equipment.ts index 8555396..b233614 100644 --- a/web/src/api/equipment.ts +++ b/web/src/api/equipment.ts @@ -1,4 +1,4 @@ -import { mutationCsrfToken } from './sync' +import { mutationCsrfToken, newUuidV4 } from './sync' export interface EquipmentItem { equipment_id: string @@ -41,7 +41,7 @@ export async function mergeEquipment(canonicalId: string, duplicateId: string): headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': await mutationCsrfToken(), - 'X-Trainlog-Request-ID': crypto.randomUUID(), + 'X-Trainlog-Request-ID': newUuidV4(), }, body: JSON.stringify({ canonical_id: canonicalId, duplicate_id: duplicateId }), }) diff --git a/web/src/api/exercises.ts b/web/src/api/exercises.ts index 5c3a3b6..3dab662 100644 --- a/web/src/api/exercises.ts +++ b/web/src/api/exercises.ts @@ -1,4 +1,4 @@ -import { mutationCsrfToken } from './sync' +import { mutationCsrfToken, newUuidV4 } from './sync' export type RecordingMode = 'sets' | 'continuous' export type TrackingMode = 'reps' | 'duration' @@ -113,7 +113,7 @@ export async function fetchBodyZones(signal?: AbortSignal): Promise { const csrf = await mutationCsrfToken() - const requestId = crypto.randomUUID() + const requestId = newUuidV4() const headers: Record = { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': csrf, diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index f77280d..d068522 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -157,7 +157,7 @@ export async function startSync(requestId: string): Promise { if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token return parse(value) } -export function newRequestId(): string { +export function newUuidV4(): string { /* * WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure * context, so randomUUID() is legitimately unavailable there. @@ -176,7 +176,10 @@ export function newRequestId(): string { hexadecimal.slice(8, 10).join(''), hexadecimal.slice(10, 16).join(''), ].join('-') - return `sy_${uuid}` + return uuid +} +export function newRequestId(): string { + return `sy_${newUuidV4()}` } export function syncIsActive(phase: SyncPhase): boolean { return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)