trusty-memory 0.25.3

MCP server (stdio + Unix socket) for trusty-memory
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
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
// Tests for crates/trusty-memory/src/lib.rs
// Moved from lib.rs to comply with the 500 SLOC production file cap (issue #607).
// Why: lib.rs is a production file (capped at 500 SLOC); the test block alone
// is 439 SLOC. Extracting to lib_tests.rs (test-file cap: 1500 SLOC) keeps
// lib.rs under 500 SLOC while preserving all test coverage.

#![allow(clippy::too_many_lines)]

use super::*;

/// Why: Issue #234 — previously we `mem::forget`ed the `TempDir` so tests
/// could keep using `AppState` without juggling the directory handle, but
/// that leaked one temp directory per test (262+ accumulated each run).
/// What: Returns the `TempDir` alongside the `AppState` so the caller can
/// bind it (`let (state, _tmp) = ...;`) and let drop semantics clean up
/// when the test scope ends.
/// Test: Every test in this module that constructs state.
fn test_state() -> (AppState, tempfile::TempDir) {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();
    // Issue #88: bypass palace-slug enforcement so lib tests that call
    // `palace_create` with arbitrary names keep passing.
    // SAFETY: constant idempotent write; safe across test threads.
    unsafe {
        std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
    }
    let state = AppState::new(root);
    // Pre-existing tests exercise functional paths — flip to Ready so the
    // issue #911 warming preflight does not reject them.
    state.set_ready();
    (state, tmp)
}

/// Why: DaemonReadiness tests need a state that starts in Warming; this
/// variant skips `set_ready()` so the transition can be tested explicitly.
/// Test: `daemon_readiness_transitions_warming_to_ready`.
fn test_state_warming() -> (AppState, tempfile::TempDir) {
    // Use OnceLock so the env var is written exactly once across all
    // parallel test threads — avoids the unsynchronised set_var race while
    // remaining consistent with the idempotent-write approach used in
    // `test_state()`.
    static SKIP_ENFORCEMENT_SET: std::sync::OnceLock<()> = std::sync::OnceLock::new();
    SKIP_ENFORCEMENT_SET.get_or_init(|| unsafe {
        std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
    });
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();
    // Deliberately do NOT call set_ready() — stays in Warming state.
    (AppState::new(root), tmp)
}

#[tokio::test]
async fn initialize_returns_protocol_version_and_capabilities() {
    let (state, _tmp) = test_state();
    let req = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {"name": "test", "version": "0"}
        }
    });
    let resp = handle_message(&state, req).await;
    assert_eq!(resp["jsonrpc"], "2.0");
    assert_eq!(resp["id"], 1);
    assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
    assert!(resp["result"]["capabilities"]["tools"].is_object());
    assert_eq!(resp["result"]["serverInfo"]["name"], "trusty-memory");
}

#[tokio::test]
async fn initialized_notification_returns_null() {
    let (state, _tmp) = test_state();
    let req = json!({
        "jsonrpc": "2.0",
        "method": "notifications/initialized",
        "params": {}
    });
    let resp = handle_message(&state, req).await;
    assert!(resp.is_null());
}

#[tokio::test]
async fn tools_list_returns_all_tools() {
    let (state, _tmp) = test_state();
    let req = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
    let resp = handle_message(&state, req).await;
    let tools = resp["result"]["tools"].as_array().expect("tools array");
    // Issue #99 added `memory_send_message`; issue #180 added
    // `palace_delete`; the #180 follow-up adds `palace_update` on top
    // of the 22-tool baseline; issue #537 adds `upgrade`;
    // issue #1104 adds `console_metrics`; spec-001 adds the four
    // `chat_session_*` tools and `dream_consolidate_room`;
    // issue #1720 adds `chat_session_recall`, `chat_session_delete`,
    // `chat_turn_append`; issue #1721 adds `palace_dream`;
    // issue #1722 adds `task_add`, `task_list`, `task_complete`;
    // ADR-0027 T6 (#4805) adds `room_list`, `room_create`, `room_rename`;
    // ADR-0027 T9 (#4809) adds `wing_list`, `wing_create`, `wing_rename`;
    // #4906 adds `palace_reembed`; #5005 adds `palace_unalias`;
    // #4776 adds `kg_list_subjects`; `kg_retract_triple` adds the inverse of
    // `kg_assert`.
    // #5000 / #4786 add `palace_verify_embedded` and `palace_embed_sweep`.
    assert_eq!(tools.len(), 49);
}

#[tokio::test]
async fn unknown_method_returns_error() {
    let (state, _tmp) = test_state();
    let req = json!({"jsonrpc": "2.0", "id": 4, "method": "wat"});
    let resp = handle_message(&state, req).await;
    assert_eq!(resp["error"]["code"], -32601);
}

#[tokio::test]
async fn ping_returns_empty_result() {
    let (state, _tmp) = test_state();
    let req = json!({"jsonrpc": "2.0", "id": 5, "method": "ping"});
    let resp = handle_message(&state, req).await;
    assert!(resp["result"].is_object());
}

#[tokio::test]
async fn app_state_default_constructs() {
    let (s, _tmp) = test_state();
    assert!(!s.version.is_empty());
    assert!(s.registry.is_empty());
    assert!(s.default_palace.is_none());
}

