ai-memory 0.7.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
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
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
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! Tool registry: tool definitions, profile filtering, capabilities family dispatch.

use serde_json::{Value, json};

// --- v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — tool_names module ---

/// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — canonical
/// constants for every MCP tool name. Replaces ~150 inline string
/// literals across the codebase. Adding a tool name in one place
/// instead of N places makes renaming a one-line edit and prevents
/// silent drift between dispatch-arm tables and audit/governance
/// allowlists.
///
/// **Invariants pinned by the registry-level pin tests below
/// (`tool_names::pin_tests`):**
///
/// - Every const's value is byte-equal to the historical wire string
///   it replaces; the wire layer still emits `"memory_store"` as the
///   JSON-RPC method name, exact byte-for-byte.
/// - Every tool registered in [`registered_tools`] has a matching
///   `pub const` here and the const value equals the tool's
///   `McpTool::name()` return.
/// - The count of consts equals
///   [`crate::profile::Profile::full().expected_tool_count`].
///
/// **Adding a new tool**: append the const here AND the
/// `RegisteredTool::of::<…>()` line in [`registered_tools`]. The pin
/// tests will fail if either side is missed.
///
/// Naming convention: `MEMORY_<UPPER_SNAKE>` matches the wire string
/// `memory_<lower_snake>` so the call site reads naturally
/// (`tool_names::MEMORY_STORE => …` ⇒ `"memory_store" => …`).
pub mod tool_names {
    pub const MEMORY_AGENT_LIST: &str = "memory_agent_list";
    pub const MEMORY_AGENT_REGISTER: &str = "memory_agent_register";
    pub const MEMORY_ARCHIVE_LIST: &str = "memory_archive_list";
    pub const MEMORY_ARCHIVE_PURGE: &str = "memory_archive_purge";
    pub const MEMORY_ARCHIVE_RESTORE: &str = "memory_archive_restore";
    pub const MEMORY_ARCHIVE_STATS: &str = "memory_archive_stats";
    pub const MEMORY_ATOMISE: &str = "memory_atomise";
    pub const MEMORY_AUTO_TAG: &str = "memory_auto_tag";
    pub const MEMORY_CALIBRATE_CONFIDENCE: &str = "memory_calibrate_confidence";
    pub const MEMORY_CAPABILITIES: &str = "memory_capabilities";
    /// v0.7.0 #1389 L4 — host-volunteered turn capture per
    /// RFC-0001. See `src/mcp/tools/capture_turn.rs`.
    pub const MEMORY_CAPTURE_TURN: &str = "memory_capture_turn";
    pub const MEMORY_CHECK_AGENT_ACTION: &str = "memory_check_agent_action";
    pub const MEMORY_CHECK_DUPLICATE: &str = "memory_check_duplicate";
    pub const MEMORY_CONSOLIDATE: &str = "memory_consolidate";
    pub const MEMORY_DELETE: &str = "memory_delete";
    pub const MEMORY_DEPENDENTS_OF_INVALIDATED: &str = "memory_dependents_of_invalidated";
    pub const MEMORY_DEREF: &str = "memory_deref";
    pub const MEMORY_DETECT_CONTRADICTION: &str = "memory_detect_contradiction";
    pub const MEMORY_ENTITY_GET_BY_ALIAS: &str = "memory_entity_get_by_alias";
    pub const MEMORY_ENTITY_REGISTER: &str = "memory_entity_register";
    pub const MEMORY_EXPAND_QUERY: &str = "memory_expand_query";
    pub const MEMORY_EXPORT_REFLECTION: &str = "memory_export_reflection";
    pub const MEMORY_FIND_PATHS: &str = "memory_find_paths";
    pub const MEMORY_FORGET: &str = "memory_forget";
    pub const MEMORY_GC: &str = "memory_gc";
    pub const MEMORY_GET: &str = "memory_get";
    pub const MEMORY_GET_LINKS: &str = "memory_get_links";
    pub const MEMORY_GET_TAXONOMY: &str = "memory_get_taxonomy";
    pub const MEMORY_INBOX: &str = "memory_inbox";
    pub const MEMORY_INGEST_MULTISTEP: &str = "memory_ingest_multistep";
    pub const MEMORY_KG_INVALIDATE: &str = "memory_kg_invalidate";
    pub const MEMORY_KG_QUERY: &str = "memory_kg_query";
    pub const MEMORY_KG_TIMELINE: &str = "memory_kg_timeline";
    pub const MEMORY_LINK: &str = "memory_link";
    pub const MEMORY_LIST: &str = "memory_list";
    pub const MEMORY_LIST_SUBSCRIPTIONS: &str = "memory_list_subscriptions";
    pub const MEMORY_LOAD_FAMILY: &str = "memory_load_family";
    pub const MEMORY_NAMESPACE_CLEAR_STANDARD: &str = "memory_namespace_clear_standard";
    pub const MEMORY_NAMESPACE_GET_STANDARD: &str = "memory_namespace_get_standard";
    pub const MEMORY_NAMESPACE_SET_STANDARD: &str = "memory_namespace_set_standard";
    pub const MEMORY_NOTIFY: &str = "memory_notify";
    pub const MEMORY_OFFLOAD: &str = "memory_offload";
    pub const MEMORY_PENDING_APPROVE: &str = "memory_pending_approve";
    pub const MEMORY_PENDING_LIST: &str = "memory_pending_list";
    pub const MEMORY_PENDING_REJECT: &str = "memory_pending_reject";
    pub const MEMORY_PERSONA: &str = "memory_persona";
    pub const MEMORY_PERSONA_GENERATE: &str = "memory_persona_generate";
    pub const MEMORY_PROMOTE: &str = "memory_promote";
    pub const MEMORY_QUOTA_STATUS: &str = "memory_quota_status";
    pub const MEMORY_RECALL: &str = "memory_recall";
    pub const MEMORY_RECALL_OBSERVATIONS: &str = "memory_recall_observations";
    pub const MEMORY_REFLECT: &str = "memory_reflect";
    pub const MEMORY_REFLECTION_ORIGIN: &str = "memory_reflection_origin";
    pub const MEMORY_REPLAY: &str = "memory_replay";
    pub const MEMORY_RULE_LIST: &str = "memory_rule_list";
    pub const MEMORY_SEARCH: &str = "memory_search";
    pub const MEMORY_SESSION_START: &str = "memory_session_start";
    pub const MEMORY_SHARE: &str = "memory_share";
    pub const MEMORY_SKILL_COMPOSITIONAL_CONTEXT: &str = "memory_skill_compositional_context";
    pub const MEMORY_SKILL_EXPORT: &str = "memory_skill_export";
    pub const MEMORY_SKILL_GET: &str = "memory_skill_get";
    pub const MEMORY_SKILL_LIST: &str = "memory_skill_list";
    pub const MEMORY_SKILL_PROMOTE_FROM_REFLECTION: &str = "memory_skill_promote_from_reflection";
    pub const MEMORY_SKILL_REGISTER: &str = "memory_skill_register";
    pub const MEMORY_SKILL_RESOURCE: &str = "memory_skill_resource";
    pub const MEMORY_SMART_LOAD: &str = "memory_smart_load";
    pub const MEMORY_STATS: &str = "memory_stats";
    pub const MEMORY_STORE: &str = "memory_store";
    pub const MEMORY_SUBSCRIBE: &str = "memory_subscribe";
    pub const MEMORY_SUBSCRIPTION_DLQ_LIST: &str = "memory_subscription_dlq_list";
    pub const MEMORY_SUBSCRIPTION_REPLAY: &str = "memory_subscription_replay";
    pub const MEMORY_UNSUBSCRIBE: &str = "memory_unsubscribe";
    pub const MEMORY_UPDATE: &str = "memory_update";
    pub const MEMORY_VERIFY: &str = "memory_verify";

