magi-rs 0.8.0

Magi Agent: a terminal AI assistant in Rust with sandboxed tool execution, OAuth login, and encrypted local memory (authenticated encryption with error-correcting FEC via the cryptovault crate).
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
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
// Author: Julian Bolivar
// Version: 1.0.0
// Date: 2026-06-26

//! Encrypted vector store: `memories` table, `VectorStore` trait, and
//! `SqliteVectorStore` (REQ-01, REQ-03, REQ-04).
//!
//! # Integration design
//! `SqliteVectorStore` **shares** the `Arc<Mutex<Connection>>` and cached
//! `derived_key` from [`EncryptedSqliteMemory`] — it does NOT open a second
//! DB or re-derive a key. A fresh `CryptoVault::default()` is constructed
//! locally; the vault is a stateless algorithm bundle so this is zero-cost.
//!
//! # Lock discipline (W12)
//! The Mutex is held only long enough to collect raw ciphertext rows from
//! SQLite; it is **always released before any decryption runs**. This mirrors
//! the `collect_message_rows` / `decrypt_rows` pattern in `database.rs`.
//!
//! [`EncryptedSqliteMemory`]: crate::system::database::EncryptedSqliteMemory

use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use rusqlite::{params, TransactionBehavior};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;

use crate::agent::messages::{Content, Message};
use crate::memory::config::MemoryConfig;
use crate::memory::error::MemoryError;
use crate::memory::salience::assign_salience;
use crate::memory::MemoryKind;
use cryptovault::CryptoVault;

// ─── Memory struct ────────────────────────────────────────────────────────────

/// One stored memory record (REQ-01, SC-01).
///
/// `created_at` and `last_accessed_at` are Unix seconds (UTC); they are set
/// by the caller (a `Clock` abstraction is wired in later tasks). `access_count`
/// is held as `u64` with saturating semantics (CP2-B) and mapped to/from
/// `i64` in SQLite via `i64::try_from` / `u64::try_from` with `unwrap_or(MAX)`
/// guards so no value ever causes a panic.
///
/// # Scope
/// `scope` defaults to `"root"` (D-14). Multi-scope is activated in the Agent
/// Society layer (AS-REQ-09); everything in this task lives in root.
///
/// # Empty embedding
/// An empty `embedding` vector signals "needs lazy embed" (Tasks 5 / 7).
/// `model_id = ""` similarly signals a pending embed.
// Narrow allow: struct consumed by SqliteVectorStore (this task) and wired in Task 12.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct Memory {
    /// Unique identifier (UUID string).
    pub id: String,
    /// Session that produced this memory.
    pub session_id: String,
    /// Whether this is an episodic turn or a durable preference.
    pub kind: MemoryKind,
    /// Raw text of the memory (stored encrypted as `text_blob`).
    pub text: String,
    /// Embedding vector (stored encrypted as `embedding_blob`). Empty → pending.
    pub embedding: Vec<f32>,
    /// Model that produced the embedding, e.g. `"nomic-embed-text"`. Empty → pending.
    pub model_id: String,
    /// Vector dimension. `0` → pending embed.
    pub dim: usize,
    /// Creation time (Unix seconds UTC).
    pub created_at: i64,
    /// Salience score in `[0, 1]` (assigned at write time, Task 6).
    pub salience: f64,
    /// Access counter (CP2-B: saturating at `i64::MAX` in the SQLite store).
    pub access_count: u64,
    /// Time of last retrieval hit (Unix seconds UTC).
    pub last_accessed_at: i64,
    /// ID of the memory that supersedes this one, if any (REQ-10).
    pub superseded_by: Option<String>,
    /// Unix seconds when this memory was evicted, if at all (REQ-09 / D-07).
    pub evicted_at: Option<i64>,
    /// Scope tag (D-14). Default `"root"`.
    pub scope: String,
    /// Unix seconds when this memory was included in a distillation pass (REQ-17).
    pub distilled_at: Option<i64>,
}

// ─── MemoryDiagnostics ───────────────────────────────────────────────────────

/// Operator-visible store counts for the tiered memory subsystem (CP2-AN/S).
///
/// All queries are pure `COUNT`/`SUM` reads against the `memories` table —
/// no decryption, no ANN index traversal. Safe to call at startup before the
/// in-RAM index is built.
///
/// `active_count` and `pending_reembed_count` are scoped to the requested
/// scope. `archived_count` spans **all** scopes: archived rows are globally
/// excluded from active retrieval regardless of scope, so a cross-scope total
/// is the operationally useful metric.
///
/// # RAM estimate
/// `ram_estimate_bytes = Σ(dim × 4)` over active records with `dim > 0` in
/// the requested scope — the ceiling on in-RAM ANN index memory (4 bytes per
/// `f32` component).
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryDiagnostics {
    /// Active memories in the requested scope
    /// (`evicted_at IS NULL AND superseded_by IS NULL`).
    pub active_count: usize,
    /// Archived (evicted) memories across all scopes (`evicted_at IS NOT NULL`).
    pub archived_count: usize,
    /// Active memories in scope with no embedding (`model_id = ''` or `dim = 0`):
    /// candidates for lazy re-embed.
    pub pending_reembed_count: usize,
    /// In-RAM ANN index ceiling in bytes: `Σ(dim × size_of::<f32>())` for active
    /// records with real embeddings (`dim > 0`) in the requested scope.
    pub ram_estimate_bytes: usize,
}

// ─── VectorStore trait ────────────────────────────────────────────────────────