/// Why (issue #225): the previous implementation called `.expect()` on the
/// tempdir fallback, which panicked the daemon at startup on hosts where
/// neither the data root nor `std::env::temp_dir()` is writable
/// (read-only Docker overlays, locked-down sandboxes). The activity log
/// is documented as best-effort, so the fix returns a no-op `Discard`
/// variant instead. This test forces both paths to fail and asserts the
/// helper returns the discard variant rather than panicking.
///
/// Skipped when running as root because `chmod 000` is a no-op for the
/// root user — the kernel grants root access regardless of mode bits.
/// CI typically runs as non-root, so coverage is preserved in the
/// common case; local root invocations simply skip with a warning.
///
/// (#3434) This test used to force the tempdir-fallback path by mutating
/// the process-global `TMPDIR` env var for its duration. Because `cargo
/// test` runs every test in this crate's lib binary as threads of ONE
/// process, that mutation was visible to any OTHER concurrently-running
/// test that called `tempfile::tempdir()` (which respects `$TMPDIR`) —
/// such a test would fail with `PermissionDenied` for a reason entirely
/// unrelated to its own code, in whichever test happened to be running at
/// that instant. `open_activity_log_with_fallback_in` (test-only seam,
/// `events.rs`) now takes the fallback ROOT as an explicit parameter, so
/// this test points it directly at the unwritable `fake-tmp` dir instead —
/// no env var is touched, so no lock, no restore-on-panic guard, and no
/// possible cross-test leakage.
#[test]
#[cfg(unix)]
fn open_activity_log_with_fallback_returns_discard_when_unwritable() {
    // Skip when running as root — chmod is ignored.
    // SAFETY: libc::geteuid is a thread-safe syscall with no preconditions.
    if unsafe { libc::geteuid() } == 0 {
        eprintln!(
                "skipping open_activity_log_with_fallback_returns_discard_when_unwritable: running as root"
            );
        return;
    }

    use std::os::unix::fs::PermissionsExt;

    // Build two unwritable directories: the primary "data root" and a
    // shadow "fallback root" so the tempdir fallback also fails.
    let outer = tempfile::tempdir().expect("outer tempdir");
    let primary = outer.path().join("primary");
    let tmpdir = outer.path().join("fake-tmp");
    std::fs::create_dir(&primary).expect("create primary");
    std::fs::create_dir(&tmpdir).expect("create tmpdir");

    // chmod 000 on both — neither can be opened for write.
    std::fs::set_permissions(&primary, std::fs::Permissions::from_mode(0o000))
        .expect("chmod primary");
    std::fs::set_permissions(&tmpdir, std::fs::Permissions::from_mode(0o000))
        .expect("chmod tmpdir");

    // Pass the unwritable "fake-tmp" dir directly as the fallback root —
    // no process-global env var involved (#3434).
    let log = open_activity_log_with_fallback_in(&primary, &tmpdir);

    // Restore permissions so the outer tempdir can clean up.
    let _ = std::fs::set_permissions(&primary, std::fs::Permissions::from_mode(0o700));
    let _ = std::fs::set_permissions(&tmpdir, std::fs::Permissions::from_mode(0o700));

    assert!(
        log.is_discard(),
        "expected ActivityLog::Discard when both data root and tempdir are unwritable"
    );

    // The Discard variant must still satisfy the public contract: no
    // panic on append/count/list.
    let id = log
        .append(
            ActivitySource::Http,
            None,
            "drawer_added",
            json!({"smoke": true}),
        )
        .expect("discard append must succeed");
    assert_eq!(id, 0);
    assert_eq!(log.count().expect("discard count"), 0);
    assert!(log
        .list(&ActivityFilter::default(), 10, 0)
        .expect("discard list")
        .is_empty());
}

/// Why: Issue #26 — when `serve --palace <name>` is set, the MCP server
/// must (a) report the default in the `initialize` `serverInfo`, (b)
/// drop `palace` from the required schema in `tools/list`, and (c) let
/// `tools/call` use the default when the caller omits `palace`.
/// Test: Construct an AppState with a default palace, create that palace
/// on disk via the registry, then call `memory_remember` without a
/// `palace` argument and confirm it resolves to the default.
#[tokio::test]
async fn default_palace_used_when_arg_omitted() {
    // This test builds its `AppState` inline rather than through a shared
    // fixture, so it seeds the process-wide `retrieval::shared_embedder()`
    // OnceCell itself. `memory_remember` below embeds; the cell is
    // first-writer-wins, so under `cargo test` a sibling's seed satisfied this
    // test, while under per-test process isolation (`cargo nextest run`) it got
    // a virgin cell and reached for the real ONNX model (HTTP 429 in CI).
    // Same defect class as #4413. Idempotent, so order does not matter.
    trusty_common::memory_core::retrieval::seed_shared_embedder_with_mock();
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();

    // Pre-create the default palace so remember has somewhere to land.
    let registry = trusty_common::memory_core::PalaceRegistry::new();
    let palace = trusty_common::memory_core::Palace {
        id: trusty_common::memory_core::PalaceId::new("default-pal"),
        name: "default-pal".to_string(),
        description: None,
        created_at: chrono::Utc::now(),
        data_dir: root.join("default-pal"),
    };
    registry
        .create_palace(&root, palace)
        .expect("create_palace");

    let state = AppState::new(root).with_default_palace(Some("default-pal".to_string()));
    // Flip to Ready so the readiness preflight (#911) does not reject the
    // `memory_remember` call below.
    state.set_ready();

    // (a) initialize advertises the default.
    let init = handle_message(
        &state,
        json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}),
    )
    .await;
    assert_eq!(
        init["result"]["serverInfo"]["default_palace"], "default-pal",
        "initialize must echo default_palace in serverInfo"
    );

    // (b) tools/list drops `palace` from required when default is set.
    let list = handle_message(
        &state,
        json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
    )
    .await;
    let tools = list["result"]["tools"].as_array().expect("tools array");
    let remember = tools
        .iter()
        .find(|t| t["name"] == "memory_remember")
        .expect("memory_remember tool");
    let required: Vec<&str> = remember["inputSchema"]["required"]
        .as_array()
        .expect("required array")
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    assert!(
        !required.contains(&"palace"),
        "palace must not be required when default is configured; got {required:?}"
    );
    assert!(required.contains(&"text"));

    // (c) tools/call resolves the default when arg is omitted.
    let call = handle_message(
        &state,
        json!({
            "jsonrpc": "2.0",
            "id": 3,
            "method": "tools/call",
            "params": {
                "name": "memory_remember",
                "arguments": {"text": "default palace test memory content with several tokens"},
            },
        }),
    )
    .await;
    // Successful dispatch returns `result.content[0].text` JSON.
    let text = call["result"]["content"][0]["text"]
        .as_str()
        .unwrap_or_else(|| panic!("expected success result, got {call}"));
    let parsed: Value = serde_json::from_str(text).expect("parse content json");
    assert_eq!(parsed["palace"], "default-pal");
    assert_eq!(parsed["status"], "stored");
    assert!(parsed["drawer_id"].as_str().is_some());
}

/// Why: When no default is set, `tools/call` for a palace-bound tool
/// without a `palace` argument should error helpfully rather than panic.
#[tokio::test]
async fn missing_palace_without_default_errors() {
    let (state, _tmp) = test_state();
    let resp = handle_message(
        &state,
        json!({
            "jsonrpc": "2.0",
            "id": 7,
            "method": "tools/call",
            "params": {
                "name": "memory_recall",
                "arguments": {"query": "anything"},
            },
        }),
    )
    .await;
    assert_eq!(resp["error"]["code"], -32603);
    let msg = resp["error"]["message"].as_str().unwrap_or("");
    assert!(
        msg.contains("missing 'palace'"),
        "expected helpful error, got: {msg}"
    );
}