    /// The full canonical set of every MCP tool name. Useful for
    /// iterating in tests + the audit-tool-call dispatcher's
    /// "known names" guard. Order is alphabetical by const name so
    /// adding a new entry has a stable diff position. The slice
    /// length is pinned against `Profile::full().expected_tool_count()`
    /// by [`pin_tests::const_count_matches_full_profile`].
    ///
    /// `dead_code` is allowed because the only consumer today is the
    /// `pin_tests` module below; making it `pub` keeps the API
    /// available to external test crates (e.g. integration tests
    /// under `tests/`) that may want to iterate the canonical set
    /// without re-typing it.
    #[allow(dead_code)]
    pub const ALL: &[&str] = &[
        MEMORY_AGENT_LIST,
        MEMORY_AGENT_REGISTER,
        MEMORY_ARCHIVE_LIST,
        MEMORY_ARCHIVE_PURGE,
        MEMORY_ARCHIVE_RESTORE,
        MEMORY_ARCHIVE_STATS,
        MEMORY_ATOMISE,
        MEMORY_AUTO_TAG,
        MEMORY_CALIBRATE_CONFIDENCE,
        MEMORY_CAPABILITIES,
        MEMORY_CAPTURE_TURN,
        MEMORY_CHECK_AGENT_ACTION,
        MEMORY_CHECK_DUPLICATE,
        MEMORY_CONSOLIDATE,
        MEMORY_DELETE,
        MEMORY_DEPENDENTS_OF_INVALIDATED,
        MEMORY_DEREF,
        MEMORY_DETECT_CONTRADICTION,
        MEMORY_ENTITY_GET_BY_ALIAS,
        MEMORY_ENTITY_REGISTER,
        MEMORY_EXPAND_QUERY,
        MEMORY_EXPORT_REFLECTION,
        MEMORY_FIND_PATHS,
        MEMORY_FORGET,
        MEMORY_GC,
        MEMORY_GET,
        MEMORY_GET_LINKS,
        MEMORY_GET_TAXONOMY,
        MEMORY_INBOX,
        MEMORY_INGEST_MULTISTEP,
        MEMORY_KG_INVALIDATE,
        MEMORY_KG_QUERY,
        MEMORY_KG_TIMELINE,
        MEMORY_LINK,
        MEMORY_LIST,
        MEMORY_LIST_SUBSCRIPTIONS,
        MEMORY_LOAD_FAMILY,
        MEMORY_NAMESPACE_CLEAR_STANDARD,
        MEMORY_NAMESPACE_GET_STANDARD,
        MEMORY_NAMESPACE_SET_STANDARD,
        MEMORY_NOTIFY,
        MEMORY_OFFLOAD,
        MEMORY_PENDING_APPROVE,
        MEMORY_PENDING_LIST,
        MEMORY_PENDING_REJECT,
        MEMORY_PERSONA,
        MEMORY_PERSONA_GENERATE,
        MEMORY_PROMOTE,
        MEMORY_QUOTA_STATUS,
        MEMORY_RECALL,
        MEMORY_RECALL_OBSERVATIONS,
        MEMORY_REFLECT,
        MEMORY_REFLECTION_ORIGIN,
        MEMORY_REPLAY,
        MEMORY_RULE_LIST,
        MEMORY_SEARCH,
        MEMORY_SESSION_START,
        MEMORY_SHARE,
        MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
        MEMORY_SKILL_EXPORT,
        MEMORY_SKILL_GET,
        MEMORY_SKILL_LIST,
        MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
        MEMORY_SKILL_REGISTER,
        MEMORY_SKILL_RESOURCE,
        MEMORY_SMART_LOAD,
        MEMORY_STATS,
        MEMORY_STORE,
        MEMORY_SUBSCRIBE,
        MEMORY_SUBSCRIPTION_DLQ_LIST,
        MEMORY_SUBSCRIPTION_REPLAY,
        MEMORY_UNSUBSCRIBE,
        MEMORY_UPDATE,
        MEMORY_VERIFY,
    ];

    #[cfg(test)]
    mod pin_tests {
        //! v0.7.x (issue #1174 PR1) — pin tests for every const VALUE
        //! is byte-equal to the historical wire string it replaces.
        //! If any of these fail, the refactor introduced wire drift —
        //! revert the offending const or the dispatch site, NOT this
        //! test.
        use super::*;

        #[test]
        fn const_count_matches_full_profile() {
            // SSOT cross-check (no literal): the canonical name slice
            // `ALL` must hold exactly as many names as the full profile
            // advertises. `Profile::full().expected_tool_count()` is the
            // single source of truth — derived from the per-family
            // `tool_names` slices in `crate::profile`. Adding a tool is
            // one append to a family slice + one const here + one slice
            // entry + one per-name byte-equal test below + one
            // `RegisteredTool::of::<…>()` line in `registered_tools()`;
            // this assertion then tracks automatically. The
            // `consts_match_registered_tools` test verifies the registry
            // side against the same set.
            assert_eq!(
                ALL.len(),
                crate::profile::Profile::full().expected_tool_count(),
                "tool_names::ALL must cover exactly the full-profile tool set"
            );
        }

        #[test]
        fn const_set_is_deduplicated() {
            // Defence-in-depth: forbids accidental copy-paste
            // duplication when adding a new const. A duplicate entry
            // in `ALL` would silently mask the missing tool.
            use std::collections::BTreeSet;
            let unique: BTreeSet<&&str> = ALL.iter().collect();
            assert_eq!(
                unique.len(),
                ALL.len(),
                "tool_names::ALL has duplicate entries"
            );
        }

        #[test]
        fn const_values_lowercase_memory_prefix() {
            // Wire-shape invariant: every tool name starts with
            // `"memory_"` and is otherwise `[a-z_]+`. Catches the
            // failure mode where someone writes
            // `pub const MEMORY_Foo: &str = "memory_Foo";` and the
            // const compiles but the wire side rejects the casing.
            for name in ALL {
                assert!(
                    name.starts_with("memory_"),
                    "tool name {name:?} does not start with 'memory_'"
                );
                assert!(
                    name.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
                    "tool name {name:?} contains non-[a-z_] character"
                );
            }
        }

        /// Per-name byte-equal pin tests. Each `assert_eq!(CONST, "literal")`
        /// is the load-bearing assertion: if anyone "tidies" the const
        /// value (e.g. `"memory_Store"` for camel-case "consistency"
        /// with a renamed module), the wire layer breaks and this
        /// test surfaces the regression at `cargo test` time, not at
        /// `Claude Desktop reload` time.
        #[test]
        fn const_values_byte_equal_to_historical_wire_strings() {
            assert_eq!(MEMORY_AGENT_LIST, "memory_agent_list");
            assert_eq!(MEMORY_AGENT_REGISTER, "memory_agent_register");
            assert_eq!(MEMORY_ARCHIVE_LIST, "memory_archive_list");
            assert_eq!(MEMORY_ARCHIVE_PURGE, "memory_archive_purge");
            assert_eq!(MEMORY_ARCHIVE_RESTORE, "memory_archive_restore");
            assert_eq!(MEMORY_ARCHIVE_STATS, "memory_archive_stats");
            assert_eq!(MEMORY_ATOMISE, "memory_atomise");
            assert_eq!(MEMORY_AUTO_TAG, "memory_auto_tag");
            assert_eq!(MEMORY_CALIBRATE_CONFIDENCE, "memory_calibrate_confidence");
            assert_eq!(MEMORY_CAPABILITIES, "memory_capabilities");
            assert_eq!(MEMORY_CHECK_AGENT_ACTION, "memory_check_agent_action");
            assert_eq!(MEMORY_CHECK_DUPLICATE, "memory_check_duplicate");
            assert_eq!(MEMORY_CONSOLIDATE, "memory_consolidate");
            assert_eq!(MEMORY_DELETE, "memory_delete");
            assert_eq!(
                MEMORY_DEPENDENTS_OF_INVALIDATED,
                "memory_dependents_of_invalidated"
            );
            assert_eq!(MEMORY_DEREF, "memory_deref");
            assert_eq!(MEMORY_DETECT_CONTRADICTION, "memory_detect_contradiction");
            assert_eq!(MEMORY_ENTITY_GET_BY_ALIAS, "memory_entity_get_by_alias");
            assert_eq!(MEMORY_ENTITY_REGISTER, "memory_entity_register");
            assert_eq!(MEMORY_EXPAND_QUERY, "memory_expand_query");
            assert_eq!(MEMORY_EXPORT_REFLECTION, "memory_export_reflection");
            assert_eq!(MEMORY_FIND_PATHS, "memory_find_paths");
            assert_eq!(MEMORY_FORGET, "memory_forget");
            assert_eq!(MEMORY_GC, "memory_gc");
            assert_eq!(MEMORY_GET, "memory_get");
            assert_eq!(MEMORY_GET_LINKS, "memory_get_links");
            assert_eq!(MEMORY_GET_TAXONOMY, "memory_get_taxonomy");
            assert_eq!(MEMORY_INBOX, "memory_inbox");
            assert_eq!(MEMORY_INGEST_MULTISTEP, "memory_ingest_multistep");
            assert_eq!(MEMORY_KG_INVALIDATE, "memory_kg_invalidate");
            assert_eq!(MEMORY_KG_QUERY, "memory_kg_query");
            assert_eq!(MEMORY_KG_TIMELINE, "memory_kg_timeline");
            assert_eq!(MEMORY_LINK, "memory_link");
            assert_eq!(MEMORY_LIST, "memory_list");
            assert_eq!(MEMORY_LIST_SUBSCRIPTIONS, "memory_list_subscriptions");
            assert_eq!(MEMORY_LOAD_FAMILY, "memory_load_family");
            assert_eq!(
                MEMORY_NAMESPACE_CLEAR_STANDARD,
                "memory_namespace_clear_standard"
            );
            assert_eq!(
                MEMORY_NAMESPACE_GET_STANDARD,
                "memory_namespace_get_standard"
            );
            assert_eq!(
                MEMORY_NAMESPACE_SET_STANDARD,
                "memory_namespace_set_standard"
            );
            assert_eq!(MEMORY_NOTIFY, "memory_notify");
            assert_eq!(MEMORY_OFFLOAD, "memory_offload");
            assert_eq!(MEMORY_PENDING_APPROVE, "memory_pending_approve");
            assert_eq!(MEMORY_PENDING_LIST, "memory_pending_list");
            assert_eq!(MEMORY_PENDING_REJECT, "memory_pending_reject");
            assert_eq!(MEMORY_PERSONA, "memory_persona");
            assert_eq!(MEMORY_PERSONA_GENERATE, "memory_persona_generate");
            assert_eq!(MEMORY_PROMOTE, "memory_promote");
            assert_eq!(MEMORY_QUOTA_STATUS, "memory_quota_status");
            assert_eq!(MEMORY_RECALL, "memory_recall");
            assert_eq!(MEMORY_RECALL_OBSERVATIONS, "memory_recall_observations");
            assert_eq!(MEMORY_REFLECT, "memory_reflect");
            assert_eq!(MEMORY_REFLECTION_ORIGIN, "memory_reflection_origin");
            assert_eq!(MEMORY_REPLAY, "memory_replay");
            assert_eq!(MEMORY_RULE_LIST, "memory_rule_list");
            assert_eq!(MEMORY_SEARCH, "memory_search");
            assert_eq!(MEMORY_SESSION_START, "memory_session_start");
            assert_eq!(MEMORY_SHARE, "memory_share");
            assert_eq!(
                MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
                "memory_skill_compositional_context"
            );
            assert_eq!(MEMORY_SKILL_EXPORT, "memory_skill_export");
            assert_eq!(MEMORY_SKILL_GET, "memory_skill_get");
            assert_eq!(MEMORY_SKILL_LIST, "memory_skill_list");
            assert_eq!(
                MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
                "memory_skill_promote_from_reflection"
            );
            assert_eq!(MEMORY_SKILL_REGISTER, "memory_skill_register");
            assert_eq!(MEMORY_SKILL_RESOURCE, "memory_skill_resource");
            assert_eq!(MEMORY_SMART_LOAD, "memory_smart_load");
            assert_eq!(MEMORY_STATS, "memory_stats");
            assert_eq!(MEMORY_STORE, "memory_store");
            assert_eq!(MEMORY_SUBSCRIBE, "memory_subscribe");
            assert_eq!(MEMORY_SUBSCRIPTION_DLQ_LIST, "memory_subscription_dlq_list");
            assert_eq!(MEMORY_SUBSCRIPTION_REPLAY, "memory_subscription_replay");
            assert_eq!(MEMORY_UNSUBSCRIBE, "memory_unsubscribe");
            assert_eq!(MEMORY_UPDATE, "memory_update");
            assert_eq!(MEMORY_VERIFY, "memory_verify");
        }

