solo-storage 0.10.0

Solo: SQLite + SQLCipher persistence layer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
// SPDX-License-Identifier: Apache-2.0

//! v0.7.1 → v0.8.0 mass-data-move helper.
//!
//! This is the highest-risk module in the v0.8.0 release: it reorganises a
//! v0.7.1 user's data dir from the single-DB layout
//!
//! ```text
//! <data_dir>/solo.db
//! <data_dir>/solo.db-wal
//! <data_dir>/solo.db-shm
//! <data_dir>/hnsw_episodes.hnsw.data
//! <data_dir>/hnsw_episodes.hnsw.graph
//! <data_dir>/hnsw_episodes_bak.hnsw.{data,graph}     (if present)
//! <data_dir>/hnsw_episodes_tmp.hnsw.{data,graph}     (orphaned tmp, if present)
//! ```
//!
//! into the v0.8.0 per-tenant layout
//!
//! ```text
//! <data_dir>/tenants_index.db
//! <data_dir>/tenants/default.db
//! <data_dir>/tenants/default.db-wal
//! <data_dir>/tenants/default.db-shm
//! <data_dir>/tenants/hnsw_episodes.hnsw.data
//! <data_dir>/tenants/hnsw_episodes.hnsw.graph
//! <data_dir>/tenants/hnsw_episodes_bak.hnsw.{data,graph}    (if present)
//! <data_dir>/tenants/hnsw_episodes_tmp.hnsw.{data,graph}    (if present)
//! ```
//!
//! v0.8.0 P1 single-tenant case: HNSW snapshots move INTO the tenants/
//! directory but keep their original basenames (no `<tenant_id>.` prefix).
//! The snapshot module is told to look in `<data_dir>/tenants/` rather
//! than at the data dir root for v0.8.0 layouts. P2 introduces a
//! per-tenant snapshot subdir (`tenants/<id>/...`) for the multi-tenant
//! case; for P1 the flat `tenants/` works because there is only the
//! one (default) tenant. (Alternatively P2 may opt for a per-tenant
//! prefix on filenames inside a flat dir — either works; P1 doesn't
//! lock that in.)
//!
//! ## Crash-recovery contract
//!
//! The helper is idempotent + crash-recoverable. After **any** crash during
//! the move, re-running the helper completes whatever's still pending and
//! leaves the data dir in the active state.
//!
//! Ordering:
//!
//!   1. Create `<data_dir>/tenants/`. Cheap; safe to retry.
//!   2. Open / create `tenants_index.db` (applies migration 0004 if needed).
//!   3. If the `default` row is absent, INSERT it with
//!      `status = 'pending_migration'`. This is the durable marker that
//!      "we promised to move files for this tenant". Committed to disk
//!      BEFORE any file is touched, so a crash here means a re-run sees
//!      `pending_migration` and resumes from step 4.
//!   4. Move the SQLCipher main file (`solo.db` → `tenants/default.db`).
//!      Same-filesystem rename is atomic. If `solo.db` is already absent
//!      AND the destination exists, this step is a no-op.
//!   5. Move the WAL + SHM sidecars + every HNSW snapshot file in the same
//!      independent fashion. Each is "rename if source exists and
//!      destination does not". Order doesn't matter for correctness — the
//!      DB at the destination can be re-opened without WAL/SHM, and HNSW
//!      can be rebuilt from SQL if its snapshot files are missing.
//!   6. Flip the registry status from `pending_migration` to `active`.
//!      This is the "we're done" durable marker. If we crash before this
//!      step, all the files are in their destination but the registry
//!      still says we're in the middle of a move — re-run completes the
//!      flip.
//!
//! ## Idempotency invariants
//!
//!   * Re-opening `TenantsIndex` is idempotent (migration 0004 runs only
//!     once, tracked in `schema_migrations_tenants_index`).
//!   * Inserting the `default` row uses `INSERT OR IGNORE` semantics
//!     emulated via an explicit lookup so we don't silently swallow other
//!     SQL errors.
//!   * File renames use the "source exists AND destination does not"
//!     guard. After a successful first run, subsequent runs see the
//!     destination already present and skip.
//!   * The final `set_status(Active)` is unconditional — running it twice
//!     is fine; the second run no-ops by writing the same value.
//!
//! ## What this does NOT do
//!
//!   * Does not validate that the moved files actually open under the
//!     same SQLCipher key — that smoke-test is done by the caller (e.g.,
//!     `init::init()` reopens the migrated default tenant after this
//!     helper returns).
//!   * Does not detect arbitrary corruption of the v0.7.1 data dir. If
//!     `solo.db` exists but `solo.db-wal` is half-written, that's a
//!     pre-existing v0.7.1 corruption issue; we move the WAL as-is and
//!     let SQLite's recovery path handle it on first open.