/// Why: regression for the "palaces lost on restart" bug — `AppState::new`
/// builds an empty registry, so the daemon must call
/// `load_palaces_from_disk` on startup to re-register palaces persisted by
/// a previous run. Without that call the registry stays empty even though
/// `palace.json` files exist on disk.
/// What: persists two palaces under a tempdir (via the same
/// `create_palace` path the `palace_create` tool uses), constructs a fresh
/// `AppState` rooted there, calls `load_palaces_from_disk`, and asserts the
/// returned count and registry contents.
/// Test: this test itself.
#[tokio::test]
async fn load_palaces_from_disk_rehydrates_registry() {
    use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();

    // Phase 1: persist two palaces to disk, then drop the writer registry
    // so nothing is held in memory — simulating a prior daemon run.
    {
        let writer = PalaceRegistry::new();
        for id in ["alpha", "beta"] {
            let palace = Palace {
                id: PalaceId::new(id),
                name: id.to_string(),
                description: None,
                created_at: chrono::Utc::now(),
                data_dir: root.join(id),
            };
            writer
                .create_palace(&root, palace)
                .expect("persist palace to disk");
        }
    }

    // Add a stray non-palace subdirectory; the walker must ignore it.
    std::fs::create_dir_all(root.join("not-a-palace")).expect("mkdir");

    // Phase 2: fresh AppState starts with an empty registry (the bug).
    let state = AppState::new(root);
    assert!(
        state.registry.is_empty(),
        "AppState::new must start with an empty registry"
    );

    // The fix: hydrate from disk.
    let count = state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk");

    assert_eq!(count, 2, "both persisted palaces should be loaded");
    assert_eq!(state.registry.len(), 2, "registry should hold both palaces");
    let ids: Vec<String> = state.registry.list().into_iter().map(|p| p.0).collect();
    assert!(ids.contains(&"alpha".to_string()));
    assert!(ids.contains(&"beta".to_string()));
}

/// Why (issue #1487): the HTTP daemon must open palace redb files as a
/// writer so a second instance fails loud instead of silently degrading to
/// read-only snapshot mode. `AppState::with_writer_intent()` is the builder
/// `run_serve` calls on the daemon path; this asserts it actually flips the
/// registry's open intent to `Writer`, while a plain `AppState::new`
/// (used by CLI / tests) stays `ReadOnlyClient`.
/// What: builds a default `AppState` and asserts `ReadOnlyClient`, then a
/// `with_writer_intent()` `AppState` and asserts `Writer`.
/// Test: this test itself.
#[tokio::test]
async fn with_writer_intent_marks_registry_writer() {
    use trusty_common::memory_core::store::OpenIntent;

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();

    let default_state = AppState::new(root.clone());
    assert_eq!(
        default_state.registry.open_intent(),
        OpenIntent::ReadOnlyClient,
        "default AppState registry must be read-only (CLI / test paths)"
    );

    let writer_state = AppState::new(root).with_writer_intent();
    assert_eq!(
        writer_state.registry.open_intent(),
        OpenIntent::Writer,
        "with_writer_intent() must flip the daemon registry to Writer"
    );
}

/// Why (issue #1487 hardening): `with_writer_intent` replaces the registry
/// `Arc`, so it must only ever run on a fresh, unhydrated registry — calling
/// it after palaces are loaded would silently drop the hydrated handles. The
/// `debug_assert!` invariant guard makes that ordering violation a loud,
/// fail-fast programmer error in debug/test builds instead of silent data loss.
/// What: persists a palace to disk, hydrates a fresh `AppState`'s registry via
/// `load_palaces_from_disk`, then calls `with_writer_intent` and asserts it
/// panics on the `is_empty()` invariant (the strongest cheap guard).
/// Test: this test itself (via `#[should_panic]`).
#[tokio::test]
#[should_panic(expected = "is_empty()")]
async fn with_writer_intent_panics_on_hydrated_registry() {
    use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();

    // Persist one palace to disk via the same path the palace_create tool uses.
    {
        let writer = PalaceRegistry::new();
        let palace = Palace {
            id: PalaceId::new("hydrated"),
            name: "hydrated".to_string(),
            description: None,
            created_at: chrono::Utc::now(),
            data_dir: root.join("hydrated"),
        };
        writer
            .create_palace(&root, palace)
            .expect("persist palace to disk");
    }

    // Hydrate a fresh AppState's registry from disk so it is no longer empty.
    let state = AppState::new(root);
    let count = state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk");
    assert_eq!(count, 1, "precondition: registry must be hydrated");

    // Calling the builder on a hydrated registry must trip the debug_assert!
    // invariant guard rather than silently dropping the live handle.
    let _ = state.with_writer_intent();
}

/// A registry root holding one palace whose vector store is in an
/// unopenable on-disk format.
///
/// Why: the two tests below need a condition where `OpenIntent` changes the
/// OUTCOME of an open, not merely its concurrency behaviour — that is the fork
/// #4911 introduced (`Writer` recovers per #702, `ReadOnlyClient` refuses).
/// Lock contention cannot serve here: `KgStoreRedb` keeps an in-process cache
/// keyed by canonical path, so a second open inside one test binary shares the
/// first handle's `OpenMode` rather than contending with it.
/// What: writes `palace.json` and a 4096-byte garbage `index.usearch.redb` —
/// the same fixture
/// `read_only_intent_refuses_incompatible_format_and_leaves_store_intact` uses,
/// which that test proves really does trip `is_incompatible_format`. No store
/// is opened here, so nothing is left in the redb cache to perturb the
/// hydration under test. Returns `(tempdir guard, registry root, palace id,
/// garbage store path)`.
/// Test: `load_palaces_from_disk_honours_registry_open_intent` and
/// `load_palaces_from_disk_records_an_unopenable_palace`.
fn incompatible_palace_fixture() -> (
    tempfile::TempDir,
    std::path::PathBuf,
    trusty_common::memory_core::PalaceId,
    std::path::PathBuf,
) {
    use trusty_common::memory_core::store::PalaceStore;
    use trusty_common::memory_core::{Palace, PalaceId};

    const GARBAGE: [u8; 4096] = [0xABu8; 4096];

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();
    let pid = PalaceId::new("stale-format");
    let data_dir = root.join(pid.as_str());
    std::fs::create_dir_all(&data_dir).expect("mkdir palace data dir");

    PalaceStore::save_palace(&Palace {
        id: pid.clone(),
        name: "stale-format".to_string(),
        description: None,
        created_at: chrono::Utc::now(),
        data_dir: data_dir.clone(),
    })
    .expect("persist palace metadata");

    let store_path = data_dir.join("index.usearch.redb");
    std::fs::write(&store_path, GARBAGE).expect("write incompatible-format store");

    (tmp, root, pid, store_path)
}