/// Trait for the encrypted, scope-aware vector store.
///
/// Every method maps all failures to typed [`MemoryError`] variants; none
/// may panic. Implementations must be `Send + Sync` for use across async tasks.
// Narrow allow: trait and all methods consumed by retrieval/decay/distiller
// modules (Tasks 7–10) and wired into the agent in Task 12.
#[allow(dead_code)]
#[async_trait]
pub trait VectorStore: Send + Sync {
    /// Persists a [`Memory`] record, encrypting `text` and `embedding` at rest.
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] on encryption failure;
    /// [`MemoryError::Storage`] on SQLite failure (e.g. duplicate `id`).
    async fn insert(&self, m: &Memory) -> Result<(), MemoryError>;

    /// Retrieves a single [`Memory`] by `id`, or `None` if not found.
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] if decryption fails;
    /// [`MemoryError::Storage`] on SQL or deserialization failure.
    async fn get(&self, id: &str) -> Result<Option<Memory>, MemoryError>;

    /// Returns all **active** memories for `scope` (SC-41, REQ-38):
    /// rows where `evicted_at IS NULL AND superseded_by IS NULL AND scope = scope`.
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] or [`MemoryError::Storage`].
    async fn active(&self, scope: &str) -> Result<Vec<Memory>, MemoryError>;

    /// Increments `access_count` and sets `last_accessed_at = now` for every id
    /// in `ids`. Only the named records are touched (SC-09, REQ-07).
    ///
    /// # Errors
    /// [`MemoryError::Storage`] on SQL failure.
    async fn mark_accessed(&self, ids: &[String], now: i64) -> Result<(), MemoryError>;

    /// Sets `superseded_by = by` for the record with `id`, excluding it from
    /// future active retrieval (REQ-10, D-12).
    ///
    /// # Errors
    /// [`MemoryError::Storage`] on SQL failure.
    async fn set_superseded(&self, id: &str, by: &str) -> Result<(), MemoryError>;

    /// Sets `evicted_at = at` (or `NULL` to un-evict) for the record with `id`
    /// (REQ-09, REQ-32, D-07).
    ///
    /// # Errors
    /// [`MemoryError::Storage`] on SQL failure.
    async fn set_evicted(&self, id: &str, at: Option<i64>) -> Result<(), MemoryError>;

    /// Hard-deletes all records with the given `ids` (REQ-32,
    /// `evicted_retention_days = 0`).
    ///
    /// # Errors
    /// [`MemoryError::Storage`] on SQL failure.
    async fn hard_delete(&self, ids: &[String]) -> Result<(), MemoryError>;

    /// Sets `distilled_at = at` for every id in `ids` (REQ-17, D-15).
    ///
    /// # Errors
    /// [`MemoryError::Storage`] on SQL failure.
    async fn set_distilled(&self, ids: &[String], at: i64) -> Result<(), MemoryError>;

    /// Returns all archived (`evicted_at IS NOT NULL`) memories, regardless of
    /// scope. Used by [`purge_expired_archives`] to locate evicted rows that
    /// have exceeded their retention window (REQ-32, D-07).
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] if decryption of any row fails;
    /// [`MemoryError::Storage`] on SQL or deserialization failure.
    ///
    /// [`purge_expired_archives`]: crate::memory::decay::purge_expired_archives
    async fn archived(&self) -> Result<Vec<Memory>, MemoryError>;

    /// Replaces the encrypted embedding for `id` with a freshly computed vector
    /// from the current embedder (CP2-C / REQ-02 / `reembed_pending`).
    ///
    /// Encrypts `embedding` outside the connection lock (W12) and then issues
    /// a single `UPDATE` on `embedding_blob`, `model_id`, and `dim`.
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] if encryption fails;
    /// [`MemoryError::Storage`] if the `UPDATE` fails or `id` does not exist.
    async fn update_embedding(
        &self,
        id: &str,
        embedding: &[f32],
        model_id: &str,
        dim: usize,
    ) -> Result<(), MemoryError>;

    /// Sets `salience` for the record with `id`, clamped to `[0, 1]` (D-11).
    ///
    /// Called by the distiller's off-hot-path salience boost (REQ-35/SC-38):
    /// after the distiller identifies a durable preference in episodic memory,
    /// it can lift that memory's salience to the protected tier so that decay
    /// never evicts it.
    ///
    /// A finite `salience` value outside `[0, 1]` is silently clamped — this is
    /// not an error, because the caller may compute salience from floating-point
    /// arithmetic that can drift slightly.
    ///
    /// **Non-finite values** (`f64::NAN`, `f64::INFINITY`, `f64::NEG_INFINITY`)
    /// are rejected before clamping and return `Err(MemoryError::Config(_))` (G3).
    /// `NAN.clamp(0.0, 1.0)` returns NaN in Rust (NaN comparisons are false), so
    /// the guard must run **before** clamp, not after.
    ///
    /// # Errors
    /// [`MemoryError::Config`] if `salience` is non-finite (G3).
    /// [`MemoryError::Storage`] on SQL failure.
    async fn set_salience(&self, id: &str, salience: f64) -> Result<(), MemoryError>;

    /// Atomically replaces the existing record with `m.id` (if any) and inserts
    /// `m` in a single crash-safe operation (G5-c; used by the profile distiller).
    ///
    /// **Required**: implementors must provide a native atomic upsert. The former
    /// non-atomic delete-then-insert default was removed (W2) because a crash between
    /// the two operations would silently lose the record. Prefer `INSERT OR REPLACE`
    /// (SQLite) or an equivalent atomic primitive on other stores.
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] on encryption failure;
    /// [`MemoryError::Storage`] on SQL failure.
    async fn upsert(&self, m: &Memory) -> Result<(), MemoryError>;
}

// ─── Free helpers ─────────────────────────────────────────────────────────────

/// Concatenates the text from a message's [`Content::Text`] and
/// [`Content::ToolResult`] blocks, space-joined. [`Content::ToolUse`] blocks
/// contribute nothing. Used by [`SqliteVectorStore::migrate_from_messages`].
fn extract_message_text(msg: &Message) -> String {
    msg.content
        .iter()
        .filter_map(|c| match c {
            Content::Text { text } => Some(text.as_str()),
            Content::ToolResult { content, .. } => Some(content.as_str()),
            Content::ToolUse { .. } => None,
        })
        .collect::<Vec<_>>()
        .join(" ")
}

// ─── Internal helpers ─────────────────────────────────────────────────────────

/// Raw encrypted row collected from SQLite before the connection lock is
/// released (W12 discipline: no decryption under the Mutex).
#[allow(dead_code)]
struct RawRow {
    id: String,
    session_id: String,
    kind_str: String,
    text_blob: String,
    embedding_blob: String,
    model_id: String,
    dim: i64,
    created_at: i64,
    salience: f64,
    access_count: i64,
    last_accessed_at: i64,
    superseded_by: Option<String>,
    evicted_at: Option<i64>,
    scope: String,
    distilled_at: Option<i64>,
}

/// Pre-encrypted blobs and scalar columns for one `memories` row,
/// produced by [`SqliteVectorStore::encode_memory`].
///
/// All encryption runs BEFORE the connection lock is acquired (W12 discipline:
/// no crypto work under the Mutex).
struct EncodedMemoryRow {
    text_blob: String,
    embedding_blob: String,
    /// `m.access_count` clamped to `i64::MAX` (CP2-B saturating write).
    access_count_i64: i64,
}

/// SQL fragment selecting all `memories` columns in the order expected by
/// [`row_from_query`].
#[allow(dead_code)]
const SELECT_COLS: &str = "SELECT id, session_id, kind, text_blob, embedding_blob, \
    model_id, dim, created_at, salience, access_count, last_accessed_at, \
    superseded_by, evicted_at, scope, distilled_at FROM memories";

/// Maps a rusqlite `Row` to a [`RawRow`]. Column order must match
/// [`SELECT_COLS`].
#[allow(dead_code)]
fn row_from_query(row: &rusqlite::Row<'_>) -> rusqlite::Result<RawRow> {
    Ok(RawRow {
        id: row.get(0)?,
        session_id: row.get(1)?,
        kind_str: row.get(2)?,
        text_blob: row.get(3)?,
        embedding_blob: row.get(4)?,
        model_id: row.get(5)?,
        dim: row.get(6)?,
        created_at: row.get(7)?,
        salience: row.get(8)?,
        access_count: row.get(9)?,
        last_accessed_at: row.get(10)?,
        superseded_by: row.get(11)?,
        evicted_at: row.get(12)?,
        scope: row.get(13)?,
        distilled_at: row.get(14)?,
    })
}