        #[test]
        fn consts_match_registered_tools() {
            // The `tool_names::ALL` slice and the
            // `registered_tools()` iterator are two halves of the
            // same contract: they enumerate the same set of names.
            // This test enforces that every const value also appears
            // as some registered tool's `name()` return, and vice
            // versa. Drift in either direction is a refactor bug.
            use std::collections::BTreeSet;
            let from_consts: BTreeSet<&str> = ALL.iter().copied().collect();
            let from_registry: BTreeSet<&str> = crate::mcp::registry::registered_tools()
                .iter()
                .map(|r| r.name)
                .collect();
            let only_in_consts: Vec<&&str> = from_consts.difference(&from_registry).collect();
            let only_in_registry: Vec<&&str> = from_registry.difference(&from_consts).collect();
            assert!(
                only_in_consts.is_empty() && only_in_registry.is_empty(),
                "tool_names::ALL and registered_tools() drifted; \
                 only_in_consts = {only_in_consts:?}, \
                 only_in_registry = {only_in_registry:?}"
            );
        }
    }
}

// --- McpTool trait (v0.7.0 #972 D1.1, issue #982) ---

/// Per-tool descriptor surface introduced by v0.7.0 #972 split D1.1.
///
/// The pre-D1 registry was a single 1500-line `json!({...})` macro in
/// [`tool_definitions`] that hand-coded every tool's `inputSchema`
/// alongside its `name` / `description` / `docs`. That layout drifted
/// from handler reality (e.g. `memory_capabilities` schema still says
/// `accept: ["v1","v2"]` while the [`crate::mcp::tools::capabilities::CapabilitiesAccept`]
/// enum has been `V1`/`V2`/`V3` since A5) because nothing forced the
/// schema and the handler to be authored from the same source.
///
/// The D1 split moves each tool to its own module under
/// [`crate::mcp::tools`]. Each module exports a zero-sized type that
/// implements [`McpTool`]. The trait's [`McpTool::input_schema`]
/// returns a `serde_json::Value` derived from a per-tool
/// `#[derive(schemars::JsonSchema, serde::Deserialize)]` request
/// struct — so a new field on the request struct lands automatically
/// in the wire schema, and a typo in field name fails to deserialise
/// at the handler boundary.
///
/// D1.1 (this issue, #982) defines the trait + a PoC implementation
/// for `memory_capabilities`. D1.2 (#983) wires the schemars derive
/// pipeline. D1.3 (#984) migrates the 5 default `--profile core`
/// tools. D1.4 (#985) + D1.5 (#986) migrate the remaining ~65 tools
/// in parallel. D1.6 (#987) deletes the giant `tool_definitions()`
/// macro and replaces its body with iteration over
/// `registered_tools()`. D1.7 (#988) lands per-profile snapshot tests
/// + the compile-time schema↔handler parity invariant. D1.8 (#989)
/// updates the docs.
///
/// During D1.1-D1.5 both surfaces coexist: the legacy `tool_definitions`
/// macro still emits the full catalog on the wire, and per-tool
/// `McpTool` impls coexist as a parallel source-of-truth. Snapshot
/// tests verify the schemars-derived schema matches the legacy
/// hand-coded one modulo property ordering (schemars sorts).
///
/// The `dead_code` allow comes off in D1.6 (#987) when the giant
/// `tool_definitions` macro is replaced with iteration over
/// `McpTool` impls. During the D1.1-D1.5 window the trait is
/// authored ahead of its first consumer (the per-profile
/// `registered_tools()` iterator that D1.6 introduces).
#[allow(dead_code)]
pub trait McpTool {
    /// Wire-level tool name (e.g. `"memory_capabilities"`).
    fn name() -> &'static str;

    /// Short one-sentence description (≤ 50 cl100k tokens) shown on
    /// the bare `tools/list` payload.
    fn description() -> &'static str;

    /// Long-form prose + examples; reachable via
    /// `memory_capabilities { family=<f>, include_schema=true, verbose=true }`.
    /// May be empty for tools that don't ship long-form docs.
    fn docs() -> &'static str;

    /// JSON Schema for the tool's request body. Derived from the
    /// per-tool `<Tool>Request` struct via
    /// `schemars::schema_for!(<Tool>Request)` and converted to
    /// `serde_json::Value`.
    fn input_schema() -> Value;

    /// Family tag (one of `core` / `lifecycle` / `graph` /
    /// `governance` / `power` / `meta` / `archive` / `other`) used by
    /// [`Profile::loads`] for per-profile filtering on `tools/list`.
    /// Impls return `crate::profile::Family::<Variant>.name()` so the
    /// taxonomy has exactly one spelling site (#1558 batch 4).
    fn family() -> &'static str;
}

/// Derive a tool's `input_schema` JSON value from its request struct —
/// the one place the schemars→`Value` conversion (and its expect
/// message) lives, instead of per-impl copies at every `McpTool`
/// (#1558 batch 4; pre-sweep the expect string was duplicated at 74
/// sites).
///
/// # Panics
///
/// Panics if the derived schema fails to serialize to a
/// `serde_json::Value` — impossible for schemars-generated schemas;
/// a panic here means a schemars regression, caught at `tools/list`
/// time in every test run.
/// The one spelling of the schema-serialization expect message —
/// pre-#1558 this string was duplicated at 74 `input_schema()` sites.
const SCHEMA_TO_VALUE_EXPECT: &str = "schemars schema must serialize to Value";

#[must_use]
pub fn input_schema_for<T: schemars::JsonSchema>() -> Value {
    serde_json::to_value(schemars::schema_for!(T)).expect(SCHEMA_TO_VALUE_EXPECT)
}

// --- v0.7.0 #972 D1.6 (#987) — registered_tools() iterator ---