/// Why (issue #4911): `with_writer_intent()` is what makes the #1487 guarantee
/// real, but startup hydration is the path that opens every palace a restarting
/// daemon already has on disk — the common case, and the one `main.rs` claims
/// the guarantee for. While `load_palaces_from_disk` called the zero-arg
/// `PalaceHandle::open` (hardcoded `ReadOnlyClient`), that claim was false: the
/// daemon's registry said `Writer` and hydration opened as a read-only client
/// anyway. The visible consequence is the #702 recovery — a daemon restarting
/// onto a store written by an older redb IS the writer that owns that file and
/// must rename it aside and rebuild, but hydration refused every such palace
/// instead, so the recovery never ran on the path that needs it.
/// What: builds the fixture twice. Phase 1 hydrates a default
/// (`ReadOnlyClient`) `AppState` and asserts it refuses and leaves the bytes
/// untouched (#4911's own contract, unchanged). Phase 2 hydrates a
/// `with_writer_intent()` `AppState` over an identical fixture and asserts the
/// palace LOADS and the stale bytes were renamed aside. Phase 2 is what fails
/// while hydration ignores `registry.open_intent()`.
/// Test: this test itself.
#[tokio::test]
async fn load_palaces_from_disk_honours_registry_open_intent() {
    use trusty_common::memory_core::store::redb_open::INCOMPATIBLE_SUFFIX;
    use trusty_common::memory_core::store::OpenIntent;

    // Phase 1 — a read-only client must refuse and leave the store where it is.
    let (_ro_tmp, ro_root, ro_pid, ro_store) = incompatible_palace_fixture();
    let before = std::fs::read(&ro_store).expect("read fixture bytes");

    let reader_state = AppState::new(ro_root);
    assert_eq!(
        reader_state.registry.open_intent(),
        OpenIntent::ReadOnlyClient,
        "precondition: a plain AppState is a read-only client"
    );
    let reader_count = reader_state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk (read-only client)");
    assert_eq!(
        reader_count, 0,
        "a read-only client must refuse an incompatible-format palace, not recover it"
    );
    assert!(
        reader_state.registry.get(&ro_pid).is_none(),
        "a refused palace must not be registered"
    );
    assert_eq!(
        before,
        std::fs::read(&ro_store).expect("re-read fixture bytes"),
        "the store must be byte-identical after a refused read-only hydration"
    );

    // Phase 2 — the daemon's writer registry OWNS the file and must recover it.
    let (_w_tmp, w_root, w_pid, w_store) = incompatible_palace_fixture();
    let writer_state = AppState::new(w_root).with_writer_intent();
    assert_eq!(
        writer_state.registry.open_intent(),
        OpenIntent::Writer,
        "precondition: the daemon AppState is a writer"
    );
    let writer_count = writer_state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk (writer)");

    assert_eq!(
        writer_count, 1,
        "writer-intent hydration must run the #702 recovery and load the palace; \
         a count of 0 means load_palaces_from_disk ignored registry.open_intent() \
         and opened as a ReadOnlyClient"
    );
    assert!(
        writer_state.registry.get(&w_pid).is_some(),
        "the recovered palace must be registered"
    );
    let backup = w_store.with_file_name(format!(
        "{}{INCOMPATIBLE_SUFFIX}",
        w_store
            .file_name()
            .expect("fixture store has a file name")
            .to_string_lossy()
    ));
    assert!(
        backup.exists(),
        "the writer must rename the stale store aside to {}",
        backup.display()
    );
}

/// Why (issue #4911): trading data destruction for data invisibility is not a
/// fix. `load_palaces_from_disk` skips a palace it cannot open, leaving it
/// absent from the registry and indistinguishable from one that never existed.
/// Recording the skip only in `PalaceRegistry::open` would keep that record
/// empty in every shipped binary — no production caller invokes it; the daemon
/// hydrates through this method.
/// What: hydrates a default `AppState` over a root holding one
/// incompatible-format palace and one healthy palace, then asserts the skipped
/// one is readable back out of the registry with a reason and the healthy one
/// is not listed.
/// Test: this test itself.
#[tokio::test]
async fn load_palaces_from_disk_records_an_unopenable_palace() {
    use trusty_common::memory_core::store::PalaceStore;
    use trusty_common::memory_core::{Palace, PalaceId};

    let (_tmp, root, pid, _store) = incompatible_palace_fixture();

    // A second, healthy palace alongside it: one bad palace must not hide a
    // good one, and a good one must never be reported unopenable.
    let good = PalaceId::new("healthy");
    let good_dir = root.join(good.as_str());
    std::fs::create_dir_all(&good_dir).expect("mkdir healthy palace dir");
    PalaceStore::save_palace(&Palace {
        id: good.clone(),
        name: "healthy".to_string(),
        description: None,
        created_at: chrono::Utc::now(),
        data_dir: good_dir,
    })
    .expect("persist healthy palace metadata");

    let state = AppState::new(root);
    let count = state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk");
    assert_eq!(count, 1, "only the healthy palace hydrates");

    let reason = state
        .registry
        .unopenable_reason(&pid)
        .expect("a palace present on disk but unopenable must stay observable");
    assert!(
        reason.contains("incompatible") || reason.contains("open vector store"),
        "the recorded reason must explain the refusal; got: {reason}"
    );
    assert!(
        state.registry.unopenable_reason(&good).is_none(),
        "a palace that hydrated must never be reported unopenable"
    );
    let listed: Vec<String> = state
        .registry
        .unopenable()
        .into_iter()
        .map(|(id, _)| id.0)
        .collect();
    assert_eq!(
        listed,
        vec!["stale-format".to_string()],
        "exactly the unopenable palace is listed"
    );
}

/// Why: existing installs (and the legacy standalone `trusty-memory` repo)
/// nest palaces one level deeper under a `palaces/` subdirectory. When that
/// subdirectory exists, `resolve_palace_registry_dir` must descend into it
/// so the daemon scans the level that actually holds the `palace.json`
/// files — otherwise it finds zero palaces, which is the restart bug.
/// What: creates `<dir>/palaces/`, resolves, and asserts the nested path is
/// returned.
/// Test: this test itself.
#[test]
fn resolve_palace_registry_dir_prefers_palaces_subdir() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let data_dir = tmp.path().to_path_buf();
    std::fs::create_dir_all(data_dir.join("palaces")).expect("mkdir palaces");

    let resolved = resolve_palace_registry_dir(data_dir.clone());
    assert_eq!(resolved, data_dir.join("palaces"));
}