use solo_core::{Result, TenantId};
use std::path::Path;

use super::{TENANTS_SUBDIR, TenantStatus, TenantsIndex};
use crate::key_material::KeyMaterial;
use crate::snapshot::{BAK_BASENAME, LIVE_BASENAME, TMP_BASENAME};
use solo_core::Error;

/// Suffixes on the two hnsw_rs snapshot files per basename. Mirrors
/// `crate::snapshot::DATA_SUFFIX` + `GRAPH_SUFFIX` which are private to
/// that module; duplicating two strings here keeps this module
/// self-contained.
const HNSW_DATA_SUFFIX: &str = ".hnsw.data";
const HNSW_GRAPH_SUFFIX: &str = ".hnsw.graph";

/// Apply the v0.7.1 → v0.8.0 layout migration to a data dir.
///
/// Idempotent + crash-recoverable. See module docs for the full
/// sequencing contract.
///
/// The `key` is the SQLCipher key derived from the user's passphrase + the
/// salt in `solo.config.toml` — used both to open the new
/// `tenants_index.db` and as the same key the moved per-tenant DB will be
/// opened with after the move (since the SQLCipher file itself is
/// untouched by the rename).
///
/// Returns `Ok(())` on success, regardless of how much work was actually
/// done (full migration on first run; no-op on subsequent runs against an
/// already-active default tenant; partial-resume on re-run after a crash).
pub fn migrate_v071_to_v080(data_dir: &Path, key: &KeyMaterial) -> Result<()> {
    // STEP 1 — ensure tenants/ exists.
    let tenants_dir = data_dir.join(TENANTS_SUBDIR);
    std::fs::create_dir_all(&tenants_dir).map_err(|e| {
        Error::storage(format!(
            "create tenants subdir {}: {e}",
            tenants_dir.display()
        ))
    })?;

    // STEP 2 — open / create tenants_index.db. Applies migration 0004 if
    // needed, idempotent on existing instance.
    let mut index = TenantsIndex::open(data_dir, key)?;

    let default_id = TenantId::default_tenant();
    let default_db_filename = format!("{}.db", default_id.as_str());

    // STEP 3 — register the default tenant if missing. Initial status is
    // PendingMigration so a crash between here and the file moves is
    // visible on re-run.
    let existing = index.lookup(&default_id)?;
    let needs_move = match existing.as_ref().map(|r| r.status) {
        // Never registered → register with PendingMigration, then move
        // files below.
        None => {
            index.register_with_status(
                &default_id,
                &default_db_filename,
                Some("Default tenant (migrated from v0.7.1)"),
                TenantStatus::PendingMigration,
            )?;
            true
        }
        // Mid-migration crash → resume the move.
        Some(TenantStatus::PendingMigration) => true,
        // Already done → no-op (the file moves below short-circuit
        // anyway, but skip the heavyweight checks).
        Some(TenantStatus::Active) => false,
        Some(TenantStatus::PendingDelete) => {
            return Err(Error::conflict(format!(
                "default tenant is in pending_delete status; refusing to migrate \
                 on top of a half-deleted tenant. Operator action required."
            )));
        }
    };

    if needs_move {
        // STEP 4 — main DB file. Atomic same-filesystem rename.
        rename_if_pending(
            &data_dir.join("solo.db"),
            &tenants_dir.join(&default_db_filename),
        )?;

        // STEP 5 — sidecars + HNSW snapshot files. Each is independent;
        // order between them does not matter.
        for suffix in &["-wal", "-shm"] {
            let src = data_dir.join(format!("solo.db{suffix}"));
            let dst = tenants_dir.join(format!("{default_db_filename}{suffix}"));
            rename_if_pending(&src, &dst)?;
        }

        // HNSW snapshots: keep basenames as-is, just move into tenants/.
        // P2 may introduce a per-tenant prefix or subdir; P1 leaves them
        // flat so single-tenant snapshot loading is a one-line dir
        // change in startup.rs (point at <data_dir>/tenants instead of
        // <data_dir>).
        for basename in [LIVE_BASENAME, BAK_BASENAME, TMP_BASENAME] {
            for suffix in [HNSW_DATA_SUFFIX, HNSW_GRAPH_SUFFIX] {
                let filename = format!("{basename}{suffix}");
                let src = data_dir.join(&filename);
                let dst = tenants_dir.join(&filename);
                rename_if_pending(&src, &dst)?;
            }
        }

        // STEP 6 — flip to active. Durable. Re-running after this point
        // is a complete no-op (existing.status == Active).
        index.set_status(&default_id, TenantStatus::Active)?;
    }

    Ok(())
}