/// v0.7.0 #972 D1.6 (#987) — owned snapshot of one tool's catalog
/// row, derived from its per-tool [`McpTool`] impl. Together with
/// [`registered_tools`] it replaces the hand-coded `json!({...})`
/// body of [`tool_definitions`] (D1.6 collapses the macro).
///
/// The row carries the tool's `name`, `description`, `docs`, family
/// tag, and the schemars-derived `inputSchema`. [`RegisteredTool::of`]
/// constructs the row from any `T: McpTool` so the dispatch table is
/// authored in one place: `registered_tools()`.
pub struct RegisteredTool {
    pub name: &'static str,
    pub description: &'static str,
    pub docs: &'static str,
    /// Family tag retained on the struct for per-profile filtering
    /// (D1.7 (#988) will consume this in the per-profile snapshot
    /// tests). [`RegisteredTool::to_value`] does NOT emit it — the
    /// wire shape excludes the family tag to keep the post-D1.6
    /// catalog byte-identical to the pre-D1.6 shape modulo the
    /// allowed-diffs catalog. So it reads as dead code at compile
    /// time until D1.7 lands; the allow stays narrow and load-bearing.
    #[allow(dead_code)]
    pub family: &'static str,
    pub input_schema: Value,
}

impl RegisteredTool {
    /// Derive a catalog row from any type that implements [`McpTool`].
    /// All five `McpTool` methods are pure / cheap; the schemars-derived
    /// `input_schema` is recomputed each call (no caching) because the
    /// per-request budget is dominated by the JSON serialisation below,
    /// not by schemars reflection.
    #[must_use]
    pub fn of<T: McpTool>() -> Self {
        Self {
            name: T::name(),
            description: T::description(),
            docs: T::docs(),
            family: T::family(),
            input_schema: T::input_schema(),
        }
    }

    /// Render the row in the wire shape `tool_definitions` emits:
    /// `{ name, description, docs, inputSchema }`. The `family` tag is
    /// kept out of the wire form (it's a server-side filter only) so
    /// the post-D1.6 payload matches the pre-D1.6 payload byte-for-byte
    /// modulo the documented allowed-diffs (property order, schemars
    /// `default: null` on optional fields, schemars
    /// `additionalProperties: false`).
    ///
    /// Normalisation: schemars omits the `properties` map entirely
    /// when the request struct has zero fields (e.g. `StatsRequest`,
    /// `ArchiveStatsRequest`, `AgentListRequest`,
    /// `ListSubscriptionsRequest`). The pre-D1.6 hand-coded macro
    /// emitted `"properties": {}` for those tools so the wire shape
    /// stayed uniform across tools. Backfill the empty map here so
    /// the post-D1.6 wire shape preserves that uniformity.
    #[must_use]
    pub fn to_value(&self) -> Value {
        let mut input_schema = self.input_schema.clone();
        if let Some(obj) = input_schema.as_object_mut()
            && !obj.contains_key("properties")
        {
            obj.insert(
                "properties".to_string(),
                Value::Object(serde_json::Map::new()),
            );
        }
        json!({
            "name": self.name,
            "description": self.description,
            "docs": self.docs,
            "inputSchema": input_schema,
        })
    }
}

/// v0.7.0 #972 D1.6 (#987) — canonical iterator over every
/// `McpTool`-impl in the codebase. Each entry pairs the tool with a
/// closure that derives its catalog row via [`RegisteredTool::of`].
///
/// **One row per tool. Adding a tool = adding ONE line here + an impl
/// in the per-tool module.** That's the post-D1.6 contract — see the
/// "New MCP tool" recipe in `CLAUDE.md`.
///
/// Order matches the pre-D1.6 `tool_definitions()` macro order so
/// callers that iterate the wire array see the same sequence they
/// saw before the migration.
///
/// DOC-8 (med/low review batch) — counting discipline. A naive
/// `grep -c 'RegisteredTool::of' src/mcp/registry.rs` over-counts
/// because some tools (`offload::DerefTool`,
/// `offload::OffloadTool`, sibling re-exports) appear in multiple
/// register-call sites. The authoritative tool-count is the count
/// of **unique `<crate::mcp::*>` paths** under the `RegisteredTool::of`
/// invocations, pinned by
/// [`crate::profile::Profile::full().expected_tool_count()`]. The raw
/// `grep -c` count is structurally cosmetic; reach for the
/// `Profile::*().expected_tool_count()` assertion when counting.
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn registered_tools() -> Vec<RegisteredTool> {
    // ORDER MUST MATCH THE PRE-D1.6 `tool_definitions()` macro order
    // so callers iterating the wire array see the same sequence they
    // saw before the migration. Re-ordering is allowed at the wire
    // form (the JSON output) but pinning it here keeps the snapshot
    // regression test (Phase 4) trivial — any future reorder shows up
    // as a single diff hunk in this file, not as a full-profile
    // reshuffle in the wire snapshot.
    vec![
        RegisteredTool::of::<crate::mcp::store::StoreTool>(),
        RegisteredTool::of::<crate::mcp::recall::RecallTool>(),
        RegisteredTool::of::<crate::mcp::recall_observations::RecallObservationsTool>(),
        RegisteredTool::of::<crate::mcp::search::SearchTool>(),
        RegisteredTool::of::<crate::mcp::list::ListTool>(),
        RegisteredTool::of::<crate::mcp::load_family::LoadFamilyTool>(),
        RegisteredTool::of::<crate::mcp::load_family::SmartLoadTool>(),
        RegisteredTool::of::<crate::mcp::get_taxonomy::GetTaxonomyTool>(),
        RegisteredTool::of::<crate::mcp::check_duplicate::CheckDuplicateTool>(),
        RegisteredTool::of::<crate::mcp::entity_register::EntityRegisterTool>(),
        RegisteredTool::of::<crate::mcp::entity_get_by_alias::EntityGetByAliasTool>(),
        RegisteredTool::of::<crate::mcp::kg_timeline::KgTimelineTool>(),
        RegisteredTool::of::<crate::mcp::kg_invalidate::KgInvalidateTool>(),
        RegisteredTool::of::<crate::mcp::kg_query::KgQueryTool>(),
        RegisteredTool::of::<crate::mcp::find_paths::FindPathsTool>(),
        RegisteredTool::of::<crate::mcp::delete::DeleteTool>(),
        RegisteredTool::of::<crate::mcp::promote::PromoteTool>(),
        RegisteredTool::of::<crate::mcp::forget::ForgetTool>(),
        RegisteredTool::of::<crate::mcp::forget::StatsTool>(),
        RegisteredTool::of::<crate::mcp::update::UpdateTool>(),
        RegisteredTool::of::<crate::mcp::get::GetTool>(),
        RegisteredTool::of::<crate::mcp::link::LinkTool>(),
        RegisteredTool::of::<crate::mcp::link::GetLinksTool>(),
        RegisteredTool::of::<crate::mcp::verify::VerifyTool>(),
        RegisteredTool::of::<crate::mcp::replay::ReplayTool>(),
        RegisteredTool::of::<crate::mcp::reflect::ReflectTool>(),
        RegisteredTool::of::<crate::mcp::export_reflection::ExportReflectionTool>(),
        RegisteredTool::of::<crate::mcp::persona::PersonaTool>(),
        RegisteredTool::of::<crate::mcp::persona::PersonaGenerateTool>(),
        RegisteredTool::of::<crate::mcp::reflection_origin::ReflectionOriginTool>(),
        RegisteredTool::of::<crate::mcp::dependents_of_invalidated::DependentsOfInvalidatedTool>(),
        RegisteredTool::of::<crate::mcp::consolidate::ConsolidateTool>(),
        RegisteredTool::of::<crate::mcp::ingest_multistep::IngestMultistepTool>(),
        RegisteredTool::of::<crate::mcp::atomise::AtomiseTool>(),
        RegisteredTool::of::<crate::mcp::share::ShareTool>(),
        RegisteredTool::of::<crate::mcp::calibrate_confidence::CalibrateConfidenceTool>(),
        RegisteredTool::of::<crate::mcp::capabilities::CapabilitiesTool>(),
        // v0.7.0 #1389 L4 — host-volunteered turn capture per RFC-0001.
        RegisteredTool::of::<crate::mcp::capture_turn::MemoryCaptureTurnTool>(),
        RegisteredTool::of::<crate::mcp::expand_query::ExpandQueryTool>(),
        RegisteredTool::of::<crate::mcp::auto_tag::AutoTagTool>(),
        RegisteredTool::of::<crate::mcp::detect_contradiction::DetectContradictionTool>(),
        RegisteredTool::of::<crate::mcp::archive::ArchiveListTool>(),
        RegisteredTool::of::<crate::mcp::archive::ArchiveRestoreTool>(),
        RegisteredTool::of::<crate::mcp::archive::ArchivePurgeTool>(),
        RegisteredTool::of::<crate::mcp::archive::ArchiveStatsTool>(),
        RegisteredTool::of::<crate::mcp::archive::GcTool>(),
        RegisteredTool::of::<crate::mcp::session_start::SessionStartTool>(),
        RegisteredTool::of::<crate::mcp::namespace::NamespaceSetStandardTool>(),
        RegisteredTool::of::<crate::mcp::namespace::NamespaceGetStandardTool>(),
        RegisteredTool::of::<crate::mcp::namespace::NamespaceClearStandardTool>(),
        RegisteredTool::of::<crate::mcp::pending::PendingListTool>(),
        RegisteredTool::of::<crate::mcp::pending::PendingApproveTool>(),
        RegisteredTool::of::<crate::mcp::pending::PendingRejectTool>(),
        RegisteredTool::of::<crate::mcp::agent::AgentRegisterTool>(),
        RegisteredTool::of::<crate::mcp::agent::AgentListTool>(),
        RegisteredTool::of::<crate::mcp::notify::NotifyTool>(),
        RegisteredTool::of::<crate::mcp::notify::InboxTool>(),
        RegisteredTool::of::<crate::mcp::subscribe::SubscribeTool>(),
        RegisteredTool::of::<crate::mcp::subscribe::UnsubscribeTool>(),
        RegisteredTool::of::<crate::mcp::subscribe::ListSubscriptionsTool>(),
        RegisteredTool::of::<crate::mcp::subscribe::SubscriptionReplayTool>(),
        RegisteredTool::of::<crate::mcp::pending::SubscriptionDlqListTool>(),
        RegisteredTool::of::<crate::mcp::quota_status::QuotaStatusTool>(),
        RegisteredTool::of::<crate::mcp::check_agent_action::CheckAgentActionTool>(),
        RegisteredTool::of::<crate::mcp::rule_list::RuleListTool>(),
        RegisteredTool::of::<crate::mcp::skill_register::SkillRegisterTool>(),
        RegisteredTool::of::<crate::mcp::skill_list::SkillListTool>(),
        RegisteredTool::of::<crate::mcp::skill_get::SkillGetTool>(),
        RegisteredTool::of::<crate::mcp::skill_resource::SkillResourceTool>(),
        RegisteredTool::of::<crate::mcp::skill_export::SkillExportTool>(),
        RegisteredTool::of::<crate::mcp::skill_promote::SkillPromoteFromReflectionTool>(),
        RegisteredTool::of::<crate::mcp::skill_compositional_context::SkillCompositionalContextTool>(
        ),
        RegisteredTool::of::<crate::mcp::offload::OffloadTool>(),
        RegisteredTool::of::<crate::mcp::offload::DerefTool>(),
    ]
}