/// Why: a fresh install with no `palaces/` subdirectory must fall back to
/// the data dir itself (the current flat monorepo layout).
#[test]
fn resolve_palace_registry_dir_falls_back_to_data_dir() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let data_dir = tmp.path().to_path_buf();

    let resolved = resolve_palace_registry_dir(data_dir.clone());
    assert_eq!(resolved, data_dir);
}

/// Why: defense-in-depth assertion (#503) — a non-absolute data_dir must be
/// caught and rejected before reaching `AppState::new`, as it would create
/// palace dirs relative to the daemon CWD (/ under launchd).
/// What: passing a relative path to `resolve_palace_registry_dir` produces
/// a relative result; the daemon startup guard in `main.rs` must refuse it.
/// This test validates the outcome of that guard path by confirming that a
/// relative input would NOT produce an absolute registry dir.
/// Test: this test itself.
#[test]
fn resolve_palace_registry_dir_relative_input_is_not_absolute() {
    // A relative dir is not a valid input, but we want to confirm that
    // if one somehow slipped through, the result would also be relative —
    // so the `main.rs` guard (is_absolute check) correctly rejects it.
    let relative = std::path::PathBuf::from("relative/path");
    let result = resolve_palace_registry_dir(relative.clone());
    assert!(
        !result.is_absolute(),
        "a relative input should produce a relative registry dir (caught by main.rs guard)"
    );
}

/// Why: defense-in-depth assertion (#503) — a data_dir equal to "/" must be
/// caught before reaching `AppState::new` to prevent palace dirs at `/`.
/// What: confirms that "/" produces a result that equals "/", which the
/// `main.rs` startup guard correctly rejects.
/// Test: this test itself.
#[test]
fn resolve_palace_registry_dir_root_input_stays_root() {
    let root = std::path::PathBuf::from("/");
    let result = resolve_palace_registry_dir(root);
    // The guard in main.rs checks data_dir (before calling this fn), so
    // "/" would be caught before reaching this fn. But if it did reach here,
    // we confirm it yields "/" (no palaces/ subdir exists there), which would
    // then be caught by the main.rs post-resolve guard.
    assert_eq!(result, std::path::PathBuf::from("/"));
}

/// Why: end-to-end check that the nested-`palaces/` layout hydrates — the
/// daemon resolves the registry dir via `resolve_palace_registry_dir`, so
/// an `AppState` rooted there must load palaces persisted one level below
/// the bare data dir.
/// What: persists two palaces under `<root>/palaces/<id>/`, constructs an
/// `AppState` rooted at the resolved registry dir, and asserts hydration
/// finds both.
/// Test: this test itself.
#[tokio::test]
async fn load_palaces_from_disk_handles_palaces_subdir() {
    use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();
    let nested = root.join("palaces");

    {
        let writer = PalaceRegistry::new();
        for id in ["cto", "engineering"] {
            let palace = Palace {
                id: PalaceId::new(id),
                name: id.to_string(),
                description: None,
                created_at: chrono::Utc::now(),
                data_dir: nested.join(id),
            };
            // create_palace anchors data_dir under the passed root, so
            // pass `nested` here to land palaces under `<root>/palaces/`.
            writer
                .create_palace(&nested, palace)
                .expect("persist palace under palaces/ subdir");
        }
    }

    // Mirror main.rs: resolve the registry dir, then root AppState there.
    let registry_dir = resolve_palace_registry_dir(root);
    assert_eq!(registry_dir, nested, "must resolve into palaces/ subdir");
    let state = AppState::new(registry_dir);
    let count = state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk");

    assert_eq!(count, 2, "both nested palaces should be loaded");
    assert_eq!(state.registry.len(), 2);
    let ids: Vec<String> = state.registry.list().into_iter().map(|p| p.0).collect();
    assert!(ids.contains(&"cto".to_string()));
    assert!(ids.contains(&"engineering".to_string()));
}

/// Why: an empty (or missing) palace registry directory must not error — a
/// brand-new install has nothing to hydrate and should report zero.
#[tokio::test]
async fn load_palaces_from_disk_empty_root_returns_zero() {
    let (state, _tmp) = test_state();
    let count = state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk on empty root");
    assert_eq!(count, 0);
    assert!(state.registry.is_empty());
}

/// Why (issue #228): hydration must seed `state.palace_names` so the
/// MCP write hot path (`memory_remember` / `memory_note`) can resolve a
/// friendly palace name without re-walking the data root on every call.
/// Regression risk: a future refactor that forgets to populate the cache
/// would silently degrade write latency.
/// What: persists two palaces with distinct `name` values, constructs a
/// fresh `AppState`, hydrates from disk, and asserts the cache holds the
/// expected mappings.
/// Test: this test itself.
#[tokio::test]
async fn palace_name_cache_populated_after_hydration() {
    use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};

    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().to_path_buf();
    {
        let writer = PalaceRegistry::new();
        for (id, name) in [("alpha", "Alpha Project"), ("beta", "Beta Project")] {
            let palace = Palace {
                id: PalaceId::new(id),
                name: name.to_string(),
                description: None,
                created_at: chrono::Utc::now(),
                data_dir: root.join(id),
            };
            writer.create_palace(&root, palace).expect("persist palace");
        }
    }

    let state = AppState::new(root);
    assert!(
        state.palace_names.is_empty(),
        "fresh AppState must start with an empty name cache"
    );
    state
        .load_palaces_from_disk()
        .await
        .expect("load_palaces_from_disk");

    assert_eq!(state.palace_names.len(), 2, "cache must hold both palaces");
    assert_eq!(
        state.palace_names.get("alpha").map(|e| e.value().clone()),
        Some("Alpha Project".to_string()),
    );
    assert_eq!(
        state.palace_names.get("beta").map(|e| e.value().clone()),
        Some("Beta Project".to_string()),
    );
}

/// Why (issue #228): `palace_create` (MCP tool) and `MemoryService::create_palace`
/// (HTTP path) both insert into the name cache so a freshly-created palace
/// is resolvable on the very next write — without waiting for the next
/// hydration cycle.
/// What: dispatches the `palace_create` MCP tool against a tempdir and
/// asserts the cache row was written.
/// Test: this test itself.
#[tokio::test]
async fn palace_name_cache_updates_on_create() {
    use serde_json::json;

    let (state, _tmp) = test_state();
    let _ = tools::dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
        .await
        .expect("palace_create");
    assert_eq!(
        state.palace_names.get("gamma").map(|e| e.value().clone()),
        Some("gamma".to_string()),
        "palace_create must populate the in-memory name cache so writes \
             can resolve the friendly name without a disk walk"
    );
}