/// Rename `src` to `dst` if `src` exists and `dst` does not. Otherwise
/// no-op (idempotent).
///
/// Crash-recovery model: if a prior partial run moved the file already,
/// `src` won't exist anymore and `dst` will — this returns `Ok(())`. If
/// neither exists (e.g., the v0.7.1 DB never had a WAL because no writes
/// had landed yet), this also returns `Ok(())`. If BOTH exist, we treat
/// that as an unexpected operator-surgery state and error: re-renaming
/// would overwrite the destination silently.
fn rename_if_pending(src: &Path, dst: &Path) -> Result<()> {
    let src_exists = src.exists();
    let dst_exists = dst.exists();
    match (src_exists, dst_exists) {
        (false, _) => {
            // Nothing to do — either we already moved it, or it never
            // existed.
            Ok(())
        }
        (true, true) => Err(Error::storage(format!(
            "v0.7.1 → v0.8.0 migration: both source {} and destination {} exist; \
             refusing to silently overwrite. Operator action required.",
            src.display(),
            dst.display()
        ))),
        (true, false) => {
            std::fs::rename(src, dst).map_err(|e| {
                Error::storage(format!(
                    "rename {}{}: {e}",
                    src.display(),
                    dst.display()
                ))
            })?;
            tracing::info!(
                src = %src.display(),
                dst = %dst.display(),
                "v071→v080 mass-data-move: renamed file"
            );
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::key_material::KeyMaterial;
    use rusqlite::Connection;
    use std::fs;
    use std::path::PathBuf;
    use tempfile::TempDir;

    /// Throwaway key derived once per test. Argon2id with default params
    /// is ~500ms, but only the FIRST derive in a process pays that — the
    /// derive is CPU-bound and runs cold in each test process anyway.
    /// For tests we accept the cost rather than building a parallel
    /// "fast key" path that could mask production bugs.
    fn test_key() -> KeyMaterial {
        let salt = [0x42u8; 16];
        KeyMaterial::derive("v071-migrate-test", &salt).expect("derive test key")
    }

    /// Plant a fake v0.7.1 SQLCipher-encrypted DB at `data_dir/solo.db`
    /// using the given key. We open the file, apply migration 0001-0003,
    /// and seed one episode row so we can verify the same DB opens after
    /// the move.
    fn plant_v071_db(data_dir: &Path, key: &KeyMaterial) -> PathBuf {
        let db_path = data_dir.join("solo.db");
        let mut conn = crate::init::open_sqlcipher(&db_path, key).unwrap();
        crate::migration::run_migrations(&mut conn).unwrap();
        let now_ms: i64 = chrono::Utc::now().timestamp_millis();
        conn.execute(
            "INSERT INTO episodes (
                memory_id, ts_ms, source_type, content,
                encoding_context_json, confidence, strength, salience,
                tier, created_at_ms, updated_at_ms
             ) VALUES (?, ?, 'user_message', 'pre-migration episode',
                       '{}', 1.0, 0.5, 0.5, 'hot', ?, ?)",
            rusqlite::params![
                "00000000-0000-0000-0000-000000000001",
                now_ms,
                now_ms,
                now_ms,
            ],
        )
        .unwrap();
        drop(conn);
        db_path
    }

    /// Plant a stub HNSW snapshot pair at the given basename. These are
    /// not real hnsw_rs dumps — the helper only moves bytes, it doesn't
    /// open them as HNSW files.
    fn plant_hnsw_pair(data_dir: &Path, basename: &str) {
        fs::write(
            data_dir.join(format!("{basename}.hnsw.data")),
            b"stub-data",
        )
        .unwrap();
        fs::write(
            data_dir.join(format!("{basename}.hnsw.graph")),
            b"stub-graph",
        )
        .unwrap();
    }

    /// Plant a stub WAL/SHM pair next to the planted DB. These are
    /// likewise opaque from the helper's standpoint.
    fn plant_wal_shm(data_dir: &Path) {
        fs::write(data_dir.join("solo.db-wal"), b"stub-wal").unwrap();
        fs::write(data_dir.join("solo.db-shm"), b"stub-shm").unwrap();
    }

    #[test]
    fn migrate_from_fresh_install_creates_default_tenant() {
        // No solo.db, no tenants_index.db. The helper should produce a
        // clean layout: tenants/ subdir, tenants_index.db with one
        // active default tenant, no spurious files.
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        migrate_v071_to_v080(dir, &key).unwrap();

        // Directory structure.
        assert!(dir.join("tenants_index.db").exists());
        assert!(dir.join("tenants").is_dir());
        // No solo.db at root after migration.
        assert!(!dir.join("solo.db").exists());

        // Registry state.
        let idx = TenantsIndex::open(dir, &key).unwrap();
        let default = TenantId::default_tenant();
        let rec = idx.lookup(&default).unwrap().unwrap();
        assert_eq!(rec.tenant_id, default);
        assert_eq!(rec.db_filename, "default.db");
        assert_eq!(rec.status, TenantStatus::Active);
        assert!(
            rec.display_name
                .as_deref()
                .unwrap_or("")
                .contains("v0.7.1"),
            "display_name should mention v0.7.1 provenance; got {:?}",
            rec.display_name
        );
    }

    #[test]
    fn migrate_from_v071_install_moves_db_files() {
        // Plant a complete v0.7.1 layout: SQLCipher DB + WAL + SHM + HNSW
        // live snapshot pair. After migration, all four (plus the two
        // HNSW files) live under tenants/default.* and the source paths
        // are empty.
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        plant_v071_db(dir, &key);
        plant_wal_shm(dir);
        plant_hnsw_pair(dir, LIVE_BASENAME);

        migrate_v071_to_v080(dir, &key).unwrap();

        let tenants = dir.join("tenants");
        assert!(tenants.join("default.db").exists());
        assert!(tenants.join("default.db-wal").exists());
        assert!(tenants.join("default.db-shm").exists());
        // HNSW snapshots keep their basenames; only the parent dir
        // changes. P2 may revisit per-tenant naming.
        assert!(tenants.join(format!("{LIVE_BASENAME}.hnsw.data")).exists());
        assert!(tenants.join(format!("{LIVE_BASENAME}.hnsw.graph")).exists());

        // Source paths empty.
        for name in [
            "solo.db",
            "solo.db-wal",
            "solo.db-shm",
            "hnsw_episodes.hnsw.data",
            "hnsw_episodes.hnsw.graph",
        ] {
            assert!(
                !dir.join(name).exists(),
                "source must be moved: {}",
                dir.join(name).display()
            );
        }

        // The moved SQLCipher DB still opens under the same key.
        let moved_db = tenants.join("default.db");
        let conn = crate::init::open_sqlcipher(&moved_db, &key).unwrap();
        let n: i64 = conn
            .query_row("SELECT COUNT(*) FROM episodes", [], |r| r.get(0))
            .unwrap();
        assert_eq!(n, 1, "pre-migration episode must survive the move");
    }

    #[test]
    fn migrate_moves_bak_and_tmp_hnsw_pairs_if_present() {
        // Defensive coverage: even if a v0.7.1 install has _bak and _tmp
        // HNSW pairs lying around, they're moved too. (We don't generally
        // see _tmp because it's cleaned up on the next save, but a crash
        // during snapshot could leave one behind.)
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        plant_v071_db(dir, &key);
        plant_hnsw_pair(dir, LIVE_BASENAME);
        plant_hnsw_pair(dir, BAK_BASENAME);
        plant_hnsw_pair(dir, TMP_BASENAME);

        migrate_v071_to_v080(dir, &key).unwrap();

        let tenants = dir.join("tenants");
        for basename in [LIVE_BASENAME, BAK_BASENAME, TMP_BASENAME] {
            for suffix in [".hnsw.data", ".hnsw.graph"] {
                let expected = tenants.join(format!("{basename}{suffix}"));
                assert!(expected.exists(), "missing moved: {}", expected.display());
                let unexpected = dir.join(format!("{basename}{suffix}"));
                assert!(
                    !unexpected.exists(),
                    "source remained: {}",
                    unexpected.display()
                );
            }
        }
    }

    #[test]
    fn migrate_idempotent() {
        // Running the helper twice on the same data dir is a no-op the
        // second time: state is unchanged, no errors.
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        plant_v071_db(dir, &key);
        plant_wal_shm(dir);

        migrate_v071_to_v080(dir, &key).unwrap();
        let tenants_index_size_before =
            fs::metadata(dir.join("tenants_index.db")).unwrap().len();

        // Second invocation — must succeed and not mutate the layout.
        migrate_v071_to_v080(dir, &key).unwrap();

        let tenants = dir.join("tenants");
        assert!(tenants.join("default.db").exists());
        // Registry still has one active default tenant.
        let idx = TenantsIndex::open(dir, &key).unwrap();
        let listed = idx.list().unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].status, TenantStatus::Active);
        // Tenants_index.db unchanged in size (no duplicate rows).
        let tenants_index_size_after =
            fs::metadata(dir.join("tenants_index.db")).unwrap().len();
        assert_eq!(tenants_index_size_before, tenants_index_size_after);
    }

    #[test]
    fn migrate_resumes_from_pending_migration() {
        // Simulate a crash AFTER step 3 (registry row inserted with
        // status=pending_migration) but BEFORE step 4 (file moves).
        // The DB and sidecars are still at the v0.7.1 root paths;
        // tenants_index has the pending marker. Re-running completes
        // the move + flips to active.
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        plant_v071_db(dir, &key);
        plant_wal_shm(dir);

        // Simulate the partial state.
        fs::create_dir_all(dir.join("tenants")).unwrap();
        {
            let mut idx = TenantsIndex::open(dir, &key).unwrap();
            let default_id = TenantId::default_tenant();
            idx.register_with_status(
                &default_id,
                "default.db",
                Some("Default tenant (migrated from v0.7.1)"),
                TenantStatus::PendingMigration,
            )
            .unwrap();
            // Connection drop flushes the row to disk.
        }
        // Sanity: solo.db is still at the root (move hadn't happened).
        assert!(dir.join("solo.db").exists());

        // Re-run the helper. It must observe the pending marker and
        // resume from step 4.
        migrate_v071_to_v080(dir, &key).unwrap();

        let tenants = dir.join("tenants");
        assert!(tenants.join("default.db").exists());
        assert!(tenants.join("default.db-wal").exists());
        assert!(tenants.join("default.db-shm").exists());
        assert!(!dir.join("solo.db").exists());

        let idx = TenantsIndex::open(dir, &key).unwrap();
        let rec = idx.lookup(&TenantId::default_tenant()).unwrap().unwrap();
        assert_eq!(rec.status, TenantStatus::Active);
    }

    #[test]
    fn migrate_resumes_from_partial_file_move() {
        // Simulate a crash where solo.db moved but solo.db-wal did not.
        // Re-running the helper completes the WAL move + flips active.
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        plant_v071_db(dir, &key);
        plant_wal_shm(dir);

        // Partial state: tenants_index registered with PendingMigration,
        // main DB already moved, WAL still at root, SHM still at root.
        fs::create_dir_all(dir.join("tenants")).unwrap();
        fs::rename(dir.join("solo.db"), dir.join("tenants/default.db")).unwrap();
        {
            let mut idx = TenantsIndex::open(dir, &key).unwrap();
            let default_id = TenantId::default_tenant();
            idx.register_with_status(
                &default_id,
                "default.db",
                None,
                TenantStatus::PendingMigration,
            )
            .unwrap();
        }
        // Sanity-check the planted partial state.
        assert!(!dir.join("solo.db").exists());
        assert!(dir.join("tenants/default.db").exists());
        assert!(dir.join("solo.db-wal").exists());
        assert!(dir.join("solo.db-shm").exists());

        migrate_v071_to_v080(dir, &key).unwrap();

        // All four files now under tenants/.
        let tenants = dir.join("tenants");
        assert!(tenants.join("default.db").exists());
        assert!(tenants.join("default.db-wal").exists());
        assert!(tenants.join("default.db-shm").exists());
        assert!(!dir.join("solo.db-wal").exists());
        assert!(!dir.join("solo.db-shm").exists());

        let idx = TenantsIndex::open(dir, &key).unwrap();
        let rec = idx.lookup(&TenantId::default_tenant()).unwrap().unwrap();
        assert_eq!(rec.status, TenantStatus::Active);
    }

    #[test]
    fn migrate_when_already_active_is_noop() {
        // Helper called against a data dir whose default tenant is
        // already Active does nothing — no errors, no file mutations.
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();

        plant_v071_db(dir, &key);
        migrate_v071_to_v080(dir, &key).unwrap();

        // Snapshot file mtimes before and after.
        let tenants = dir.join("tenants");
        let db_mtime_before =
            fs::metadata(tenants.join("default.db")).unwrap().modified().unwrap();
        std::thread::sleep(std::time::Duration::from_millis(10));

        // Second call: registry already Active → no-op.
        migrate_v071_to_v080(dir, &key).unwrap();

        let db_mtime_after =
            fs::metadata(tenants.join("default.db")).unwrap().modified().unwrap();
        assert_eq!(
            db_mtime_before, db_mtime_after,
            "no-op migration must not touch files"
        );

        let idx = TenantsIndex::open(dir, &key).unwrap();
        let listed = idx.list().unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].status, TenantStatus::Active);
    }

    /// Sanity: rename_if_pending refuses to silently clobber when both
    /// source and destination exist (operator surgery state).
    #[test]
    fn rename_if_pending_errors_on_both_exist() {
        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src.bin");
        let dst = tmp.path().join("dst.bin");
        fs::write(&src, b"src").unwrap();
        fs::write(&dst, b"dst").unwrap();
        let err = rename_if_pending(&src, &dst).unwrap_err();
        assert!(
            err.to_string().contains("both source"),
            "expected both-exist guard, got {err}"
        );
        // Neither side touched.
        assert_eq!(fs::read(&src).unwrap(), b"src");
        assert_eq!(fs::read(&dst).unwrap(), b"dst");
    }

    /// Sanity: SQLCipher cipher round-trip survives the rename. Drop-in
    /// regression for the most basic "did the move actually preserve the
    /// file" question, distinct from the row-count check above.
    #[test]
    fn moved_sqlcipher_db_decrypts_under_same_key() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();
        let key = test_key();
        plant_v071_db(dir, &key);
        migrate_v071_to_v080(dir, &key).unwrap();

        let moved = dir.join("tenants/default.db");
        let conn = crate::init::open_sqlcipher(&moved, &key).unwrap();
        // First query against the moved file must succeed without a
        // cipher error.
        let _: u32 = conn
            .query_row("SELECT MAX(version) FROM schema_migrations", [], |r| r.get(0))
            .unwrap();
        drop(conn);
        // And does NOT decrypt under a different key (smoke test for
        // SQLCipher feature actually being present; if the workspace
        // is built without the SQLCipher feature this will silently
        // pass — same constraint as `wrong_passphrase_fails_to_open`
        // in init.rs, so we don't make this an assertion).
        let _ = Connection::open(&moved); // just exercising the API
    }
}