// --- Tool definitions ---

/// Version tag for the `tools/list` response schema. Bumped whenever
/// an existing tool's shape changes in a breaking way (renamed params,
/// tightened schemas, removed options). Adding a new tool is additive
/// and does NOT require a bump. Ultrareview #351.
///
/// v0.7 C4 — bumped to `2026-05-06` because `tools/list` now ships
/// the trimmed schema by default (optional params hidden unless the
/// caller passes `verbose=true` to `memory_capabilities`). The wire
/// shape of every existing tool's `inputSchema.properties` map is
/// strictly a subset of the prior version, which is a breaking change
/// for any client that was reading the long-tail optional params off
/// `tools/list` directly. The full schema is still reachable via
/// `memory_capabilities { family=<f>, include_schema=true, verbose=true }`.
const TOOLS_VERSION: &str = "2026-05-06";

/// v0.7 C4 — tools/list optional-param trim allow-list.
///
/// **Historical (pre-#859):** optional properties (those NOT in
/// `inputSchema.required`) were dropped from the default `tools/list`
/// payload UNLESS their name appeared here. This hid the long-tail
/// optionals (`max_depth`, `relation`, `confidence`, …) from MCP
/// clients reading the wire schema directly, breaking NHI runtime
/// discovery (issue #859).
///
/// **Current (#859 / v0.7.0 fix):** every property is preserved on
/// the wire; the allow-list is retained for narrative purposes (and
/// as a marker if a future tightening reintroduces a per-name gate)
/// but is no longer consulted by [`trim_optional_params`].
#[allow(dead_code)]
const C4_KEEP_OPTIONAL_PARAMS: &[&str] = &["namespace", "format"];

/// v0.7 C4 (rev #859) — wire-schema property pruner.
///
/// **What it does on the wire-form schema:**
/// - **Preserves** every `inputSchema.properties` entry, including
///   the long-tail optionals (`max_depth`, `relation`, `valid_at`,
///   `allowed_agents`, `limit`, `include_invalidated`, …). NHI
///   agents reading `tools/list` need to DISCOVER what knobs exist
///   to set them.
/// - **Preserves** every property's structural metadata: `type`,
///   `enum`, `minimum`, `maximum`, `default`, `items`, `minItems`,
///   `maxItems`, `oneOf`. These are load-bearing for argument
///   validation on the client side.
/// - **Preserves** the `required` array — clients still need to
///   know which params are mandatory.
/// - **Strips** per-property `description` text (the prose). The
///   long-form prose is reachable via `memory_capabilities {
///   family=<f>, include_schema=true, verbose=true }`. Callers
///   that just want to know "what params does this tool accept"
///   no longer pay for the prose on every `tools/list` request.
/// - **Strips** per-property `default` values that are non-trivial
///   strings (>32 chars). Numeric / boolean / short-string defaults
///   stay (they're tiny and load-bearing for client-side argument
///   construction).
///
/// Note: per-property `description` stripping is also performed by
/// [`strip_docs_from_tools`]; running both is idempotent. This
/// function is kept as a stable entry point so call sites that
/// historically invoked it (and the budget model in
/// [`crate::sizes`]) keep their semantics aligned with the wire.
///
/// **Why this changed (#859).** Pre-#859 the function dropped entire
/// optional property keys (everything not in `required` + the small
/// allow-list `[namespace, format]`), which produced
/// `memory_kg_query.inputSchema.properties = {source_id}` on the
/// wire — agents could not see that `max_depth`, `valid_at`,
/// `allowed_agents`, `limit`, `include_invalidated` were valid
/// params at all. The fix restores discovery by keeping every
/// property entry on the wire and trimming only the prose.
///
/// Returns the count of property entries whose `description` was
/// stripped — useful for telemetry / acceptance assertions in tests.
/// (Pre-#859 this counted dropped property entries; same shape,
/// different denominator.)
pub(crate) fn trim_optional_params(defs: &mut Value) -> usize {
    let Some(tools) = defs.get_mut("tools").and_then(Value::as_array_mut) else {
        return 0;
    };
    let mut stripped = 0_usize;
    for tool in tools.iter_mut() {
        let Some(input_schema) = tool.get_mut("inputSchema") else {
            continue;
        };
        let Some(properties) = input_schema
            .get_mut("properties")
            .and_then(Value::as_object_mut)
        else {
            continue;
        };
        for (_param_name, prop_value) in properties.iter_mut() {
            // Count `description` removals before the recursive
            // walker erases them, for telemetry.
            let had_desc = prop_value
                .as_object()
                .is_some_and(|o| o.contains_key("description"));
            strip_description_recursively(prop_value);
            if had_desc {
                stripped += 1;
            }
        }
    }
    stripped
}

/// v0.6.4-006 — Build the `families` overview included in the v2
/// `memory_capabilities` response. Each entry carries:
///
/// - `name` — family identifier (`core`, `graph`, …)
/// - `tool_count` — expected tool count per the family map
/// - `loaded` — whether the family is loaded under the active profile
/// - `tools` — the canonical tool-name list for that family
///
/// This is the v0.6.4 NHI runtime-discovery surface: an agent reading
/// the response sees which families are reachable AND can decide which
/// to opt into (via `memory_capabilities --include-schema family=<f>`)
/// without restarting the MCP server.
pub(crate) fn families_overview(profile: &crate::profile::Profile) -> Value {
    use crate::profile::Family;
    let defs = tool_definitions();
    let all_tools = defs
        .get("tools")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let entries: Vec<Value> = Family::all()
        .iter()
        .map(|fam| {
            let tools_in_family: Vec<&str> = all_tools
                .iter()
                .filter_map(|t| t.get("name").and_then(Value::as_str))
                .filter(|n| Family::for_tool(n) == Some(*fam))
                .collect();
            json!({
                "name": fam.name(),
                "tool_count": tools_in_family.len(),
                "loaded": profile.includes(*fam),
                "tools": tools_in_family,
            })
        })
        .collect();
    json!({
        "schema_version": "v0.6.4-families-1",
        "always_on": crate::profile::ALWAYS_ON_TOOLS,
        "families": entries,
    })
}