/// Decrypts a [`RawRow`] into a [`Memory`] outside the connection lock (W12).
///
/// # Errors
/// [`MemoryError::Crypto`] on cipher failure; [`MemoryError::Storage`] on JSON
/// deserialization or an unknown `kind` string (data integrity error).
#[allow(dead_code)]
fn decode_row(raw: RawRow, vault: &CryptoVault, key: &[u8]) -> Result<Memory, MemoryError> {
    let kind = parse_kind(&raw.kind_str)?;

    let text = vault
        .decrypt_with_key(key, &raw.text_blob)
        .map_err(|e| MemoryError::Crypto(e.to_string()))?
        .as_str()
        .to_owned();

    let emb_json = vault
        .decrypt_with_key(key, &raw.embedding_blob)
        .map_err(|e| MemoryError::Crypto(e.to_string()))?;
    let embedding: Vec<f32> = serde_json::from_str(emb_json.as_str())
        .map_err(|e| MemoryError::Storage(format!("embedding deserialization: {e}")))?;

    // CP2-B: saturating read — a negative DB value (should never happen in
    // normal operation) maps to u64::MAX as a safe sentinel, not a panic.
    let access_count = u64::try_from(raw.access_count).unwrap_or(u64::MAX);

    Ok(Memory {
        id: raw.id,
        session_id: raw.session_id,
        kind,
        text,
        embedding,
        model_id: raw.model_id,
        dim: raw.dim as usize,
        created_at: raw.created_at,
        salience: raw.salience,
        access_count,
        last_accessed_at: raw.last_accessed_at,
        superseded_by: raw.superseded_by,
        evicted_at: raw.evicted_at,
        scope: raw.scope,
        distilled_at: raw.distilled_at,
    })
}

/// Parses the stable on-disk `kind` string back to a [`MemoryKind`].
///
/// An unknown string is treated as a data integrity error, not a silent
/// default — consistent with the message `Role` serialization discipline.
#[allow(dead_code)]
fn parse_kind(s: &str) -> Result<MemoryKind, MemoryError> {
    match s {
        "episodic" => Ok(MemoryKind::Episodic),
        "preference" => Ok(MemoryKind::Preference),
        other => Err(MemoryError::Storage(format!(
            "unknown memory kind in DB: {:?} (data integrity error)",
            other
        ))),
    }
}

// ─── SqliteVectorStore ────────────────────────────────────────────────────────

/// SQLite-backed, encrypted, scope-aware vector store.
///
/// Shares the `Arc<Mutex<Connection>>` and cached `derived_key` from
/// [`EncryptedSqliteMemory`][crate::system::database::EncryptedSqliteMemory]
/// so the `memories` table lives in the same database file and uses the same
/// AES-256-GCM-SIV key. No second DB connection is opened and no additional
/// Argon2 key derivation is performed.
///
/// # Construction
/// ```ignore
/// let store = SqliteVectorStore::new(mem.shared_conn(), mem.data_key())?;
/// ```
///
/// # Thread safety
/// `SqliteVectorStore` is `Send + Sync`; it can be wrapped in an `Arc` and
/// shared across async tasks.
// Narrow allow: struct consumed by retrieval/decay/distiller (Tasks 7–10) and
// wired into the agent in Task 12.
#[allow(dead_code)]
pub struct SqliteVectorStore {
    conn: Arc<Mutex<rusqlite::Connection>>,
    vault: CryptoVault,
    derived_key: Zeroizing<Vec<u8>>,
}

/// `i64::MAX` as a decimal literal for embedding in raw SQL strings.
///
/// SQLite does not accept Rust integer constants inline, so the value must be
/// a decimal literal. Using this const as the source of truth ensures the SQL
/// guard in `mark_accessed` and the Rust saturation logic remain in sync.
const I64_MAX_SQL: i64 = i64::MAX;