/// Why: initialize without a default palace must omit `default_palace`
/// from `serverInfo` so clients can detect the unbound mode.
#[tokio::test]
async fn initialize_without_default_palace_omits_field() {
    let (state, _tmp) = test_state();
    let init = handle_message(
        &state,
        json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}),
    )
    .await;
    assert!(init["result"]["serverInfo"]["default_palace"].is_null());
}

// #6286: the three tests that used to sit here — `http_addr_path`'s location,
// `write_http_addr_file`'s one-line-plus-newline disk format, and
// `bind_dynamic_port` walking 7070..=7079 — all covered the discovery file and
// the port walk ADR-0032 retired. There is no file and no port to pin: the
// socket path is derived by `trusty_common::daemon_socket_path`, and
// `socket_path_is_named_for_this_daemon` in `transport::uds::tests` is what
// keeps every side computing the same one.

/// Why: Issue #42 — prompt-facts are now served by the per-message
/// `get_prompt_context` tool rather than the MCP prompts surface, so the
/// `initialize` handshake must NOT advertise a `prompts` capability and
/// `prompts/list` / `prompts/get` must fall through to the "method not
/// found" path.
#[tokio::test]
async fn initialize_does_not_advertise_prompts_capability() {
    let (state, _tmp) = test_state();
    let init = handle_message(
        &state,
        json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}),
    )
    .await;
    assert!(
        init["result"]["capabilities"]["prompts"].is_null(),
        "initialize must NOT advertise the prompts capability; got {init}"
    );

    // Both prompts/* dispatchers should now report method-not-found.
    for method in ["prompts/list", "prompts/get"] {
        let resp =
            handle_message(&state, json!({"jsonrpc": "2.0", "id": 2, "method": method})).await;
        assert_eq!(
            resp["error"]["code"], -32601,
            "{method} should return method-not-found; got {resp}"
        );
    }
}

/// Why: `AppState::new` must initialise `bound_addr` to an empty
/// `OnceLock` so `/health` reports `addr: None` on the stdio path. A
/// regression that pre-populates this field would advertise a bogus
/// address from a stale clone.
///
/// Note (issue #231): now async so it runs inside a Tokio runtime —
/// `AppState::new` spawns the bounded BM25 index worker via
/// `tokio::spawn`, which requires an active runtime.
#[tokio::test]
async fn app_state_starts_with_empty_bound_addr() {
    let (state, _tmp) = test_state();
    assert!(state.bound_addr.get().is_none());
}

/// Why (issue #96): `DaemonEvent::type_str` underpins the persisted
/// activity log's `event_type` column — every variant must map to the
/// exact SSE `type` tag the UI already handles. A drift between the
/// SSE wire format and the stored type would break the feed's icon /
/// label rendering for historical rows.
/// What: constructs one of each variant, serialises via serde, and
/// confirms `type_str()` matches the JSON `type` field.
/// Test: this test.
#[test]
fn daemon_event_type_str_matches_sse_tag() {
    let cases = [
        DaemonEvent::PalaceCreated {
            id: "p".into(),
            name: "p".into(),
            source: ActivitySource::Http,
        },
        DaemonEvent::DrawerAdded {
            palace_id: "p".into(),
            palace_name: "p".into(),
            drawer_count: 1,
            timestamp: chrono::Utc::now(),
            content_preview: String::new(),
            source: ActivitySource::Mcp,
        },
        DaemonEvent::DrawerDeleted {
            palace_id: "p".into(),
            drawer_count: 0,
            source: ActivitySource::Http,
        },
        DaemonEvent::DreamCompleted {
            palace_id: None,
            merged: 0,
            pruned: 0,
            compacted: 0,
            closets_updated: 0,
            duration_ms: 0,
            source: ActivitySource::Http,
        },
        DaemonEvent::StatusChanged {
            total_drawers: 0,
            total_vectors: 0,
            total_kg_triples: 0,
        },
        DaemonEvent::HookFired {
            palace_id: Some("p".into()),
            palace_name: Some("p".into()),
            hook_type: HookType::UserPromptSubmit,
            injection_kind: InjectionKind::PromptContext,
            injection_length: 12,
            trigger_prompt_excerpt: "hello".into(),
            timestamp: chrono::Utc::now(),
            duration_ms: 5,
            source: ActivitySource::Hook,
        },
    ];
    for ev in &cases {
        let json = serde_json::to_value(ev).unwrap();
        assert_eq!(json["type"].as_str(), Some(ev.type_str()));
    }
}

/// Why: `HookType` is serialised on every `HookFired` activity row; its
/// wire format must round-trip cleanly so dashboard / TUI consumers can
/// safely parse historic entries written by an older daemon build.
/// What: serde-encodes each variant, asserts the JSON matches the
/// expected PascalCase label, then decodes back.
/// Test: itself.
#[test]
fn hook_type_serde_round_trips() {
    let cases = [
        (HookType::UserPromptSubmit, "\"UserPromptSubmit\""),
        (HookType::SessionStart, "\"SessionStart\""),
    ];
    for (ht, expected) in cases {
        let s = serde_json::to_string(&ht).unwrap();
        assert_eq!(s, expected, "{ht:?} should serialise to {expected}");
        let back: HookType = serde_json::from_str(&s).unwrap();
        assert_eq!(back, ht);
        assert_eq!(ht.as_str(), expected.trim_matches('"'));
    }
}

/// Why: same as `hook_type_serde_round_trips` but for `InjectionKind`.
/// What: kebab-case round trip on every variant.
/// Test: itself.
#[test]
fn injection_kind_serde_round_trips() {
    let cases = [
        (InjectionKind::PromptContext, "\"prompt-context\""),
        (InjectionKind::InboxCheck, "\"inbox-check\""),
    ];
    for (ik, expected) in cases {
        let s = serde_json::to_string(&ik).unwrap();
        assert_eq!(s, expected);
        let back: InjectionKind = serde_json::from_str(&s).unwrap();
        assert_eq!(back, ik);
        assert_eq!(ik.as_str(), expected.trim_matches('"'));
    }
}

/// Why: the activity feed renders the trigger prompt excerpt directly;
/// runaway prompts must be capped at [`HOOK_PROMPT_EXCERPT_CHARS`] with
/// a `…` marker so the row stays readable.
/// What: feeds a 200-character prompt and asserts the excerpt is
/// bounded.
/// Test: itself.
#[test]
fn hook_excerpt_truncates_long_prompts() {
    let long = "x".repeat(200);
    let excerpt = hook_prompt_excerpt(&long);
    assert!(excerpt.chars().count() <= HOOK_PROMPT_EXCERPT_CHARS);
    assert!(excerpt.ends_with('…'));
    assert_eq!(hook_prompt_excerpt(""), "");
}