/// v0.6.4-006 — Handle `memory_capabilities` invocations that pass a
/// `family=<name>` parameter. When `include_schema=false` (default),
/// returns the canonical tool-name list. When `include_schema=true`,
/// returns the full MCP-style tool definitions for each tool — the
/// caller (an NHI agent or a host like Claude Code's deferred-tools
/// path) can register them at runtime without restarting the server.
///
/// v0.6.4-008 — when `include_schema=true` AND the daemon's
/// `[mcp.allowlist]` is configured, the requesting `agent_id` must be
/// permitted by the allowlist for the requested family. Permissive
/// (no-allowlist) default preserves Tier-1 single-process behavior —
/// operators opt into the gate by writing the table.
///
/// v0.7 C2 — `verbose` controls whether the per-tool `docs` field
/// (long-form description + examples) is preserved in the response.
/// When `verbose=false` (default), `docs` is stripped, matching the
/// always-on `tools/list` shape; when `verbose=true` AND
/// `include_schema=true`, callers receive the full documentation.
/// `verbose=true` without `include_schema=true` is a no-op (the
/// name-list response carries no `docs`).
///
/// v0.7 C4 — when `include_schema=true`, the returned tool schemas
/// are now trimmed by default (optional params hidden) to match the
/// `tools/list` shape. Pass `verbose=true` to opt into the full
/// schema — every optional param, every default, every per-property
/// description. The trim/keep allow-list lives in
/// [`C4_KEEP_OPTIONAL_PARAMS`]. C2's `docs`-field strip and C4's
/// `inputSchema.properties` trim are orthogonal and both governed by
/// the same `verbose` flag.
///
/// Errors:
/// - Unknown family → `Err` with diagnostic listing valid families.
/// - Empty family name → `Err`.
/// - Allowlist deny → `Err` with structured reason.
pub fn handle_capabilities_family(
    family_name: &str,
    include_schema: bool,
    verbose: bool,
    profile: &crate::profile::Profile,
    allowlist_cfg: Option<&crate::config::McpConfig>,
    agent_id: Option<&str>,
    audit_conn: Option<&rusqlite::Connection>,
) -> Result<Value, String> {
    use crate::profile::Family;
    if family_name.is_empty() {
        return Err("memory_capabilities: 'family' must not be empty".to_string());
    }
    let family = Family::all()
        .iter()
        .find(|f| f.name() == family_name)
        .copied()
        .ok_or_else(|| {
            let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
            format!(
                "unknown family '{family_name}'. Valid families: {}.",
                valid.join(", ")
            )
        })?;

    // v0.6.4-008 — allowlist gate, only on the runtime-expansion path.
    if include_schema && let Some(mcp_cfg) = allowlist_cfg {
        use crate::config::AllowlistDecision;
        match mcp_cfg.allowlist_decision(agent_id, family.name()) {
            AllowlistDecision::Disabled | AllowlistDecision::Allow => {}
            AllowlistDecision::Deny => {
                // v0.6.4-009 — record the deny so operators can see
                // attempted-but-blocked expansion patterns.
                if let Some(conn) = audit_conn {
                    crate::db::record_capability_expansion(
                        conn,
                        agent_id,
                        family.name(),
                        false,
                        None,
                    );
                }
                return Err(format!(
                    "agent '{}' is not permitted to expand family '{}' under \
                     [mcp.allowlist]. Ask an operator to add a matching rule \
                     to config.toml or pass an allowed agent_id.",
                    agent_id.unwrap_or("<anonymous>"),
                    family.name()
                ));
            }
        }
    }

    // v0.6.4-009 — record the grant on the include_schema=true path.
    // Lightweight name-list calls are not audited (they're informational
    // only — no schema material released).
    if include_schema && let Some(conn) = audit_conn {
        crate::db::record_capability_expansion(conn, agent_id, family.name(), true, None);
    }

    let mut defs = tool_definitions();
    // v0.7 C4 — apply the optional-param trim BEFORE filtering by
    // family when the caller did not opt into verbose. Trimming is a
    // cheap pass over every tool's `inputSchema.properties` map, so
    // running it pre-filter is fine and keeps the call site simple.
    if !verbose {
        trim_optional_params(&mut defs);
    }
    let all_tools = defs
        .get("tools")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let mut in_family: Vec<Value> = all_tools
        .into_iter()
        .filter(|t| {
            t.get("name")
                .and_then(Value::as_str)
                .and_then(Family::for_tool)
                == Some(family)
        })
        .collect();

    // v0.7 C2 — strip the verbose `docs` field unless the caller
    // explicitly opted into the long-form payload via `verbose=true`.
    // This keeps the family drilldown response consistent with the
    // bare `tools/list` shape by default.
    if !verbose {
        strip_docs_from_tools(&mut in_family);
    }

    if include_schema {
        Ok(json!({
            "schema_version": "v0.6.4-family-schemas-1",
            "family": family.name(),
            "loaded_under_active_profile": profile.includes(family),
            "verbose": verbose,
            "tools": in_family,
        }))
    } else {
        let names: Vec<&str> = in_family
            .iter()
            .filter_map(|t| t.get("name").and_then(Value::as_str))
            .collect();
        Ok(json!({
            "schema_version": "v0.6.4-family-list-1",
            "family": family.name(),
            "loaded_under_active_profile": profile.includes(family),
            "tools": names,
        }))
    }
}

/// v0.6.4-002 — Filter `tool_definitions()` down to the tools loaded
/// under `profile`. Tools whose family is not in the profile's family
/// list are dropped from `tools[]`. `memory_capabilities` and any
/// other [`crate::profile::ALWAYS_ON_TOOLS`] are kept regardless of
/// profile so the runtime-discovery dance still works on
/// `--profile core`.
///
/// v0.7 C2 — the verbose `docs` field (long-form description + examples)
/// is stripped from each entry so the always-on `tools/list` payload
/// stays inside the C5 token budget. Callers that want the full docs
/// invoke `memory_capabilities { family=<f>, verbose: true }`, which
/// uses `tool_definitions()` directly without stripping.
///
/// v0.7 C4 — on top of the C2 docs strip, optional
/// `inputSchema.properties` are also stripped from each tool by
/// default (see [`trim_optional_params`]) so the `tools/list` payload
/// fits the v0.7 token budget. Callers that need the full schema
/// SHAPE (every optional property, every default) should call
/// [`tool_definitions_for_profile_verbose`] — noting it still strips
/// the C2 prose (`docs` + per-property `description`s; #1648) — or,
/// on the wire, pass `verbose=true` to `memory_capabilities`, whose
/// family drilldown skips both trims and so carries the prose too.
/// The C2 (description/docs) trim and the C4 (optional-params) trim
/// are orthogonal — both run on this default path; only the C4 trim
/// is skipped by [`tool_definitions_for_profile_verbose`] (its C2
/// strip runs via [`strip_docs_from_tools`]).
pub fn tool_definitions_for_profile(profile: &crate::profile::Profile) -> Value {
    // v0.7.0 #1077 — memoize per-Profile so repeat `tools/list` calls
    // are a single `Value::clone()` of the cached payload instead of
    // re-running the schemars + trim + strip + compact pipeline. The
    // catalog is build-time-fixed (schemars-derived), so no
    // invalidation is needed across the daemon lifetime.
    static CACHE: std::sync::OnceLock<
        std::sync::RwLock<std::collections::HashMap<crate::profile::Profile, Value>>,
    > = std::sync::OnceLock::new();
    let cache = CACHE.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new()));
    if let Ok(read) = cache.read()
        && let Some(cached) = read.get(profile)
    {
        return cached.clone();
    }
    let defs = build_tool_definitions_for_profile(profile);
    if let Ok(mut write) = cache.write() {
        write.entry(profile.clone()).or_insert_with(|| defs.clone());
    }
    defs
}

/// v0.7.0 #1077 — cache-miss path. Pulled out of
/// [`tool_definitions_for_profile`] so the hot path is a pure
/// hashmap-read clone.
fn build_tool_definitions_for_profile(profile: &crate::profile::Profile) -> Value {
    let mut defs = tool_definitions_for_profile_verbose(profile);
    if !tools_verbose_env_enabled() {
        trim_optional_params(&mut defs);
        wire_compact_descriptions(&mut defs);
    }
    defs
}