impl SqliteVectorStore {
    /// Builds a `SqliteVectorStore` sharing `conn` and `derived_key` with an
    /// existing `EncryptedSqliteMemory`.
    ///
    /// Creates the `memories` table and its scope index if they do not exist.
    ///
    /// # Errors
    /// [`MemoryError::Storage`] if the DDL statement fails.
    // Narrow allow: called by the agent wiring in Task 12.
    #[allow(dead_code)]
    pub fn new(
        conn: Arc<Mutex<rusqlite::Connection>>,
        derived_key: Zeroizing<Vec<u8>>,
    ) -> Result<Self, MemoryError> {
        {
            let c = conn.lock().unwrap_or_else(|p| p.into_inner());
            c.execute_batch(
                "CREATE TABLE IF NOT EXISTS memories (
                    id TEXT PRIMARY KEY,
                    session_id TEXT NOT NULL,
                    kind TEXT NOT NULL,
                    text_blob TEXT NOT NULL,
                    embedding_blob TEXT NOT NULL,
                    model_id TEXT NOT NULL,
                    dim INTEGER NOT NULL,
                    created_at INTEGER NOT NULL,
                    salience REAL NOT NULL,
                    access_count INTEGER NOT NULL DEFAULT 0,
                    last_accessed_at INTEGER NOT NULL,
                    superseded_by TEXT,
                    evicted_at INTEGER,
                    scope TEXT NOT NULL DEFAULT 'root',
                    distilled_at INTEGER
                );
                CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope);",
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        }
        Ok(Self {
            conn,
            vault: CryptoVault::default(),
            derived_key,
        })
    }

    /// Acquires the connection lock, recovering from a poisoned mutex (same
    /// recovery pattern as `EncryptedSqliteMemory::locked_conn`).
    fn locked_conn(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
        self.conn.lock().unwrap_or_else(|p| p.into_inner())
    }

    /// Encrypts `m.text` and `m.embedding`, and converts `m.access_count` to
    /// an `i64` (saturating, CP2-B). All work runs **outside** the connection
    /// lock so the critical section stays short (W12 discipline: no crypto under
    /// the Mutex).
    ///
    /// Both [`VectorStore::insert`] and [`VectorStore::upsert`] call this
    /// method; the only difference between those two operations is the SQL
    /// conflict-resolution keyword (`INSERT` vs `INSERT OR REPLACE`).
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] if either encryption call fails;
    /// [`MemoryError::Storage`] if `m.embedding` cannot be serialized to JSON.
    fn encode_memory(&self, m: &Memory) -> Result<EncodedMemoryRow, MemoryError> {
        let text_blob = self
            .vault
            .encrypt_with_key(&self.derived_key, &m.text)
            .map_err(|e| MemoryError::Crypto(e.to_string()))?;

        let emb_json = serde_json::to_string(&m.embedding)
            .map_err(|e| MemoryError::Storage(format!("embedding serialization: {e}")))?;
        let embedding_blob = self
            .vault
            .encrypt_with_key(&self.derived_key, &emb_json)
            .map_err(|e| MemoryError::Crypto(e.to_string()))?;

        // CP2-B: saturating write — u64 values above i64::MAX are clamped to
        // i64::MAX rather than panicking or wrapping.
        let access_count_i64 = i64::try_from(m.access_count).unwrap_or(i64::MAX);

        Ok(EncodedMemoryRow {
            text_blob,
            embedding_blob,
            access_count_i64,
        })
    }

    /// Returns operator-visible diagnostic counts for `scope` (CP2-AN/S).
    ///
    /// Four cheap `COUNT`/`SUM` queries — no decryption, no ANN traversal.
    /// Safe to call at startup before the in-RAM index is built.
    ///
    /// - `active_count` — rows where `evicted_at IS NULL AND superseded_by IS NULL AND scope = scope`.
    /// - `archived_count` — rows where `evicted_at IS NOT NULL` (all scopes).
    /// - `pending_reembed_count` — active rows in scope with `model_id = ''` or `dim = 0`.
    /// - `ram_estimate_bytes` — `Σ(dim × 4)` for active rows with `dim > 0` in scope.
    ///
    /// See [`MemoryDiagnostics`] for full field semantics.
    ///
    /// # Errors
    /// [`MemoryError::Storage`] if any of the underlying SQL queries fail.
    pub async fn diagnostics(&self, scope: &str) -> Result<MemoryDiagnostics, MemoryError> {
        let c = self.locked_conn();

        // Active records in the requested scope.
        let active_count: i64 = c
            .query_row(
                "SELECT COUNT(*) FROM memories \
                 WHERE evicted_at IS NULL AND superseded_by IS NULL AND scope = ?1",
                params![scope],
                |r| r.get(0),
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;

        // Archived records (cross-scope — all evicted rows regardless of scope).
        let archived_count: i64 = c
            .query_row(
                "SELECT COUNT(*) FROM memories WHERE evicted_at IS NOT NULL",
                [],
                |r| r.get(0),
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;

        // Active records in scope that need a (re-)embed: no model or zero dim.
        let pending_reembed_count: i64 = c
            .query_row(
                "SELECT COUNT(*) FROM memories \
                 WHERE evicted_at IS NULL AND superseded_by IS NULL AND scope = ?1 \
                 AND (model_id = '' OR dim = 0)",
                params![scope],
                |r| r.get(0),
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;

        // RAM ceiling: sum of dim*4 for active records with real embeddings in scope.
        let ram_estimate_bytes: i64 = c
            .query_row(
                "SELECT COALESCE(SUM(CAST(dim AS INTEGER) * 4), 0) FROM memories \
                 WHERE evicted_at IS NULL AND superseded_by IS NULL AND scope = ?1 \
                 AND dim > 0",
                params![scope],
                |r| r.get(0),
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;

        Ok(MemoryDiagnostics {
            active_count: usize::try_from(active_count).unwrap_or(usize::MAX),
            archived_count: usize::try_from(archived_count).unwrap_or(usize::MAX),
            pending_reembed_count: usize::try_from(pending_reembed_count).unwrap_or(usize::MAX),
            ram_estimate_bytes: usize::try_from(ram_estimate_bytes).unwrap_or(usize::MAX),
        })
    }

    /// Imports prior conversation turns as episodic memories, **non-destructively**:
    /// each turn becomes a `Memory { kind: Episodic, embedding: vec![], model_id: "",
    /// scope: "root", .. }` (empty embedding ⇒ pending lazy re-embed in Task 7). All
    /// inserts run in ONE `IMMEDIATE` transaction (atomic; a mid-batch error rolls back —
    /// CP2-V). Already-present ids are skipped (idempotent — CP2-F), so re-running or
    /// overlapping batches never duplicate. Returns the number of NEW memories imported.
    ///
    /// `now` is the unix-seconds timestamp to stamp `created_at`/`last_accessed_at` with
    /// (a `Clock` is injected by the caller in later tasks).
    ///
    /// # Text extraction
    /// `Content::Text` and `Content::ToolResult` blocks are space-joined; `Content::ToolUse`
    /// contributes nothing. Turns whose combined text is empty/whitespace are skipped.
    ///
    /// # Idempotency
    /// Each turn is identified by a deterministic SHA-256 content hash of
    /// `session_id || 0x1F || role_str || 0x1F || text`, prefixed `"mig:"`.
    /// Duplicate ids within the same batch or across re-runs are silently skipped.
    ///
    /// # Errors
    /// [`MemoryError::Crypto`] if any turn's text exceeds the 50 MiB plaintext cap or
    /// cipher fails. [`MemoryError::Storage`] on any SQLite failure.
    /// On any error, **no** records are imported (atomic rollback).
    // Narrow allow: called by the agent wiring in Task 12.
    #[allow(dead_code)]
    pub async fn migrate_from_messages(
        &self,
        msgs: &[(String, Message)],
        now: i64,
        cfg: &MemoryConfig,
    ) -> Result<usize, MemoryError> {
        /// Holds the pre-computed, pre-encrypted blobs for one turn.
        struct Prepared {
            id: String,
            session_id: String,
            text_blob: String,
            embedding_blob: String,
            salience: f64,
        }

        // Phase 1: extract text, derive content-hash ids, encrypt blobs — ALL outside
        // the connection lock (W12 discipline: no crypto under the Mutex).
        // If any encryption fails here, no transaction has started, so nothing is imported.
        let empty_emb_json = "[]";
        let mut prepared: Vec<Prepared> = Vec::with_capacity(msgs.len());

        for (session_id, msg) in msgs {
            let text = extract_message_text(msg);
            if text.trim().is_empty() {
                continue;
            }

            // Deterministic content-hash id for idempotency (CP2-F).
            let role_str = format!("{:?}", msg.role);
            let id = {
                let mut h = Sha256::new();
                h.update(session_id.as_bytes());
                h.update([0x1F_u8]);
                h.update(role_str.as_bytes());
                h.update([0x1F_u8]);
                h.update(text.as_bytes());
                format!("mig:{:x}", h.finalize())
            };

            // Deterministic config-aware salience at write time (H1 / REQ-35 / D-11).
            let salience = assign_salience(MemoryKind::Episodic, &text, msg.role.clone(), cfg);

            // Encrypt the turn text; fails fast if text > MAX_PLAINTEXT_LEN (50 MiB).
            let text_blob = self
                .vault
                .encrypt_with_key(&self.derived_key, &text)
                .map_err(|e| MemoryError::Crypto(e.to_string()))?;

            // Each record gets its own independently-nonced embedding blob.
            let embedding_blob = self
                .vault
                .encrypt_with_key(&self.derived_key, empty_emb_json)
                .map_err(|e| MemoryError::Crypto(e.to_string()))?;

            prepared.push(Prepared {
                id,
                session_id: session_id.clone(),
                text_blob,
                embedding_blob,
                salience,
            });
        }

        if prepared.is_empty() {
            return Ok(0);
        }

        // Phase 2: ONE IMMEDIATE transaction — the Mutex is held for the entire SQL
        // batch (required for atomicity; no decryption happens here so W12 is satisfied).
        let mut imported = 0;
        {
            let mut conn = self.locked_conn();
            let tx = conn
                .transaction_with_behavior(TransactionBehavior::Immediate)
                .map_err(|e| MemoryError::Storage(e.to_string()))?;

            for p in &prepared {
                // Skip ids already present (idempotency — CP2-F).
                let count: i64 = tx
                    .query_row(
                        "SELECT COUNT(*) FROM memories WHERE id = ?1",
                        params![p.id],
                        |r| r.get(0),
                    )
                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
                if count > 0 {
                    continue;
                }

                tx.execute(
                    "INSERT INTO memories \
                         (id, session_id, kind, text_blob, embedding_blob, model_id, dim, \
                          created_at, salience, access_count, last_accessed_at, \
                          superseded_by, evicted_at, scope, distilled_at) \
                     VALUES (?1, ?2, 'episodic', ?3, ?4, '', 0, ?5, ?6, 0, ?5, \
                             NULL, NULL, 'root', NULL)",
                    params![
                        p.id,
                        p.session_id,
                        p.text_blob,
                        p.embedding_blob,
                        now,
                        p.salience
                    ],
                )
                .map_err(|e| MemoryError::Storage(e.to_string()))?;

                imported += 1;
            }

            tx.commit()
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
        } // MutexGuard dropped here, lock released.

        Ok(imported)
    }
}

#[async_trait]
impl VectorStore for SqliteVectorStore {
    async fn insert(&self, m: &Memory) -> Result<(), MemoryError> {
        // Encrypt BEFORE acquiring the lock (W12 discipline: no crypto under
        // the Mutex).
        let enc = self.encode_memory(m)?;

        let c = self.locked_conn();
        c.execute(
            "INSERT INTO memories \
                 (id, session_id, kind, text_blob, embedding_blob, model_id, dim, \
                  created_at, salience, access_count, last_accessed_at, \
                  superseded_by, evicted_at, scope, distilled_at) \
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
            params![
                m.id,
                m.session_id,
                m.kind.as_str(),
                enc.text_blob,
                enc.embedding_blob,
                m.model_id,
                m.dim as i64,
                m.created_at,
                m.salience,
                enc.access_count_i64,
                m.last_accessed_at,
                m.superseded_by,
                m.evicted_at,
                m.scope,
                m.distilled_at,
            ],
        )
        .map_err(|e| MemoryError::Storage(e.to_string()))?;

        Ok(())
    }

    /// Atomic upsert: `INSERT OR REPLACE` is a single SQLite statement
    /// (crash-safe — no window between delete and insert, G5-c).
    ///
    /// Encryption runs BEFORE the Mutex is acquired (W12: no crypto under the
    /// lock). The semantics match [`Self::insert`] but with REPLACE conflict
    /// resolution so an existing record with the same `id` is atomically removed.
    async fn upsert(&self, m: &Memory) -> Result<(), MemoryError> {
        // Encrypt BEFORE acquiring the lock (W12 discipline).
        let enc = self.encode_memory(m)?;

        let c = self.locked_conn();
        c.execute(
            "INSERT OR REPLACE INTO memories \
                 (id, session_id, kind, text_blob, embedding_blob, model_id, dim, \
                  created_at, salience, access_count, last_accessed_at, \
                  superseded_by, evicted_at, scope, distilled_at) \
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
            params![
                m.id,
                m.session_id,
                m.kind.as_str(),
                enc.text_blob,
                enc.embedding_blob,
                m.model_id,
                m.dim as i64,
                m.created_at,
                m.salience,
                enc.access_count_i64,
                m.last_accessed_at,
                m.superseded_by,
                m.evicted_at,
                m.scope,
                m.distilled_at,
            ],
        )
        .map_err(|e| MemoryError::Storage(e.to_string()))?;

        Ok(())
    }

    async fn get(&self, id: &str) -> Result<Option<Memory>, MemoryError> {
        // 1. Collect the raw ciphertext row under the lock.
        let raw: Option<RawRow> = {
            let c = self.locked_conn();
            let mut stmt = c
                .prepare(&format!("{SELECT_COLS} WHERE id = ?1"))
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
            let mut rows = stmt
                .query_map(params![id], row_from_query)
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
            match rows.next() {
                None => None,
                Some(r) => Some(r.map_err(|e| MemoryError::Storage(e.to_string()))?),
            }
        }; // lock released here

        // 2. Decrypt outside the lock (W12).
        match raw {
            None => Ok(None),
            Some(row) => Ok(Some(decode_row(row, &self.vault, &self.derived_key)?)),
        }
    }

    async fn active(&self, scope: &str) -> Result<Vec<Memory>, MemoryError> {
        // 1. Collect raw rows under the lock.
        let raw_rows: Vec<RawRow> = {
            let c = self.locked_conn();
            let mut stmt = c
                .prepare(&format!(
                    "{SELECT_COLS} WHERE evicted_at IS NULL \
                      AND superseded_by IS NULL AND scope = ?1"
                ))
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
            let iter = stmt
                .query_map(params![scope], row_from_query)
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
            let mut collected = Vec::new();
            for r in iter {
                collected.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
            }
            collected
        }; // lock released here

        // 2. Decrypt outside the lock (W12).
        let mut out = Vec::with_capacity(raw_rows.len());
        for row in raw_rows {
            out.push(decode_row(row, &self.vault, &self.derived_key)?);
        }
        Ok(out)
    }

    async fn mark_accessed(&self, ids: &[String], now: i64) -> Result<(), MemoryError> {
        if ids.is_empty() {
            return Ok(());
        }
        // Pure SQL updates — no decryption — so the lock may be held for the
        // entire transaction without violating the W12 discipline.
        // IMMEDIATE transaction: all-or-nothing so a partial failure rolls back.
        let mut c = self.locked_conn();
        let tx = c
            .transaction_with_behavior(TransactionBehavior::Immediate)
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        // Build the SQL once; the guard uses I64_MAX_SQL so the decimal literal
        // stays in sync with the Rust saturation check (i64::try_from + unwrap_or).
        let sql = format!(
            "UPDATE memories \
             SET access_count = CASE WHEN access_count < {I64_MAX_SQL} \
                 THEN access_count + 1 ELSE access_count END, \
                 last_accessed_at = ?2 \
             WHERE id = ?1"
        );
        for id in ids {
            tx.execute(&sql, params![id, now])
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
        }
        tx.commit()
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        Ok(())
    }

    async fn set_superseded(&self, id: &str, by: &str) -> Result<(), MemoryError> {
        let c = self.locked_conn();
        c.execute(
            "UPDATE memories SET superseded_by = ?2 WHERE id = ?1",
            params![id, by],
        )
        .map_err(|e| MemoryError::Storage(e.to_string()))?;
        Ok(())
    }

    async fn set_evicted(&self, id: &str, at: Option<i64>) -> Result<(), MemoryError> {
        let c = self.locked_conn();
        c.execute(
            "UPDATE memories SET evicted_at = ?2 WHERE id = ?1",
            params![id, at],
        )
        .map_err(|e| MemoryError::Storage(e.to_string()))?;
        Ok(())
    }

    async fn hard_delete(&self, ids: &[String]) -> Result<(), MemoryError> {
        if ids.is_empty() {
            return Ok(());
        }
        // IMMEDIATE transaction: all-or-nothing so a partial failure rolls back (F2).
        // Pure SQL deletes — no decryption — W12 satisfied.
        let mut c = self.locked_conn();
        let tx = c
            .transaction_with_behavior(TransactionBehavior::Immediate)
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        for id in ids {
            tx.execute("DELETE FROM memories WHERE id = ?1", params![id])
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
        }
        tx.commit()
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        Ok(())
    }

    async fn set_distilled(&self, ids: &[String], at: i64) -> Result<(), MemoryError> {
        if ids.is_empty() {
            return Ok(());
        }
        // IMMEDIATE transaction: all-or-nothing so a partial failure rolls back (F2).
        // Pure SQL updates — no decryption — W12 satisfied.
        let mut c = self.locked_conn();
        let tx = c
            .transaction_with_behavior(TransactionBehavior::Immediate)
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        for id in ids {
            tx.execute(
                "UPDATE memories SET distilled_at = ?2 WHERE id = ?1",
                params![id, at],
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        }
        tx.commit()
            .map_err(|e| MemoryError::Storage(e.to_string()))?;
        Ok(())
    }

    async fn update_embedding(
        &self,
        id: &str,
        embedding: &[f32],
        model_id: &str,
        dim: usize,
    ) -> Result<(), MemoryError> {
        // Encrypt outside the lock (W12 discipline: no crypto under the Mutex).
        let emb_json = serde_json::to_string(embedding)
            .map_err(|e| MemoryError::Storage(format!("embedding serialization: {e}")))?;
        let embedding_blob = self
            .vault
            .encrypt_with_key(&self.derived_key, &emb_json)
            .map_err(|e| MemoryError::Crypto(e.to_string()))?;

        // Hold the lock only for the SQL UPDATE.
        let c = self.locked_conn();
        let affected = c
            .execute(
                "UPDATE memories \
                 SET embedding_blob = ?2, model_id = ?3, dim = ?4 \
                 WHERE id = ?1",
                params![id, embedding_blob, model_id, dim as i64],
            )
            .map_err(|e| MemoryError::Storage(e.to_string()))?;

        if affected == 0 {
            return Err(MemoryError::Storage(format!(
                "update_embedding: id not found: {id}"
            )));
        }
        Ok(())
    }

    async fn archived(&self) -> Result<Vec<Memory>, MemoryError> {
        // 1. Collect raw rows under the lock (W12 discipline).
        let raw_rows: Vec<RawRow> = {
            let c = self.locked_conn();
            let mut stmt = c
                .prepare(&format!("{SELECT_COLS} WHERE evicted_at IS NOT NULL"))
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
            let iter = stmt
                .query_map([], row_from_query)
                .map_err(|e| MemoryError::Storage(e.to_string()))?;
            let mut collected = Vec::new();
            for r in iter {
                collected.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
            }
            collected
        }; // lock released here

        // 2. Decrypt outside the lock (W12).
        let mut out = Vec::with_capacity(raw_rows.len());
        for row in raw_rows {
            out.push(decode_row(row, &self.vault, &self.derived_key)?);
        }
        Ok(out)
    }

    async fn set_salience(&self, id: &str, salience: f64) -> Result<(), MemoryError> {
        // Reject non-finite values BEFORE clamping. `NaN.clamp(0.0, 1.0)` in
        // Rust returns NaN (NaN < min and NaN > max are both false, so clamp
        // returns self unchanged). A NaN stored in the DB would corrupt salience
        // comparisons and eviction decisions. `±Inf.clamp(0.0, 1.0)` would be
        // silently coerced to a valid value (0.0 or 1.0), hiding a caller bug
        // — also rejected so the contract is explicit (G3).
        if !salience.is_finite() {
            return Err(MemoryError::Config(format!(
                "set_salience: non-finite value is not permitted (got {salience}); \
                 pass a finite value in [0.0, 1.0] or outside that range to clamp"
            )));
        }
        // Clamp to [0, 1] before writing; floating-point drift from the caller
        // must not produce an out-of-range value in the DB.
        let s = salience.clamp(0.0, 1.0);
        let c = self.locked_conn();
        c.execute(
            "UPDATE memories SET salience = ?2 WHERE id = ?1",
            params![id, s],
        )
        .map_err(|e| MemoryError::Storage(e.to_string()))?;
        Ok(())
    }
}

// ─── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::messages::Message;
    use crate::system::database::EncryptedSqliteMemory;

    /// Creates a temp-file-backed `SqliteVectorStore` sharing the connection
    /// and key from a fresh `EncryptedSqliteMemory`. The returned
    /// `NamedTempFile` must stay alive for the test's lifetime.
    fn test_store() -> (tempfile::NamedTempFile, SqliteVectorStore) {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let mem = EncryptedSqliteMemory::new(tmp.path().to_path_buf(), "pw".into()).unwrap();
        let store = SqliteVectorStore::new(mem.shared_conn(), mem.data_key()).unwrap();
        (tmp, store)
    }

    /// Builds a minimal `Memory` for testing.
    fn sample(id: &str, text: &str, emb: Vec<f32>) -> Memory {
        Memory {
            id: id.into(),
            session_id: "s".into(),
            kind: MemoryKind::Episodic,
            text: text.into(),
            embedding: emb.clone(),
            model_id: "nomic-embed-text".into(),
            dim: emb.len(),
            created_at: 1000,
            salience: 0.3,
            access_count: 0,
            last_accessed_at: 1000,
            superseded_by: None,
            evicted_at: None,
            scope: "root".into(),
            distilled_at: None,
        }
    }

    /// Reads a raw column value via a *separate* SQLite connection so we
    /// inspect what is actually on disk (not what the in-process store caches).
    fn raw_blob(tmp: &tempfile::NamedTempFile, col: &str) -> String {
        let conn = rusqlite::Connection::open(tmp.path()).unwrap();
        conn.query_row(&format!("SELECT {col} FROM memories LIMIT 1"), [], |r| {
            r.get::<_, String>(0)
        })
        .unwrap()
    }

    /// SC-05, SC-41: round-trip through insert/get preserves all fields; disk
    /// blobs contain ciphertext, not plaintext.
    #[tokio::test]
    async fn test_persist_and_reload_roundtrips_vector_and_metadata() {
        let (tmp, store) = test_store();
        let m = sample("m1", "remember the api budget is 8000", vec![0.1, 0.2, 0.3]);
        store.insert(&m).await.unwrap();
        let got = store.get("m1").await.unwrap().unwrap();
        assert_eq!(got, m);
        assert_eq!(got.scope, "root"); // SC-41
        assert!(!raw_blob(&tmp, "text_blob").contains("budget")); // SC-05 no cleartext text
        assert!(!raw_blob(&tmp, "embedding_blob").contains("0.1")); // SC-05 no cleartext vector
    }

    /// SC-41: `scope` defaults to `"root"` and `active` filters by scope.
    #[tokio::test]
    async fn test_default_scope_is_root_and_active_filters_by_scope() {
        let (_t, store) = test_store();
        store.insert(&sample("a", "x", vec![0.0; 3])).await.unwrap();
        assert_eq!(store.active("root").await.unwrap().len(), 1);
        assert!(store.active("other").await.unwrap().is_empty());
    }

    /// SC-09: `mark_accessed` updates ONLY the named ids.
    #[tokio::test]
    async fn test_mark_accessed_updates_only_named_ids() {
        let (_t, store) = test_store();
        store.insert(&sample("a", "a", vec![0.0; 3])).await.unwrap();
        store.insert(&sample("b", "b", vec![0.0; 3])).await.unwrap();
        store.mark_accessed(&["a".into()], 2000).await.unwrap();
        assert_eq!(store.get("a").await.unwrap().unwrap().access_count, 1);
        assert_eq!(
            store.get("a").await.unwrap().unwrap().last_accessed_at,
            2000
        );
        assert_eq!(store.get("b").await.unwrap().unwrap().access_count, 0);
    }

    /// CP2-B: `access_count` values that fit in `i64` round-trip exactly.
    /// `i64::MAX as u64` is the highest value the write-side
    /// `i64::try_from(…).unwrap_or(i64::MAX)` passes through without clamping.
    #[tokio::test]
    async fn test_access_count_storage_saturates_at_i64_max() {
        let (_t, store) = test_store();
        let mut m = sample("a", "x", vec![0.0; 3]);
        m.access_count = i64::MAX as u64;
        store.insert(&m).await.unwrap();
        assert_eq!(
            store.get("a").await.unwrap().unwrap().access_count,
            i64::MAX as u64
        );
    }

    /// SC-08: migration imports prior turns without loss and marks for lazy embed.
    #[tokio::test]
    async fn test_migration_imports_prior_turns_without_loss_and_marks_for_lazy_embed() {
        let (_t, store) = test_store();
        let prior = vec![
            ("s1".to_string(), Message::user("old fact one")),
            ("s1".to_string(), Message::assistant("ack")),
        ];
        let n = store
            .migrate_from_messages(
                &prior,
                1000,
                &crate::memory::config::MemoryConfig::default(),
            )
            .await
            .unwrap();
        assert_eq!(n, 2);
        let active = store.active("root").await.unwrap();
        assert_eq!(active.len(), 2);
        assert!(active
            .iter()
            .all(|m| m.embedding.is_empty() && m.model_id.is_empty()));
        assert!(active
            .iter()
            .all(|m| matches!(m.kind, crate::memory::MemoryKind::Episodic)));
        assert!(active.iter().all(|m| m.scope == "root"));
    }

    /// CP2-F: re-running (and overlapping batches) imports no duplicates.
    #[tokio::test]
    async fn test_migration_is_idempotent_across_reruns_and_batches() {
        let (_t, store) = test_store();
        let prior = vec![("s1".to_string(), Message::user("fact"))];
        let cfg = crate::memory::config::MemoryConfig::default();
        assert_eq!(
            store
                .migrate_from_messages(&prior, 1000, &cfg)
                .await
                .unwrap(),
            1
        );
        assert_eq!(
            store
                .migrate_from_messages(&prior, 1000, &cfg)
                .await
                .unwrap(),
            0
        ); // already present
        assert_eq!(store.active("root").await.unwrap().len(), 1);
    }

    /// CP2-V: a mid-batch error rolls the whole batch back (nothing imported).
    #[tokio::test]
    async fn test_migration_is_atomic_on_failure() {
        let (_t, store) = test_store();
        // Second turn's text exceeds the crypto plaintext cap (50 MiB) ⇒ encrypt fails fast
        // (the cap is checked before any large allocation), aborting the transaction.
        let huge = "x".repeat(50 * 1024 * 1024 + 1);
        let batch = vec![
            ("s1".to_string(), Message::user("ok")),
            ("s1".to_string(), Message::user(&huge)),
        ];
        assert!(store
            .migrate_from_messages(
                &batch,
                1000,
                &crate::memory::config::MemoryConfig::default()
            )
            .await
            .is_err());
        assert!(
            store.active("root").await.unwrap().is_empty(),
            "rollback ⇒ nothing imported"
        );
    }

    /// CP2-AN/S: diagnostics reports correct active, archived, and pending counts,
    /// and produces a non-zero RAM estimate when active vectors exist.
    #[tokio::test]
    async fn test_diagnostics_reports_active_archived_and_pending() {
        let (_t, store) = test_store();

        // Active record with a real embedding.
        let m_active = sample(
            "diag-active",
            "active memory with vector",
            vec![0.1, 0.2, 0.3],
        );
        store.insert(&m_active).await.unwrap();

        // Active record with empty embedding → pending re-embed.
        let mut m_pending = sample("diag-pending", "pending re-embed memory", vec![]);
        m_pending.model_id = String::new();
        m_pending.dim = 0;
        store.insert(&m_pending).await.unwrap();

        // Insert a third record then evict it → archived.
        let m_evict = sample("diag-evicted", "evicted memory", vec![0.4, 0.5]);
        store.insert(&m_evict).await.unwrap();
        store
            .set_evicted("diag-evicted", Some(1_000_000))
            .await
            .unwrap();

        let diag = store.diagnostics("root").await.unwrap();

        assert_eq!(
            diag.active_count, 2,
            "two non-evicted records in root scope"
        );
        assert_eq!(diag.archived_count, 1, "one evicted record (any scope)");
        assert_eq!(
            diag.pending_reembed_count, 1,
            "one active record without embedding"
        );
        assert!(
            diag.ram_estimate_bytes > 0,
            "active vector ⇒ positive RAM estimate (got 0)"
        );
    }

    /// A1: `mark_accessed` with `access_count = i64::MAX` must NOT overflow.
    /// The SQL CASE guard must leave the value at `i64::MAX as u64`.
    #[tokio::test]
    async fn test_mark_accessed_saturates_at_i64_max() {
        let (_t, store) = test_store();
        let mut m = sample("sat", "overflow test", vec![0.0; 3]);
        m.access_count = i64::MAX as u64;
        store.insert(&m).await.unwrap();
        store.mark_accessed(&["sat".into()], 9999).await.unwrap();
        let got = store.get("sat").await.unwrap().unwrap();
        assert_eq!(
            got.access_count,
            i64::MAX as u64,
            "A1: access_count must stay at i64::MAX after mark_accessed (no overflow)"
        );
    }

    /// A2: `update_embedding` on a non-existent id must return an error,
    /// not silently succeed.
    #[tokio::test]
    async fn test_update_embedding_on_missing_id_is_err() {
        let (_t, store) = test_store();
        let result = store
            .update_embedding("no-such-id", &[0.1f32, 0.2], "model", 2)
            .await;
        assert!(
            matches!(result, Err(MemoryError::Storage(_))),
            "A2: update_embedding on missing id must be Err(Storage), got: {result:?}"
        );
    }

    /// A3: `diagnostics` i64→usize conversion must not truncate on large counts.
    /// (Smoke test: result fields are `usize`, not negative or wrapped.)
    #[tokio::test]
    async fn test_diagnostics_counts_are_usize_safe() {
        let (_t, store) = test_store();
        let d = store.diagnostics("root").await.unwrap();
        // All counts start at 0; the important thing is the code path compiles
        // and runs without panic. The try_from conversion is the fix; absence of
        // panic here demonstrates it is safe on this platform.
        assert_eq!(d.active_count, 0);
        assert_eq!(d.archived_count, 0);
        assert_eq!(d.pending_reembed_count, 0);
        assert_eq!(d.ram_estimate_bytes, 0);
    }

    /// CP2-F volume: a 1000-turn batch imports fully.
    #[tokio::test]
    async fn test_migration_imports_large_batch() {
        let (_t, store) = test_store();
        let prior: Vec<_> = (0..1000)
            .map(|i| ("s1".to_string(), Message::user(&format!("fact {i}"))))
            .collect();
        assert_eq!(
            store
                .migrate_from_messages(
                    &prior,
                    1000,
                    &crate::memory::config::MemoryConfig::default()
                )
                .await
                .unwrap(),
            1000
        );
        assert_eq!(store.active("root").await.unwrap().len(), 1000);
    }

    // ── F2: transactional atomicity for multi-id ops ──────────────────────────

    /// F2: hard_delete of multiple ids removes all of them (atomic batch).
    #[tokio::test]
    async fn test_hard_delete_multiple_ids_removes_all() {
        let (_t, store) = test_store();
        for i in 0..3u8 {
            store
                .insert(&sample(
                    &format!("del{i}"),
                    &format!("txt{i}"),
                    vec![0.0; 3],
                ))
                .await
                .unwrap();
        }
        store
            .hard_delete(&["del0".into(), "del1".into(), "del2".into()])
            .await
            .unwrap();
        assert!(
            store.get("del0").await.unwrap().is_none(),
            "del0 must be gone"
        );
        assert!(
            store.get("del1").await.unwrap().is_none(),
            "del1 must be gone"
        );
        assert!(
            store.get("del2").await.unwrap().is_none(),
            "del2 must be gone"
        );
    }

    /// F2: mark_accessed on a batch updates all named ids in one shot.
    #[tokio::test]
    async fn test_mark_accessed_batch_updates_all_ids() {
        let (_t, store) = test_store();
        for i in 0..3u8 {
            store
                .insert(&sample(&format!("acc{i}"), &format!("t{i}"), vec![0.0; 3]))
                .await
                .unwrap();
        }
        store
            .mark_accessed(&["acc0".into(), "acc1".into(), "acc2".into()], 5000)
            .await
            .unwrap();
        for i in 0..3u8 {
            let m = store.get(&format!("acc{i}")).await.unwrap().unwrap();
            assert_eq!(m.access_count, 1, "F2: acc{i} access_count must be 1");
            assert_eq!(
                m.last_accessed_at, 5000,
                "F2: acc{i} last_accessed_at must be 5000"
            );
        }
    }

    /// F2: set_distilled on a batch stamps all named ids.
    #[tokio::test]
    async fn test_set_distilled_batch_stamps_all_ids() {
        let (_t, store) = test_store();
        for i in 0..3u8 {
            store
                .insert(&sample(&format!("dst{i}"), &format!("t{i}"), vec![0.0; 3]))
                .await
                .unwrap();
        }
        store
            .set_distilled(&["dst0".into(), "dst1".into(), "dst2".into()], 9999)
            .await
            .unwrap();
        for i in 0..3u8 {
            let m = store.get(&format!("dst{i}")).await.unwrap().unwrap();
            assert_eq!(
                m.distilled_at,
                Some(9999),
                "F2: dst{i} distilled_at must be 9999"
            );
        }
    }

    // ── G3: set_salience rejects non-finite values ────────────────────────────

    /// G3: `set_salience` with `f64::NAN` must return `Err(MemoryError::Config(_))`
    /// rather than a SQL-layer error or Ok. `f64::NAN.clamp(0.0, 1.0)` returns NaN
    /// in Rust (NaN comparisons are false, so the input propagates unchanged),
    /// which would either silently corrupt the DB or produce a `Storage` error
    /// depending on the SQL driver — both are wrong; we want a typed `Config` error.
    #[tokio::test]
    async fn test_set_salience_nan_is_config_err() {
        let (_t, store) = test_store();
        store
            .insert(&sample("sal-nan", "text", vec![0.0; 3]))
            .await
            .unwrap();
        let result = store.set_salience("sal-nan", f64::NAN).await;
        assert!(
            matches!(result, Err(MemoryError::Config(_))),
            "G3: set_salience(NaN) must return Err(Config), got: {result:?}"
        );
    }

    /// G3: `set_salience` with `f64::INFINITY` must return `Err(MemoryError::Config(_))`.
    /// Without the guard, `INFINITY.clamp(0.0, 1.0) = 1.0` — a valid storage value —
    /// so the SQL succeeds and the error is silently lost.
    #[tokio::test]
    async fn test_set_salience_infinity_is_config_err() {
        let (_t, store) = test_store();
        store
            .insert(&sample("sal-inf", "text", vec![0.0; 3]))
            .await
            .unwrap();
        let result = store.set_salience("sal-inf", f64::INFINITY).await;
        assert!(
            matches!(result, Err(MemoryError::Config(_))),
            "G3: set_salience(INFINITY) must return Err(Config), got: {result:?}"
        );
    }

    /// G3: `set_salience` with a finite out-of-range value (e.g. 1.5) must
    /// still succeed — out-of-range finite values are silently clamped to [0, 1].
    #[tokio::test]
    async fn test_set_salience_out_of_range_finite_is_clamped() {
        let (_t, store) = test_store();
        store
            .insert(&sample("sal-clamp", "text", vec![0.0; 3]))
            .await
            .unwrap();
        store.set_salience("sal-clamp", 1.5).await.unwrap();
        let got = store.get("sal-clamp").await.unwrap().unwrap();
        assert_eq!(
            got.salience, 1.0,
            "G3: out-of-range finite salience must be clamped to 1.0"
        );
    }

    /// H1: a migrated turn whose text contains a configured salience marker must
    /// receive the protected salience floor (not the flat 0.3 hard-code).
    /// SC-37-ext: config-aware salience at migration time.
    #[tokio::test]
    async fn test_migration_uses_config_salience_for_marker_text() {
        let (_t, store) = test_store();
        let cfg = crate::memory::config::MemoryConfig {
            salience_markers: vec!["important_marker".into()],
            protect_salience_threshold: 0.9,
            default_salience: 0.3,
            ..crate::memory::config::MemoryConfig::default()
        };
        // Text containing the marker must yield salience >= protect_salience_threshold.
        let prior = vec![(
            "s1".to_string(),
            crate::agent::messages::Message::user("remember: important_marker fact"),
        )];
        let n = store
            .migrate_from_messages(&prior, 1000, &cfg)
            .await
            .unwrap();
        assert_eq!(n, 1, "one turn must be imported");
        let active = store.active("root").await.unwrap();
        assert_eq!(active.len(), 1);
        assert!(
            active[0].salience >= cfg.protect_salience_threshold,
            "H1: migrated turn with marker must have salience >= {}, got {}",
            cfg.protect_salience_threshold,
            active[0].salience
        );
    }

    /// H1: a migrated turn WITHOUT a configured marker must receive
    /// the base salience from assign_salience (not the hard-coded 0.3 literal).
    #[tokio::test]
    async fn test_migration_uses_config_default_salience_for_plain_text() {
        let (_t, store) = test_store();
        let cfg = crate::memory::config::MemoryConfig {
            default_salience: 0.5,
            salience_markers: vec!["important_marker".into()],
            ..crate::memory::config::MemoryConfig::default()
        };
        let prior = vec![(
            "s1".to_string(),
            // No marker in text; role=User adds +0.05 structural nudge → 0.55.
            crate::agent::messages::Message::user("just a plain turn"),
        )];
        store
            .migrate_from_messages(&prior, 1000, &cfg)
            .await
            .unwrap();
        let active = store.active("root").await.unwrap();
        // assign_salience(Episodic, "just a plain turn", User, cfg) = 0.5 + 0.05 = 0.55
        let expected = (cfg.default_salience + 0.05_f64).clamp(0.0, 1.0);
        assert_eq!(
            active[0].salience, expected,
            "H1: plain turn salience must come from assign_salience, not hard-coded 0.3"
        );
    }

    /// F2: hard_delete on an empty slice is a no-op (no error, no rows touched).
    #[tokio::test]
    async fn test_hard_delete_empty_slice_is_noop() {
        let (_t, store) = test_store();
        store
            .insert(&sample("keep", "important", vec![0.0; 3]))
            .await
            .unwrap();
        store.hard_delete(&[]).await.unwrap();
        assert!(
            store.get("keep").await.unwrap().is_some(),
            "F2: empty hard_delete must not remove any rows"
        );
    }

    /// DRY: `insert` and `upsert` must decode to identical values given the
    /// same input `Memory` — proving that `encode_memory` is the single
    /// encryption path for both operations.
    #[tokio::test]
    async fn test_insert_and_upsert_produce_equivalent_encrypted_rows() {
        let (_t, store) = test_store();

        // Insert via `insert`.
        let mut via_insert = sample("eq-insert", "encryption equivalence check", vec![0.1, 0.9]);
        via_insert.access_count = 7;
        store.insert(&via_insert).await.unwrap();

        // Insert via `upsert` (fresh id — no prior row, so REPLACE is a no-op).
        let mut via_upsert = via_insert.clone();
        via_upsert.id = "eq-upsert".into();
        store.upsert(&via_upsert).await.unwrap();

        let got_insert = store.get("eq-insert").await.unwrap().unwrap();
        let got_upsert = store.get("eq-upsert").await.unwrap().unwrap();

        assert_eq!(got_insert.text, got_upsert.text, "text must be identical");
        assert_eq!(
            got_insert.embedding, got_upsert.embedding,
            "embedding must be identical"
        );
        assert_eq!(
            got_insert.salience, got_upsert.salience,
            "salience must be identical"
        );
        assert_eq!(got_insert.kind, got_upsert.kind, "kind must be identical");
        assert_eq!(
            got_insert.access_count, got_upsert.access_count,
            "access_count must be identical"
        );
        assert_eq!(
            got_insert.scope, got_upsert.scope,
            "scope must be identical"
        );
    }
}