/// Why: multi-line prompts must collapse to a single line so the
/// activity feed row doesn't blow out vertically.
/// What: feeds a multi-line whitespace-heavy prompt and asserts the
/// output is a single-spaced single line.
/// Test: itself.
#[test]
fn hook_excerpt_collapses_whitespace() {
    let input = "hello\n\nworld\t\tfoo";
    let excerpt = hook_prompt_excerpt(input);
    assert_eq!(excerpt, "hello world foo");
}

/// Why (issue #96): `palace_id()` and `source()` feed the persisted
/// activity log's columns; they must extract the right field per
/// variant. Sloppy refactors could swap two fields and the log would
/// silently mis-attribute writes.
/// What: builds each variant with known field values and asserts the
/// extractor returns them.
/// Test: this test.
#[test]
fn daemon_event_palace_id_and_source_extraction() {
    let ev = DaemonEvent::DrawerAdded {
        palace_id: "alpha".into(),
        palace_name: "alpha".into(),
        drawer_count: 1,
        timestamp: chrono::Utc::now(),
        content_preview: String::new(),
        source: ActivitySource::Mcp,
    };
    assert_eq!(ev.palace_id(), Some("alpha"));
    assert_eq!(ev.source(), Some(ActivitySource::Mcp));

    let status = DaemonEvent::StatusChanged {
        total_drawers: 1,
        total_vectors: 2,
        total_kg_triples: 3,
    };
    assert_eq!(status.palace_id(), None);
    assert_eq!(status.source(), None);

    let dream = DaemonEvent::DreamCompleted {
        palace_id: Some("p1".into()),
        merged: 0,
        pruned: 0,
        compacted: 0,
        closets_updated: 0,
        duration_ms: 10,
        source: ActivitySource::Http,
    };
    assert_eq!(dream.palace_id(), Some("p1"));
    assert_eq!(dream.source(), Some(ActivitySource::Http));
}

/// Why (issue #96): `AppState::emit` must persist mutation events to
/// the activity log while keeping `StatusChanged` (a recomputed
/// aggregate, not a mutation) out of the persisted history.
/// What: emits one of each variant under a fresh state and asserts
/// the persisted count matches the number of mutation events.
/// Test: this test.
#[tokio::test]
async fn emit_persists_mutations_but_skips_status_changed() {
    let (state, _tmp) = test_state();
    state.emit(DaemonEvent::PalaceCreated {
        id: "p".into(),
        name: "p".into(),
        source: ActivitySource::Http,
    });
    state.emit(DaemonEvent::StatusChanged {
        total_drawers: 1,
        total_vectors: 0,
        total_kg_triples: 0,
    });
    state.emit(DaemonEvent::DrawerAdded {
        palace_id: "p".into(),
        palace_name: "p".into(),
        drawer_count: 1,
        timestamp: chrono::Utc::now(),
        content_preview: "x".into(),
        source: ActivitySource::Mcp,
    });
    // Issue #232: `emit` now offloads the redb write to `spawn_blocking`,
    // so the test must wait for the background pool to drain before
    // asserting on the persisted count.
    state.flush_activity_writes().await;
    let count = state.activity_log.count().unwrap();
    assert_eq!(count, 2, "only PalaceCreated + DrawerAdded must persist");
}

/// Why (issue #156): the BM25 lane must be opt-in — existing deployments that
/// do not set `TRUSTY_BM25_DAEMON=1` must see `bm25 = None` and the recall hot
/// path must continue to behave exactly as before.
/// What: builds an `AppState` with `with_bm25_lane_from_env()` while the env
/// var is unset; asserts the field stays `None`. #5329 folded the former
/// `bm25_supervisor` assertion into this one — there is a single field now, so
/// there is no client/supervisor opt-in parity left to drift.
/// Test: this test.
#[tokio::test]
async fn bm25_lane_disabled_by_default() {
    // Serialise with the sibling `bm25_lane_enabled_when_env_set` test so they
    // do not race on the shared `TRUSTY_BM25_DAEMON` env var.
    let _guard = crate::commands::env_test_lock().lock().await;
    // SAFETY: this test exercises std::env::remove_var which is unsafe in 2024
    // edition because the global env is shared. We restore the pre-test value
    // at the end so neighbours are unaffected.
    let prev = std::env::var("TRUSTY_BM25_DAEMON").ok();
    unsafe {
        std::env::remove_var("TRUSTY_BM25_DAEMON");
    }
    let (state, _tmp) = test_state();
    let state = state.with_bm25_lane_from_env();
    assert!(
        state.bm25.is_none(),
        "the bm25 lane must be None when TRUSTY_BM25_DAEMON is unset"
    );
    if let Some(v) = prev {
        unsafe {
            std::env::set_var("TRUSTY_BM25_DAEMON", v);
        }
    }
}

/// Why (issue #156, #5329): when the operator opts in via
/// `TRUSTY_BM25_DAEMON=1`, the builder must construct a real lane. The gate
/// keeps its daemon-era name deliberately — an operator who set it before the
/// collapse must not silently lose the lane.
/// What: sets the env var, runs the builder, asserts the lane is present and
/// serving the palace directory layout the daemon used.
/// Test: this test.
#[tokio::test]
async fn bm25_lane_enabled_when_env_set() {
    let _guard = crate::commands::env_test_lock().lock().await;
    let prev = std::env::var("TRUSTY_BM25_DAEMON").ok();
    unsafe {
        std::env::set_var("TRUSTY_BM25_DAEMON", "1");
    }
    let (state, _tmp) = test_state();
    let state = state.with_bm25_lane_from_env();
    let lane = state
        .bm25
        .as_ref()
        .expect("the bm25 lane must be Some when TRUSTY_BM25_DAEMON=1");
    // The lane must be rooted at this state's data_root, which is where the
    // retired daemon wrote its snapshots — otherwise an existing corpus is
    // invisible and #5329's migration promise is empty.
    assert_eq!(
        lane.data_dir_for_palace("some-palace"),
        state.data_root.join("some-palace").join("bm25"),
    );
    match prev {
        Some(v) => unsafe { std::env::set_var("TRUSTY_BM25_DAEMON", v) },
        None => unsafe { std::env::remove_var("TRUSTY_BM25_DAEMON") },
    }
}