/// #859 helper — wire-form description compaction. After
/// [`trim_optional_params`] preserves every property entry on the
/// wire (so MCP clients can DISCOVER what knobs exist), the wire
/// payload still has to fit the C5 token budget. Two strategies are
/// applied, in order:
///
/// 1. **Truncate** the top-level tool `description` to the first
///    sentence (anything before `.` / `;` / first 28 characters,
///    whichever is shorter). The verbose drilldown
///    (`memory_capabilities { verbose=true }`) still carries the
///    full short-form description; the wire form is now even
///    shorter so the budget gate at 11000 cl100k tokens (post-D1.6 schemars expansion, was 3500 in pre-D1.6 hand-coded macro) holds.
/// 2. **Strip** numeric / boolean schema defaults that match the
///    JSON-Schema validation no-op (e.g. `"default": 0` on an
///    `integer` with `minimum: 0`). Currently no-op; left as a
///    future-proofing seam so a future tightening doesn't require
///    a fresh trimmer entry point.
fn wire_compact_descriptions(defs: &mut Value) {
    let Some(tools) = defs.get_mut("tools").and_then(Value::as_array_mut) else {
        return;
    };
    for tool in tools.iter_mut() {
        let Some(obj) = tool.as_object_mut() else {
            continue;
        };
        let Some(desc) = obj.get("description").and_then(Value::as_str) else {
            continue;
        };
        let compact = compact_description(desc);
        if compact.len() != desc.len() {
            obj.insert("description".to_string(), Value::String(compact));
        }
    }
}

/// Truncate a tool's short-form description to the first sentence
/// (or the first 32 characters at a word boundary), preserving at
/// least the verb-noun gist so display surfaces have a label.
///
/// Strategy:
/// 1. If the full description is ≤ 32 chars, keep it verbatim (cheap
///    enough to ship intact).
/// 2. If there's a sentence terminator (`.` / `;`) at or before the
///    32-char mark, cut just before it — that's the cleanest break.
/// 3. Otherwise cut at the last whitespace before 32 chars so we
///    never split a word in half. If no whitespace exists in the
///    first 32 chars, fall back to a char-boundary-safe truncation.
fn compact_description(s: &str) -> String {
    const MAX: usize = 32;
    if s.len() <= MAX {
        return s.to_string();
    }
    // Sentence-terminator path — preserves natural prose boundary.
    let slice = &s[..MAX.min(s.len())];
    if let Some(idx) = slice.find(['.', ';']) {
        return s[..idx].to_string();
    }
    // Word-boundary path — never split a word.
    if let Some(idx) = slice.rfind(char::is_whitespace) {
        return s[..idx].to_string();
    }
    // No whitespace in budget — char-boundary-safe truncation.
    let mut end = MAX.min(s.len());
    while !s.is_char_boundary(end) && end > 0 {
        end -= 1;
    }
    s[..end].to_string()
}

/// Round-4 — process-level escape hatch from the C4 trim used by
/// [`tool_definitions_for_profile`]. Reads `AI_MEMORY_TOOLS_VERBOSE`
/// once and accepts `1` or `true` (case-insensitive) as the truthy
/// values; anything else (including absent) is false. Cached behind a
/// `OnceLock` so the hot tools/list path doesn't re-stat the env on
/// every call.
fn tools_verbose_env_enabled() -> bool {
    static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
    *CACHED.get_or_init(|| {
        std::env::var("AI_MEMORY_TOOLS_VERBOSE")
            .ok()
            .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
    })
}

/// v0.7 C4 — full-SCHEMA-SHAPE (verbose) variant of
/// [`tool_definitions_for_profile`]. Returns every optional param and
/// every default (`type`, `enum`, `default`, `minimum`, `maximum`,
/// `required`, `items`) — but NOT the natural-language prose: the
/// body calls [`strip_docs_from_tools`], so the top-level `docs`
/// field AND every per-property `description` string under
/// `inputSchema.properties.*` are stripped here too (#1648 doc
/// truth-fix; the pre-#1648 claim of "every per-property description"
/// was false). Used by the `memory_capabilities { verbose=true }`
/// opt-in path so power users / NHI agents can still set the
/// long-tail knobs (`confidence`, `priority`, `tier`, `metadata`,
/// `agent_id`, …) without restarting the MCP server with a different
/// profile.
///
/// v0.7 C2 — the verbose flag controls only whether
/// `inputSchema.properties` is trimmed (C4); the C2 strip
/// ([`strip_docs_from_tools`]: top-level `docs` + nested per-property
/// `description`s) runs on the verbose path as well. To recover the
/// full prose surface, call [`tool_definitions`] directly.
pub fn tool_definitions_for_profile_verbose(profile: &crate::profile::Profile) -> Value {
    let mut defs = tool_definitions();
    if let Some(arr) = defs.get_mut("tools").and_then(|t| t.as_array_mut()) {
        arr.retain(|tool| {
            tool.get("name")
                .and_then(Value::as_str)
                .is_some_and(|name| profile.loads(name))
        });
        strip_docs_from_tools(arr);
    }
    defs
}

/// v0.7 C2 — strip every long-form natural-language string from a
/// `tools[]` array so the bare `tools/list` payload stays inside the
/// C5 token budget (≤ 11000 cl100k tokens for the full profile (post-D1.6)).
///
/// Removed:
/// - The top-level `docs` field (the long-form prose mirror of
///   `description`).
/// - Every `description` string nested under
///   `inputSchema.properties.*` — agents that need parameter prose
///   should re-fetch with `memory_capabilities { family=<f>,
///   include_schema: true, verbose: true }`, which calls
///   [`tool_definitions`] directly without stripping.
///
/// Preserved on the bare path:
/// - The top-level short `description` (≤ 50 cl100k tokens).
/// - The full `inputSchema` shape (`type`, `enum`, `default`,
///   `minimum`, `maximum`, `required`, `items`) so callers can still
///   construct valid argument objects without a verbose drilldown.
pub(crate) fn strip_docs_from_tools(tools: &mut Vec<Value>) {
    for tool in tools.iter_mut() {
        let Some(obj) = tool.as_object_mut() else {
            continue;
        };
        obj.remove("docs");
        if let Some(input_schema) = obj.get_mut("inputSchema").and_then(Value::as_object_mut) {
            // **v0.7.0 #987/#988 update.** D1.6 schemars-derived schemas
            // emit additional metadata the legacy hand-coded macro didn't:
            // - top-level `description` on the request struct itself
            // - `$schema` reference
            // - `title` field
            // - `definitions` map for `$ref`-resolved untagged enums
            //   (e.g. `RecallKindsFilter::Many(Vec<String>) | One(String)`)
            //
            // The wire form is a discovery surface for NHI agents; the
            // verbose drilldown (`memory_capabilities { verbose=true }`)
            // is the prose surface. Strip the schemars-only metadata
            // from the wire to keep the post-D1.6 trimmed-payload
            // ceiling honest.
            input_schema.remove("description");
            input_schema.remove("$schema");
            input_schema.remove("title");
            if let Some(defs) = input_schema
                .get_mut("definitions")
                .and_then(Value::as_object_mut)
            {
                for (_name, def_value) in defs.iter_mut() {
                    strip_description_recursively(def_value);
                }
            }
            if let Some(props) = input_schema
                .get_mut("properties")
                .and_then(Value::as_object_mut)
            {
                for (_param_name, prop_value) in props.iter_mut() {
                    strip_description_recursively(prop_value);
                }
            }
        }
    }
}

/// #859 helper — walk a property value and drop every `description`
/// key encountered, including inside nested `properties` maps and
/// `oneOf` / `anyOf` / `allOf` branch arrays. Idempotent.
///
/// v0.7.0 #1058 (Agent-4 F4) — also drops every `default: null` key.
/// Every `Option<T>` field on a schemars-derived request struct emits
/// `default: null` on the wire (≈170 entries in `tools_list_full.json`,
/// cumulative ~700-1000 cl100k tokens of pure noise — the pre-D1.6
/// hand-coded `tool_definitions` macro never emitted these). Stripping
/// keeps wire payloads honest about which defaults are load-bearing
/// (numeric / boolean / short-enum defaults that callers need to
/// construct valid arguments) while dropping the schemars-only
/// `null` noise.
fn strip_description_recursively(value: &mut Value) {
    match value {
        Value::Object(map) => {
            map.remove("description");
            // v0.7.0 #1058 — drop `default: null` (schemars-only noise).
            if let Some(Value::Null) = map.get("default") {
                map.remove("default");
            }
            // Drop long string defaults (>32 chars of prose) — short
            // numeric / boolean / enum defaults are load-bearing for
            // client-side argument construction so stay.
            if let Some(default) = map.get("default")
                && default.as_str().is_some_and(|s| s.len() > 32)
            {
                map.remove("default");
            }
            for (_, child) in map.iter_mut() {
                strip_description_recursively(child);
            }
        }
        Value::Array(items) => {
            for item in items.iter_mut() {
                strip_description_recursively(item);
            }
        }
        _ => {}
    }
}

/// v0.7 C2 — canonical tool catalog. Each tool entry carries a short
/// one-sentence `description` (≤ 50 cl100k_base tokens) and a
/// long-form `docs` field with the full prose + examples. The
/// always-on `tools/list` payload strips `docs` via
/// [`tool_definitions_for_profile`]; callers wanting the verbose form
/// invoke `memory_capabilities { family=<f>, verbose: true }` which
/// preserves `docs` so an NHI can drill in without reloading the
/// full-fat catalog into context.
pub fn tool_definitions() -> Value {
    // v0.7.0 #1077 — cache the deterministic catalog Value in a
    // `OnceLock<Value>`. `registered_tools()` is build-time static,
    // so the full catalog is invariant across the daemon lifetime.
    // Pre-#1077 every MCP `tools/list` request paid the full
    // schemars expansion across every registered tool; post-#1077 every call
    // after boot is a single `Value::clone()` of the cached payload.
    static CACHE: std::sync::OnceLock<Value> = std::sync::OnceLock::new();
    CACHE
        .get_or_init(|| {
            let tools: Vec<Value> = registered_tools()
                .iter()
                .map(RegisteredTool::to_value)
                .collect();
            json!({
                "toolsVersion": TOOLS_VERSION,
                "tools": tools,
            })
        })
        .clone()
}

#[cfg(test)]
mod d1_6_987_tests {
    //! D1.6 (#987) — registry-level regression tests for the
    //! post-collapse [`tool_definitions`] wire shape.
    //!
    //! The snapshot at `tests/snapshots/tool_definitions_pre_d1_6.json`
    //! is the pre-D1.6 catalog (captured before the macro collapse
    //! landed). These tests pin the post-D1.6 catalog against it,
    //! tolerating only the documented allowed-diffs:
    //!
    //! - Property order (schemars sorts; legacy was insertion-ordered)
    //! - `default: null` on schemars-derived Option<T> fields vs.
    //!   typed defaults on legacy
    //! - `additionalProperties: false` added by schemars (tightening)
    //! - `minimum`/`maximum` range-constraint loss on Option<T>
    //!   fields lacking `#[schemars(range)]` attributes
    //!
    //! Disallowed (tests fail):
    //! - Tools added or removed
    //! - Property names changed or missing
    //! - Description text changed (must be byte-equal)
    //! - `required` array changed
    //! - Type widening or narrowing beyond Option<T>::None nullable
    use super::*;
    use std::collections::BTreeSet;

    fn load_snapshot() -> Value {
        let path = "tests/snapshots/tool_definitions_pre_d1_6.json";
        let raw = std::fs::read_to_string(path).unwrap_or_else(|e| {
            panic!(
                "missing pre-D1.6 snapshot at {path}: {e}; \
                 regenerate via the one-shot capture documented in #987 dispatch"
            )
        });
        serde_json::from_str(&raw).expect("snapshot must be valid JSON")
    }

    /// Pre-D1.6 vs. post-D1.6: same tool COUNT.
    #[test]
    fn tool_definitions_byte_shape_v0_7_0_compat_987_count() {
        let pre = load_snapshot();
        let post = tool_definitions();
        let pre_count = pre["tools"].as_array().map_or(0, Vec::len);
        let post_count = post["tools"].as_array().map_or(0, Vec::len);
        assert_eq!(
            pre_count, post_count,
            "post-D1.6 tool count must match pre-D1.6 snapshot ({pre_count}); \
             a tool was added or removed."
        );
    }

    /// Pre-D1.6 vs. post-D1.6: same tool NAMES (set equality).
    #[test]
    fn tool_definitions_byte_shape_v0_7_0_compat_987_names() {
        let pre = load_snapshot();
        let post = tool_definitions();
        let names = |v: &Value| -> BTreeSet<String> {
            v["tools"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter_map(|t| t.get("name").and_then(Value::as_str).map(String::from))
                        .collect()
                })
                .unwrap_or_default()
        };
        let pre_names = names(&pre);
        let post_names = names(&post);
        let added: Vec<&String> = post_names.difference(&pre_names).collect();
        let removed: Vec<&String> = pre_names.difference(&post_names).collect();
        assert!(
            added.is_empty() && removed.is_empty(),
            "post-D1.6 tool name set drifted: added = {added:?}, removed = {removed:?}"
        );
    }

    /// Pre-D1.6 vs. post-D1.6: per-tool short DESCRIPTION strings
    /// byte-for-byte equal. The D1.2 (#983) parity contract pinned
    /// this — D1.6 must preserve it.
    #[test]
    fn tool_definitions_byte_shape_v0_7_0_compat_987_descriptions() {
        let pre = load_snapshot();
        let post = tool_definitions();
        let by_name = |v: &Value| -> std::collections::BTreeMap<String, String> {
            v["tools"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter_map(|t| {
                            let name = t.get("name").and_then(Value::as_str)?.to_string();
                            let desc = t.get("description").and_then(Value::as_str)?.to_string();
                            Some((name, desc))
                        })
                        .collect()
                })
                .unwrap_or_default()
        };
        let pre_map = by_name(&pre);
        let post_map = by_name(&post);
        for (name, want) in &pre_map {
            let got = post_map
                .get(name)
                .unwrap_or_else(|| panic!("post-D1.6 missing tool {name}"));
            assert_eq!(
                got, want,
                "post-D1.6 {name}.description drifted from snapshot\n  legacy: {want:?}\n  post-D1.6: {got:?}"
            );
        }
    }

    /// Pre-D1.6 vs. post-D1.6: per-tool long-form DOCS strings
    /// byte-for-byte equal. Same contract as descriptions.
    #[test]
    fn tool_definitions_byte_shape_v0_7_0_compat_987_docs() {
        let pre = load_snapshot();
        let post = tool_definitions();
        let by_name = |v: &Value| -> std::collections::BTreeMap<String, String> {
            v["tools"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter_map(|t| {
                            let name = t.get("name").and_then(Value::as_str)?.to_string();
                            let docs = t.get("docs").and_then(Value::as_str)?.to_string();
                            Some((name, docs))
                        })
                        .collect()
                })
                .unwrap_or_default()
        };
        let pre_map = by_name(&pre);
        let post_map = by_name(&post);
        for (name, want) in &pre_map {
            let got = post_map
                .get(name)
                .unwrap_or_else(|| panic!("post-D1.6 missing tool {name}"));
            assert_eq!(
                got, want,
                "post-D1.6 {name}.docs drifted from snapshot\n  legacy: {want:?}\n  post-D1.6: {got:?}"
            );
        }
    }

    /// Pre-D1.6 vs. post-D1.6: per-tool `inputSchema.properties`
    /// KEY SET (property names). Order is allowed to differ
    /// (schemars sorts); membership must match exactly.
    #[test]
    fn tool_definitions_byte_shape_v0_7_0_compat_987_property_keys() {
        let pre = load_snapshot();
        let post = tool_definitions();

        let props_by_name = |v: &Value| -> std::collections::BTreeMap<String, BTreeSet<String>> {
            v["tools"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter_map(|t| {
                            let name = t.get("name").and_then(Value::as_str)?.to_string();
                            let keys: BTreeSet<String> = t
                                .pointer("/inputSchema/properties")
                                .and_then(Value::as_object)
                                .map(|m| m.keys().cloned().collect())
                                .unwrap_or_default();
                            Some((name, keys))
                        })
                        .collect()
                })
                .unwrap_or_default()
        };
        let pre_props = props_by_name(&pre);
        let post_props = props_by_name(&post);
        for (name, want) in &pre_props {
            let got = post_props
                .get(name)
                .unwrap_or_else(|| panic!("post-D1.6 missing tool {name}"));
            let added: Vec<&String> = got.difference(want).collect();
            let removed: Vec<&String> = want.difference(got).collect();
            assert!(
                added.is_empty() && removed.is_empty(),
                "post-D1.6 {name}.inputSchema.properties drifted: added = {added:?}, removed = {removed:?}"
            );
        }
    }

    /// Pre-D1.6 vs. post-D1.6: per-tool `required` array. Order
    /// allowed to differ; set membership must match.
    #[test]
    fn tool_definitions_byte_shape_v0_7_0_compat_987_required() {
        let pre = load_snapshot();
        let post = tool_definitions();

        let req_by_name = |v: &Value| -> std::collections::BTreeMap<String, BTreeSet<String>> {
            v["tools"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter_map(|t| {
                            let name = t.get("name").and_then(Value::as_str)?.to_string();
                            let req: BTreeSet<String> = t
                                .pointer("/inputSchema/required")
                                .and_then(Value::as_array)
                                .map(|a| {
                                    a.iter()
                                        .filter_map(|v| v.as_str().map(String::from))
                                        .collect()
                                })
                                .unwrap_or_default();
                            Some((name, req))
                        })
                        .collect()
                })
                .unwrap_or_default()
        };
        let pre_req = req_by_name(&pre);
        let post_req = req_by_name(&post);
        for (name, want) in &pre_req {
            let got = post_req
                .get(name)
                .unwrap_or_else(|| panic!("post-D1.6 missing tool {name}"));
            assert_eq!(
                got, want,
                "post-D1.6 {name}.inputSchema.required drifted; \
                 the D1.6 allowed-diffs do NOT permit required-set changes"
            );
        }
    }
}