/// Why (issue #2223): `with_multi_tenant_mode_from_env()` existed and was
/// unit-tested at the `authz` level (manual field assignment) but was never
/// wired into `main.rs` startup, so the env-var parsing path itself had no
/// coverage. This pins the opt-out default: unset `TRUSTY_MEMORY_MULTI_TENANT`
/// must leave `multi_tenant_mode = false`, preserving today's single-tenant
/// behaviour exactly.
/// What: builds an `AppState` with `with_multi_tenant_mode_from_env()` while
/// the env var is unset; asserts the field stays `false`.
/// Test: this test.
#[tokio::test]
async fn multi_tenant_mode_disabled_by_default() {
    // Serialise with the sibling `multi_tenant_mode_enabled_when_env_set`
    // test so they don't race on the shared `TRUSTY_MEMORY_MULTI_TENANT` env
    // var (mirrors the BM25 env-test pattern above).
    let _guard = crate::commands::env_test_lock().lock().await;
    // SAFETY: this test exercises std::env::remove_var which is unsafe in
    // 2024 edition because the global env is shared. We restore the
    // pre-test value at the end so neighbours are unaffected.
    let prev = std::env::var("TRUSTY_MEMORY_MULTI_TENANT").ok();
    unsafe {
        std::env::remove_var("TRUSTY_MEMORY_MULTI_TENANT");
    }
    let (state, _tmp) = test_state();
    let state = state.with_multi_tenant_mode_from_env();
    assert!(
        !state.multi_tenant_mode,
        "multi_tenant_mode must be false when TRUSTY_MEMORY_MULTI_TENANT is unset"
    );
    if let Some(v) = prev {
        unsafe {
            std::env::set_var("TRUSTY_MEMORY_MULTI_TENANT", v);
        }
    }
}

/// Why (issue #2223): when the operator opts in via
/// `TRUSTY_MEMORY_MULTI_TENANT=1`, the builder must flip `multi_tenant_mode`
/// to `true` so `authz::authorize_force_palace_create` starts failing closed
/// on `force=true` (see `authz.rs`'s own tests for that enforcement).
/// What: sets the env var, runs the builder, asserts `true`.
/// Test: this test.
#[tokio::test]
async fn multi_tenant_mode_enabled_when_env_set() {
    let _guard = crate::commands::env_test_lock().lock().await;
    let prev = std::env::var("TRUSTY_MEMORY_MULTI_TENANT").ok();
    unsafe {
        std::env::set_var("TRUSTY_MEMORY_MULTI_TENANT", "1");
    }
    let (state, _tmp) = test_state();
    let state = state.with_multi_tenant_mode_from_env();
    assert!(
        state.multi_tenant_mode,
        "multi_tenant_mode must be true when TRUSTY_MEMORY_MULTI_TENANT=1"
    );
    match prev {
        Some(v) => unsafe { std::env::set_var("TRUSTY_MEMORY_MULTI_TENANT", v) },
        None => unsafe { std::env::remove_var("TRUSTY_MEMORY_MULTI_TENANT") },
    }
}

// -------------------------------------------------------------------------
// Issues #910 / #911 — DaemonReadiness
// -------------------------------------------------------------------------

/// Why (issue #910/#911): `AppState` starts in `Warming` state; `set_ready`
/// must flip it to `Ready` atomically; subsequent `readiness()` calls must
/// observe `Ready`.
/// What: construct a state, assert `Warming`, call `set_ready`, assert
/// `Ready`.
/// Test: this test.
#[tokio::test]
async fn daemon_readiness_transitions_warming_to_ready() {
    let (state, _tmp) = test_state_warming();
    assert_eq!(
        state.readiness(),
        DaemonReadiness::Warming,
        "daemon must start in Warming state"
    );
    state.set_ready();
    assert_eq!(
        state.readiness(),
        DaemonReadiness::Ready,
        "daemon must be Ready after set_ready()"
    );
}

/// Why (issue #911): `DaemonReadiness::from_u8` must map 0 → Warming and
/// any non-zero → Ready.
/// Test: this test.
#[test]
fn daemon_readiness_from_u8() {
    assert_eq!(DaemonReadiness::from_u8(0), DaemonReadiness::Warming);
    assert_eq!(DaemonReadiness::from_u8(1), DaemonReadiness::Ready);
    assert_eq!(DaemonReadiness::from_u8(255), DaemonReadiness::Ready);
}

// -------------------------------------------------------------------------
// Issue #467 — palaces skipped at startup hydration are lazily re-opened
// -------------------------------------------------------------------------

/// Why (issue #467): when `load_palaces_from_disk` fails to open a palace
/// (e.g. EMFILE — "Too many open files"), it logs a warning and does NOT
/// register the palace in the in-memory registry. A subsequent call to
/// `open_palace` for that id must attempt a fresh open from disk, not
/// permanently return "not found". This test verifies the lazy-reopen
/// path: create a palace on disk, remove it from the registry (simulating a
/// startup-hydration skip), then open it via `open_palace` and assert success.
/// What: builds an `AppState` with an on-disk palace that is subsequently
/// removed from the in-memory registry (simulating what happens when
/// `PalaceHandle::open` fails during `load_palaces_from_disk`), calls
/// `registry.open_palace`, and asserts the palace handle is returned.
/// Test: this test.
#[tokio::test]
async fn open_palace_lazy_reopens_hydration_skipped_palace() {
    let (state, _tmp) = test_state();
    // Create a palace on disk.
    let pid = trusty_common::memory_core::palace::PalaceId::new("hydration-skip");
    let palace = trusty_common::memory_core::Palace {
        id: pid.clone(),
        name: "hydration-skip".to_string(),
        description: None,
        created_at: chrono::Utc::now(),
        data_dir: state.data_root.join("hydration-skip"),
    };
    state
        .registry
        .create_palace(&state.data_root, palace)
        .expect("create_palace");

    // Simulate a startup-hydration skip by removing the just-registered handle.
    // In production, the palace is simply never registered because
    // PalaceHandle::open failed during load_palaces_from_disk (EMFILE etc.).
    state.registry.remove(&pid);

    // The registry must now report no in-memory handle for this palace.
    assert!(
        state.registry.get(&pid).is_none(),
        "palace must appear absent (simulating hydration skip) before the lazy-reopen"
    );

    // Calling open_palace should attempt a fresh open from disk and succeed.
    let handle = state
        .registry
        .open_palace(&state.data_root, &pid)
        .expect("open_palace must lazily reopen a hydration-skipped palace");
    assert_eq!(handle.id.as_str(), "hydration-skip");
}

// #6286: the two #498 dotfile tests are gone with the file. #498 added
// `~/.trusty-memory/http_addr` because claude-mpm read the dotfile path while
// the daemon wrote the OS-standard one; ADR-0032 removed both writes, and
// `transport::uds::remove_retired_discovery_files` deletes whichever of them a
// machine still carries.