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
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! MCP `memory_capabilities` handlers, CapabilitiesAccept, and capability-summary helpers.

use crate::config::{RerankerMode, ResolvedModels, TierConfig};
use crate::db;
use crate::mcp::param_names;
use crate::mcp::registry::McpTool;
use crate::reranker::BatchedReranker;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;

// --- D1.1 (#982) PoC: per-tool descriptor for `memory_capabilities` ---

/// v0.7.0 #972 D1.1 (#982) — per-tool request body for
/// `memory_capabilities`. Source of truth for the wire schema; the
/// schemars-derived shape replaces the hand-coded entry in
/// [`crate::mcp::registry::tool_definitions`].
///
/// **Fix as a side effect of D1.1:** the legacy hand-coded schema
/// reported `accept: enum ["v1","v2"]` (default `"v2"`), but
/// [`CapabilitiesAccept`] has been `V1`/`V2`/`V3` since the v0.7.0 A5
/// release (with `V3` as the actual default). The schemars derive
/// from this struct will surface `accept` as an optional string
/// (no enum constraint at this layer — the runtime
/// [`CapabilitiesAccept::parse`] tolerates any input and falls back
/// to V3). That removes the schema/runtime drift without forcing
/// breaking-change semantics on existing v1/v2-pinned clients.
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)] // D1.1 PoC: struct is the schemars source; handler still parses Value directly until D1.3.
pub struct CapabilitiesRequest {
    /// Schema version. v3 default (A5); v2/v1 legacy.
    #[serde(default)]
    pub accept: Option<String>,

    // The accepted value set (`core` / `lifecycle` / `graph` /
    // `governance` / `power` / `meta` / `archive` / `other`) lives in
    // the long-form `docs` field on `CapabilitiesTool` so the wire
    // `description` here stays byte-identical to the legacy hand-coded
    // entry for D1.2 (#983) parity. Schemars 0.8 derives `description`
    // from the WHOLE doc comment (concatenated with `\n\n`), so any
    // prose beyond the first sentence would break the parity test.
    /// Drill into one family.
    #[serde(default)]
    pub family: Option<String>,

    /// Return full tool schemas. Requires family.
    #[serde(default)]
    pub include_schema: Option<bool>,

    /// C2/C4: preserve docs + every optional inputSchema property.
    #[serde(default)]
    pub verbose: Option<bool>,
}

/// v0.7.0 #972 D1.1 (#982) — zero-sized type implementing [`McpTool`]
/// for `memory_capabilities`. The trait impl returns the
/// schemars-derived input_schema; downstream D1.6 (#987) will collapse
/// the giant `tool_definitions` macro to iterate over `McpTool` impls
/// like this one. The `dead_code` allow comes off in D1.6 when the
/// type is registered into `registered_tools()`.
#[allow(dead_code)]
pub struct CapabilitiesTool;

impl McpTool for CapabilitiesTool {
    fn name() -> &'static str {
        crate::mcp::registry::tool_names::MEMORY_CAPABILITIES
    }

    fn description() -> &'static str {
        "Discover runtime capabilities; family=<name> drills in."
    }

    fn docs() -> &'static str {
        "Caps-v3: tier, profile, summary, callable_now, agent_permitted_families, harness detection. \
         family+include_schema drills one family. verbose=true restores full schema. \
         NOTE per #864: `family` here = MCP tool-family (8 groups: \
         core/lifecycle/graph/governance/power/meta/archive/other), NOT memory_kind taxonomy."
    }

    fn input_schema() -> Value {
        // Use schemars 0.8's `schema_for!` to derive the schema from the
        // `CapabilitiesRequest` struct, then convert to `serde_json::Value`.
        crate::mcp::registry::input_schema_for::<CapabilitiesRequest>()
    }

    fn family() -> &'static str {
        crate::profile::Family::Meta.name()
    }
}

#[cfg(test)]
mod d1_1_982_tests {
    use super::*;

    #[test]
    fn capabilities_tool_metadata_982() {
        assert_eq!(CapabilitiesTool::name(), "memory_capabilities");
        assert_eq!(CapabilitiesTool::family(), "meta");
        assert!(CapabilitiesTool::description().contains("capabilities"));
        assert!(CapabilitiesTool::docs().contains("family"));
    }

    #[test]
    fn capabilities_input_schema_has_expected_fields_982() {
        let schema = CapabilitiesTool::input_schema();
        // schemars 0.8 emits the schema under either top-level
        // `properties` or under `$ref`-resolved nesting, depending on
        // version. Probe both shapes to stay version-tolerant.
        let direct = schema.get("properties").and_then(Value::as_object);
        let nested = schema
            .pointer("/definitions/CapabilitiesRequest/properties")
            .and_then(Value::as_object);
        let props = direct
            .or(nested)
            .expect("schemars must emit properties under direct or definitions path");
        for field in &["accept", "family", "include_schema", "verbose"] {
            assert!(
                props.contains_key(*field),
                "schemars-derived schema must include `{field}` (got keys: {:?})",
                props.keys().collect::<Vec<_>>()
            );
        }
    }

    #[test]
    fn capabilities_request_deserializes_empty_982() {
        let parsed: CapabilitiesRequest = serde_json::from_value(serde_json::json!({})).unwrap();
        assert!(parsed.accept.is_none());
        assert!(parsed.family.is_none());
        assert!(parsed.include_schema.is_none());
        assert!(parsed.verbose.is_none());
    }

    #[test]
    fn capabilities_request_deserializes_full_982() {
        let parsed: CapabilitiesRequest = serde_json::from_value(serde_json::json!({
            "accept": "v3",
            "family": "core",
            "include_schema": true,
            "verbose": false
        }))
        .unwrap();
        assert_eq!(parsed.accept.as_deref(), Some("v3"));
        assert_eq!(parsed.family.as_deref(), Some("core"));
        assert_eq!(parsed.include_schema, Some(true));
        assert_eq!(parsed.verbose, Some(false));
    }
}

/// Capabilities schema selector (v0.6.3.1 P1 honesty patch; extended
/// through v0.7.0 A1–A5).
///
/// HTTP callers send `Accept-Capabilities: v1`/`v2`/`v3` to request a
/// shape; MCP callers pass `accept: "v1"`/`"v2"`/`"v3"` to
/// `memory_capabilities`. **As of v0.7.0 A5, the default is v3.** v2
/// stays supported indefinitely for backward compat — clients that
/// pin v2 explicitly continue to get the v2 shape unchanged.
///
/// v3 carries pre-computed calibration fields stacked from the A1–A4
/// increments (top-level `summary` from A1; `to_describe_to_user`
/// from A2; per-tool `tools[].callable_now` from A3;
/// `agent_permitted_families` from A4). v3 is **additive** over v2 —
/// no v2 fields are removed or retyped — so v0.6.4 SDK clients
/// reading v3 by name still resolve every field they used to. The
/// `schema_version` discriminator does change from `"2"` to `"3"`,
/// which is why clients that strict-equality-asserted on it must
/// either relax that or pin `accept="v2"` explicitly.
///
/// v3 requires the live `Profile` (and optionally `McpConfig` +
/// `agent_id`) for the new pre-computed fields, so callers that opt
/// in must reach for [`handle_capabilities_with_conn_v3`] instead of
/// the v1/v2 entry point.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapabilitiesAccept {
    V1,
    V2,
    /// v0.7.0 A1–A4 — additive on top of v2: `summary`,
    /// `to_describe_to_user`, per-tool `tools[].callable_now`,
    /// optional `agent_permitted_families`. **Default since A5.**
    V3,
}

impl CapabilitiesAccept {
    /// Parse the wire value sent by the client. Unknown / missing
    /// values fall back to v3 (the default since v0.7.0 A5).
    /// Whitespace and case insensitive. Explicit `"v2"`/`"2"` still
    /// returns `V2`; explicit `"v1"`/`"1"` still returns `V1`.
    #[must_use]
    pub fn parse(s: &str) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "v1" | "1" => Self::V1,
            "v2" | "2" => Self::V2,
            // v0.7.0 A5 — unknown / missing default flips from V2 → V3.
            // Explicit `"v2"` above keeps the v2 wire shape for clients
            // that pin it; everyone else gets v3 (additive over v2).
            _ => Self::V3,
        }
    }
}

/// v0.6.3 (capabilities schema v2 / P1 honesty patch): the canonical
/// capabilities entry point.
///
/// **Live overlays.** When the wrapper has access to the corresponding
/// runtime handle, it overlays:
/// - `features.embedder_loaded` from `embedder_loaded`,
/// - `features.recall_mode_active` from `embedder_loaded` (loaded ⇒
///   `Hybrid`; not loaded but configured ⇒ `KeywordOnly`; configured
///   but failed ⇒ `Degraded`; tier == keyword ⇒ `Disabled`),
/// - `features.reranker_active` from the `CrossEncoder` enum variant
///   (`Neural` / `LexicalFallback` / `Off`),
/// - `features.cross_encoder_reranking` flips to `false` when the
///   neural reranker fell back to lexical (the v1 honesty fix #93),
/// - `models.cross_encoder` annotated with `lexical-fallback` when the
///   neural download failed.
///
/// **Live DB counts.** When `conn` is `Some`, the dynamic blocks
/// (`permissions.active_rules`, `hooks.registered_count`,
/// `approval.pending_requests`) are populated from live counts. DB
/// errors are non-fatal — the report falls back to zero-state so a
/// transient blip cannot 500 the capabilities endpoint.
///
/// **Schema selection.** `accept` controls the wire shape. As of
/// v0.7.0 A5 the default is `V3` (#1645); `V2` and `V1` remain
/// negotiable for pinned clients (`V1` projects the v2 report down to
/// the legacy shape — see [`Capabilities::to_v1`]).
pub fn handle_capabilities_with_conn(
    tier_config: &TierConfig,
    resolved_models: &ResolvedModels,
    reranker: Option<&BatchedReranker>,
    embedder_loaded: bool,
    conn: Option<&rusqlite::Connection>,
    accept: CapabilitiesAccept,
) -> Result<Value, String> {
    let caps = build_capabilities_overlay(
        tier_config,
        resolved_models,
        reranker,
        embedder_loaded,
        conn,
    );

    // --- Schema selection ---
    match accept {
        CapabilitiesAccept::V2 => serde_json::to_value(caps).map_err(|e| e.to_string()),
        CapabilitiesAccept::V1 => serde_json::to_value(caps.to_v1()).map_err(|e| e.to_string()),
        CapabilitiesAccept::V3 => Err(
            "capabilities v3 requires profile context — call handle_capabilities_with_conn_v3"
                .to_string(),
        ),
    }
}

/// v0.7.0 A1 — the v3-shaped capabilities entry point.
///
/// Same overlay logic as [`handle_capabilities_with_conn`] (factored
/// into [`build_capabilities_overlay`]); additionally computes the
/// top-level `summary` string from the live `profile` state so the
/// LLM gets a pre-computed, plain-language description of its
/// operational tool surface (loaded count, total count, the three
/// named recovery paths for unloaded families).
///
/// HTTP callers reach this path through `Accept-Capabilities: v3`;
/// MCP callers via `accept: "v3"`. The HTTP wire-up is deferred until
/// A5 (which flips the default and threads the profile through
/// `AppState`); A1 lights up the MCP dispatch path only.
pub fn handle_capabilities_with_conn_v3(
    tier_config: &TierConfig,
    resolved_models: &ResolvedModels,
    reranker: Option<&BatchedReranker>,
    embedder_loaded: bool,
    conn: Option<&rusqlite::Connection>,
    profile: &crate::profile::Profile,
    mcp_config: Option<&crate::config::McpConfig>,
    agent_id: Option<&str>,
    // v0.7.0 B4 — the harness detected from `initialize.clientInfo.name`
    // at MCP handshake time. `None` when no handshake has happened
    // (HTTP callers, or a malformed MCP session that issued
    // `memory_capabilities` before `initialize`); the resulting
    // `your_harness_supports_deferred_registration` field is omitted
    // from the wire via `skip_serializing_if = Option::is_none`.
    harness: Option<&crate::harness::Harness>,
) -> Result<Value, String> {
    let caps = build_capabilities_overlay(
        tier_config,
        resolved_models,
        reranker,
        embedder_loaded,
        conn,
    );
    let summary = build_capabilities_summary(profile);
    let describe = build_capabilities_describe_to_user(profile);
    let tools = build_capabilities_tools(profile, mcp_config, agent_id);
    let permitted = build_agent_permitted_families(mcp_config, agent_id);
    // B4 — present only when we know the harness; otherwise omit so
    // unaware callers and HTTP callers see no schema drift.
    let deferred = harness.map(crate::harness::Harness::supports_deferred_registration);
    let mut value = serde_json::to_value(caps.to_v3(summary, describe, tools, permitted, deferred))
        .map_err(|e| e.to_string())?;
    // v0.7.0 (issue #691) — substrate-level agent-action rules engine
    // surface. Stamps two top-level keys onto the `governance` object
    // in the v3 capabilities payload. Operator UI can inspect these
    // without inferring from tool registration order.
    //
    // `agent_action_check` is the honest enforcement label:
    //   "substrate-authoritative-for-internal-ops" — substrate
    //   gates are mechanical at the K9 write path; agent-external
    //   ops are harness-mediated (PreToolUse hook calls
    //   memory_check_agent_action).
    //
    // `rules_immutable_seed` reflects the seed-rules-at-enabled=0
    // posture per design revision 2026-05-13.
    if let Some(obj) = value.as_object_mut() {
        let gov = obj
            .entry("governance".to_string())
            .or_insert_with(|| serde_json::json!({}));
        if let Some(gov_obj) = gov.as_object_mut() {
            gov_obj.insert(
                "agent_action_check".to_string(),
                serde_json::Value::String("substrate-authoritative-for-internal-ops".to_string()),
            );
            gov_obj.insert(
                "rules_immutable_seed".to_string(),
                serde_json::Value::Bool(true),
            );
        }
    }
    Ok(value)
}

/// Build the runtime-overlaid [`Capabilities`] document. Shared between
/// the v1/v2 entry point [`handle_capabilities_with_conn`] and the v3
/// entry point [`handle_capabilities_with_conn_v3`] so the overlay
/// logic stays single-sourced.
fn build_capabilities_overlay(
    tier_config: &TierConfig,
    resolved_models: &ResolvedModels,
    reranker: Option<&BatchedReranker>,
    embedder_loaded: bool,
    conn: Option<&rusqlite::Connection>,
) -> crate::config::Capabilities {
    // v0.7.x (#1168) — build the report from the operator-resolved
    // models triple. The boot banner already routes the same triple
    // through `app_config.resolve_models()`; the capabilities surface
    // now matches it so `memory_capabilities.models.*` reflects what
    // the live LLM / embedder / reranker were wired to, not the
    // compiled tier preset.
    let mut caps = tier_config.capabilities_with_resolved(resolved_models);

    // --- Reranker live state (P1) ---
    caps.features.reranker_active = match reranker {
        Some(ce) if ce.is_neural() => RerankerMode::Neural,
        Some(_) => {
            // Lexical fallback — neural download or load failed.
            caps.features.cross_encoder_reranking = false;
            caps.models.cross_encoder = "lexical-fallback (neural download failed)".to_string();
            RerankerMode::LexicalFallback
        }
        None => {
            // #1647 — no reranker handle on THIS surface (the HTTP
            // daemon never wires one; its recall path performs no
            // cross-encoder pass), so the flag must not advertise the
            // tier preset. Same live-truth posture as the #93
            // LexicalFallback flip above: the envelope was previously
            // self-contradictory (cross_encoder_reranking=true beside
            // reranker_active="off") on autonomous-tier HTTP daemons.
            caps.features.cross_encoder_reranking = false;
            RerankerMode::Off
        }
    };

    // --- Reflection-aware boost live state (v0.7.0 L2-8) ---
    if let Some(ce) = reranker {
        caps.features.reflection_boost =
            crate::config::ReflectionBoostReport::from(ce.reflection_boost());
    }

    // --- Embedder live state (P1, S18) ---
    caps.features.embedder_loaded = embedder_loaded;
    caps.features.recall_mode_active = compute_recall_mode(tier_config, embedder_loaded);

    // --- HNSW eviction surface (P3, G2) ---
    caps.hnsw.evictions_total = crate::hnsw::index_evictions_total();
    caps.hnsw.evicted_recently = crate::hnsw::evicted_recently(60);

    // v0.7-polish SEC-15 / COR-11 (issue #780) — mirror the
    // process-wide auto-export spawn-failure counter onto the
    // capabilities surface so operators see otherwise-silent
    // detached-worker failures without scraping /metrics directly.
    caps.hooks.auto_export_spawn_failed_total = crate::metrics::auto_export_spawn_failed_count();

    // --- Live DB-count overlays ---
    if let Some(c) = conn {
        if let Ok(n) = db::count_active_governance_rules(c) {
            caps.permissions.active_rules = n;
        }
        // v0.7.0 K5 — populate `permissions.rule_summary` with a
        // one-line summary per active governance policy, sorted lex by
        // namespace. The DB layer returns the rows already sorted, so
        // the format pass preserves order. Failure is silent (best-
        // effort): a malformed policy must not take down the whole
        // capabilities response. `Vec::is_empty` + `skip_serializing_if`
        // means an unconfigured deployment sees the field omitted from
        // the wire entirely (matching the v0.6.3.1 honesty disclosure
        // that the field was previously dropped because no per-rule
        // serializer existed).
        if let Ok(rules) = db::list_active_governance_policies(c) {
            caps.permissions.rule_summary = rules
                .into_iter()
                .map(|(ns, p)| format_rule_summary(&ns, &p))
                .collect();
        }
        if let Ok(n) = db::count_subscriptions(c) {
            caps.hooks.registered_count = n;
        }
        if let Ok(n) = db::count_pending_actions_by_status(c, "pending") {
            caps.approval.pending_requests = n;
        }
        // v0.7.0 Cluster-C SEC-3 (issue #767) — surface the deferred-
        // audit drainer's DLQ depth. Best-effort: a missing table
        // (pre-v40 DB) or transient lock falls through to 0 so the
        // capabilities response always succeeds.
        if let Ok(n) = crate::governance::deferred_audit::dlq_size(c) {
            caps.approval.deferred_audit_dlq_size = n;
        }

        // v0.7.0 #1324 — transcripts substrate live overlay.
        //
        // Pre-#1324 the capabilities surface advertised
        // `planned: true, enabled: false` for transcripts even though
        // the v0.7.0 substrate ships the full zstd-3 BLOB store + the
        // `memory_replay` MCP tool + the lifecycle sweep. Operators
        // hitting `memory_replay` against a reflection chain reasonably
        // expected non-empty output and instead received an empty
        // array, because the substrate does not auto-link transcripts
        // — that requires the operator to wire the R5 reference
        // `pre_store` hook (`tools/transcript-extractor/`).
        //
        // The overlay below flips `enabled: true` when at least one
        // row exists in `memory_transcripts` (the operator wired the
        // hook + rows are flowing) and surfaces a non-zero
        // `total_count` / `total_size_mb` so the operator can audit
        // capacity without scraping the DB directly. A missing table
        // (pre-v21 DB) or transient lock falls through to the
        // pre-overlay defaults (count = 0, enabled = false) so the
        // capabilities response always succeeds.
        if let Ok((count, bytes)) = c.query_row(
            "SELECT COUNT(*), COALESCE(SUM(compressed_size), 0) FROM memory_transcripts",
            [],
            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
        ) {
            #[allow(clippy::cast_sign_loss)]
            let count_usize = count.max(0) as usize;
            #[allow(clippy::cast_sign_loss)]
            let bytes_u64 = bytes.max(0) as u64;
            caps.transcripts.total_count = count_usize;
            caps.transcripts.total_size_mb = bytes_u64 / (1024 * 1024);
            if count_usize > 0 {
                caps.transcripts.status.enabled = true;
            }
        }
    }

    caps
}

/// v0.7.0 K5 — format a single [`GovernancePolicy`] as a one-line
/// human-readable summary, prefixed with the namespace it governs.
///
/// Output shape:
/// ```text
/// "alphaone/eng — write=approve, promote=any, delete=owner, approver=human, inherit=true"
/// ```
///
/// The `approver` rendering follows the [`ApproverType`] discriminator
/// tag (`human` / `agent:<id>` / `consensus:<n>`) so an operator can tell
/// apart a `Human` policy from a `Consensus(3)` policy without fanning
/// out to `memory_namespace_get_standard`. `inherit` is rendered as a
/// boolean string so the line stays scan-friendly.
///
/// Public so the capabilities-v3 integration tests (track A, K5) can
/// pin the exact wire shape without re-implementing the formatter.
#[must_use]
pub fn format_rule_summary(namespace: &str, policy: &crate::models::GovernancePolicy) -> String {
    use crate::models::ApproverType;
    // #880 — `approver` / `write` / `promote` / `delete` / `inherit`
    // live on `policy.core` after the governance decomposition.
    let approver = match &policy.core.approver {
        ApproverType::Human => "human".to_string(),
        ApproverType::Agent(id) => format!("agent:{id}"),
        ApproverType::Consensus(n) => format!("consensus:{n}"),
    };
    format!(
        "{namespace} — write={write}, promote={promote}, delete={delete}, approver={approver}, inherit={inherit}",
        write = policy.core.write.as_str(),
        promote = policy.core.promote.as_str(),
        delete = policy.core.delete.as_str(),
        inherit = policy.core.inherit,
    )
}

/// v0.7.0 A1 — build the capabilities-v3 `summary` string from the live
/// `Profile` state.
///
/// The summary names: how many tools are advertised in `tools/list`
/// under the active profile vs how many exist in total, and the three
/// recovery paths an LLM can take to reach unloaded tools (`--profile`
/// CLI flag, [`memory_load_family`](#) — landing in B1, and
/// [`memory_smart_load`](#) — landing in B2).
///
/// The result is a single plain-language string, intentionally written
/// for an LLM to repeat verbatim when an end-user asks "what tools do
/// you have?" — see the A2 increment for the explicit
/// `to_describe_to_user` field.
#[must_use]
pub fn build_capabilities_summary(profile: &crate::profile::Profile) -> String {
    use crate::profile::{ALWAYS_ON_TOOLS, Family};

    // Round-2 F13 — substantive memory-tool count, EXCLUDING the
    // always-on bootstrap (`memory_capabilities`). Reconciles with
    // `build_capabilities_describe_to_user`'s "{n_loaded} memory
    // tool{s}" phrasing so the summary number agrees with the
    // user-facing sentence — at v0.7.0 both report 73 for
    // `--profile full` (73 callable memory tools + the always-on
    // `memory_capabilities` bootstrap = 74 advertised entries, which
    // matches `Profile::full().expected_tool_count()` and
    // `crate::mcp::registry::tool_names::ALL.len()`). The F13 pin
    // guards against the off-by-one where the summary count would
    // collide with the advertised-entries count; see issue #862 for
    // the canonical 73/74 disambiguation.
    let total: usize = Family::all()
        .iter()
        .map(|f| f.expected_tool_count())
        .sum::<usize>()
        .saturating_sub(ALWAYS_ON_TOOLS.len());

    // Visible memory tools = profile-loaded family tools, minus any
    // always-on bootstrap that lives in a family the profile loads
    // (otherwise `memory_capabilities` would be double-counted for
    // profiles that load `Meta`). The bootstrap still appears in
    // `tools/list` — it just isn't a "memory tool" in the user-facing
    // sense.
    let from_families: usize = profile.expected_tool_count();
    let always_on_in_loaded_family: usize = ALWAYS_ON_TOOLS
        .iter()
        .filter(|name| Family::for_tool(name).is_some_and(|f| profile.includes(f)))
        .count();
    let visible = from_families.saturating_sub(always_on_in_loaded_family);
    let unloaded = total.saturating_sub(visible);
    let label = profile_summary_label(profile);

    format!(
        "{visible} of {total} memory tools are advertised in tools/list under the current \
         profile ({label}). The other {unloaded} are listed in this manifest but NOT directly \
         callable. To use any unloaded tool, choose one of: \
         (a) restart the server with --profile <family> or --profile full, \
         (b) call memory_load_family(family=<name>) — preferred, \
         (c) call memory_smart_load(intent='<plain language>') — easiest, \
         (d) call the tool by name and recover from JSON-RPC -32601."
    )
}

/// v0.7.0 A2 — build the capabilities-v3 `to_describe_to_user` string.
///
/// This is the canonical plain-language sentence the LLM should repeat
/// (verbatim) when an end-user asks "what tools do you have?". It
/// names how many tools are loaded right now, lists the first few by
/// short name (without the `memory_` prefix, since the prefix is MCP
/// jargon a user doesn't care about), reports how many are unloaded,
/// and gives an end-user-friendly recovery hint ("I can load them on
/// demand, or you can restart the server with a different profile").
///
/// Tone constraint (per A2 spec): NO MCP jargon. No mention of
/// `tools/list`, `JSON-RPC`, or `--profile <family>`. Reads like a
/// normal sentence a person would write.
///
/// The always-on bootstrap (`memory_capabilities`) is intentionally
/// excluded from the loaded-tool preview — to a user, it's plumbing,
/// not a feature.
#[must_use]
pub fn build_capabilities_describe_to_user(profile: &crate::profile::Profile) -> String {
    use crate::profile::Family;

    // Loaded vs unloaded by family membership. The always-on bootstrap
    // sits in `Family::Meta`; under e.g. `--profile core` Meta isn't
    // loaded, so `memory_capabilities` would normally count as
    // unloaded. We strip it from BOTH sides — the user-facing sentence
    // talks about the substantive tool surface, not the
    // runtime-discovery bootstrap.
    let loaded_tools: Vec<&'static str> = Family::all()
        .iter()
        .filter(|f| profile.includes(**f))
        .flat_map(|f| f.tool_names().iter().copied())
        .filter(|name| !crate::profile::ALWAYS_ON_TOOLS.contains(name))
        .collect();
    let unloaded_tools: Vec<&'static str> = Family::all()
        .iter()
        .filter(|f| !profile.includes(**f))
        .flat_map(|f| f.tool_names().iter().copied())
        .filter(|name| !crate::profile::ALWAYS_ON_TOOLS.contains(name))
        .collect();

    let n_loaded = loaded_tools.len();
    let n_unloaded = unloaded_tools.len();

    // Preview the first 5 loaded tools by short name (strip the
    // `memory_` prefix). Five matches the canonical example in the
    // A2 NHI prompt and lines up with the size of the smallest
    // (`core`) profile so the preview is a complete enumeration there.
    let preview_loaded = loaded_tools
        .iter()
        .take(5)
        .map(|name| short_tool_name(name))
        .collect::<Vec<_>>()
        .join(", ");
    let loaded_more_marker = if n_loaded > 5 { ", ..." } else { "" };

    if n_unloaded == 0 {
        format!(
            "I can directly use all {n_loaded} memory tools right now \
             ({preview_loaded}{loaded_more_marker}). Nothing more to load — \
             the full memory surface is already active."
        )
    } else {
        // Preview 4 unloaded tool names — the canonical example uses 4
        // (link, kg_query, consolidate, delete) followed by ", etc.".
        let preview_unloaded = unloaded_tools
            .iter()
            .take(4)
            .map(|name| short_tool_name(name))
            .collect::<Vec<_>>()
            .join(", ");
        let plural_loaded = if n_loaded == 1 { "" } else { "s" };
        format!(
            "I can directly use {n_loaded} memory tool{plural_loaded} right now \
             ({preview_loaded}{loaded_more_marker}). {n_unloaded} more \
             ({preview_unloaded}, etc.) are available on demand — I can load them \
             if you ask for something that needs them, or you can restart the \
             server with a different profile."
        )
    }
}

/// Strip the `memory_` prefix from a tool name for end-user-facing
/// previews. v0.7.0 A2 — the prefix is MCP jargon; a user doesn't care
/// that every tool name starts with the same five characters.
fn short_tool_name(name: &'static str) -> &'static str {
    name.strip_prefix("memory_").unwrap_or(name)
}

/// v0.7.0 A3 — build the per-tool array carried in the
/// capabilities-v3 `tools` field.
///
/// Each entry's `loaded` mirrors `Profile::loads(name)`. Each entry's
/// `callable_now` is `loaded && agent_can_call(agent_id, family)` —
/// when the `[mcp.allowlist]` is disabled (no table or empty), the
/// allowlist gate is `Disabled` and the AND collapses to just
/// `loaded`. When the allowlist is active and the requesting agent
/// has no entry granting the tool's family, `callable_now == false`
/// even though `loaded == true`.
///
/// The order of the returned vector matches `crate::mcp::tool_definitions()`'s
/// registration walk so a sequential reader gets a stable
/// presentation matching the order in `tools/list`.
#[must_use]
pub fn build_capabilities_tools(
    profile: &crate::profile::Profile,
    mcp_config: Option<&crate::config::McpConfig>,
    agent_id: Option<&str>,
) -> Vec<crate::config::ToolEntry> {
    use crate::config::{AllowlistDecision, ToolEntry};
    use crate::profile::{ALWAYS_ON_TOOLS, Family};

    let mut entries: Vec<ToolEntry> = Vec::with_capacity(50);

    for fam in Family::all() {
        let family_name = fam.name();
        let loaded = profile.includes(*fam);
        // Whether THIS agent can call tools in this family — disabled
        // allowlist falls through to `loaded`. When the allowlist is
        // configured but denies the family, callable_now collapses to
        // false regardless of loaded.
        let allowed = match mcp_config {
            // #1673/n13 — when there is no resolved caller agent_id (the HTTP
            // capabilities surface passes None), the per-agent allowlist cannot
            // make an honest decision: `allowlist_decision(None, ..)` coerces
            // `aid=""` -> wildcard-or-Deny, which mis-reports callable_now
            // (allowlisted callers would see false, non-allowlisted true).
            // Report callable_now from `loaded` alone for an unknown caller.
            // The MCP surface always resolves a concrete agent_id, so per-agent
            // gating is unchanged there; ENFORCING the allowlist on the HTTP
            // surface (which has its own auth model) is tracked for v0.8 (#1695).
            Some(_) if agent_id.is_none() => true,
            Some(cfg) => match cfg.allowlist_decision(agent_id, family_name) {
                AllowlistDecision::Disabled | AllowlistDecision::Allow => true,
                AllowlistDecision::Deny => false,
            },
            None => true,
        };
        for name in fam.tool_names() {
            entries.push(ToolEntry {
                name: (*name).to_string(),
                family: family_name.to_string(),
                loaded,
                callable_now: loaded && allowed,
                // v0.7.0 issue #803 — per-tool worked examples.
                examples: tool_examples(name),
            });
        }
    }

    // Always-on bootstraps not in a normal family walk.
    for name in ALWAYS_ON_TOOLS {
        if !entries.iter().any(|e| e.name == *name) {
            entries.push(ToolEntry {
                name: (*name).to_string(),
                family: "always_on".to_string(),
                loaded: true,
                callable_now: true,
                examples: tool_examples(name),
            });
        }
    }

    entries
}

/// v0.7.0 issue #803 — per-tool worked example catalog.
///
/// Returns 0-2 [`crate::config::ToolExample`] entries for a given
/// tool name. Only a curated subset of high-leverage tools carry
/// examples; the rest return empty, which `skip_serializing_if`
/// drops from the wire so the payload stays compact.
#[must_use]
pub fn tool_examples(name: &str) -> Vec<crate::config::ToolExample> {
    use crate::config::ToolExample;
    use crate::mcp::registry::tool_names as tn;
    use crate::models::Tier;
    use serde_json::json;
    let ex = |call: serde_json::Value, desc: &str| ToolExample {
        call,
        description: desc.to_string(),
    };
    match name {
        tn::MEMORY_STORE => vec![ex(
            // #1644 — the success envelope is {id, tier, title,
            // namespace, agent_id} (store/mod.rs success echo), NOT
            // the previously-claimed {id, status}.
            json!({"title": "design", "content": "wt-1 atomisation", "tier": Tier::Long.as_str(), "namespace": "ai-memory"}),
            "Persists a long-tier memory; returns {id, tier, title, namespace, agent_id}.",
        )],
        tn::MEMORY_RECALL => vec![ex(
            // #1606 — the MCP wire param is `context` (the `query` alias
            // ladder is HTTP-only); the example stays byte-equal to a
            // valid call per the #1325 discipline, pinned by
            // `recall_example_payload_parses_1606`.
            json!({"context": "atomisation gates", "namespace": "ai-memory", "limit": 5}),
            "Hybrid FTS+semantic recall; returns top-K ranked memories.",
        )],
        tn::MEMORY_SEARCH => vec![ex(
            json!({"query": "L1-6 governance", "limit": 10}),
            "FTS5 keyword search across namespaces.",
        )],
        tn::MEMORY_LINK => vec![ex(
            // #1644 — parser fields are `source_id`/`target_id`
            // (`handle_link`), NOT `from_id`/`to_id`; the success
            // envelope carries no `link_id`.
            json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "relation": "derives_from"}),
            "Signed directional edge; returns {linked, source_id, target_id, relation, invalidation_notified, attest_level}.",
        )],
        tn::MEMORY_REFLECT => vec![ex(
            // v0.7.0 #1325 — example payload is byte-equal to a valid call.
            // Canonical parser field is `source_ids` (NOT `memory_ids`);
            // `depth` is an optional caller-asserted cap that MUST equal
            // max(source_depths)+1 or the call refuses with
            // CALLER_DEPTH_MISMATCH. For depth-0 sources, the substrate
            // computes reflection_depth=1, which the example asserts.
            json!({
                "source_ids": ["<uuid-1>", "<uuid-2>"],
                "title": "Reflection over alpha + beta",
                "content": "Synthesis of the two source memories.",
                "depth": 1,
            }),
            "Curator synthesises a Reflection; returns {id, reflection_depth, reflects_on, namespace}.",
        )],
        tn::MEMORY_PERSONA_GENERATE => vec![
            ex(
                json!({"entity_id": "alice", "namespace": "team/alpha"}),
                "Single-namespace scope.",
            ),
            ex(
                json!({"entity_id": "alice"}),
                "#848 cross-namespace; persona lands in 'global'.",
            ),
        ],
        tn::MEMORY_CONSOLIDATE => vec![ex(
            // #1644 — the parser contract is `ids[]` + `title`
            // (`handle_consolidate`); there is no namespace-sweep
            // form and no `into_namespace`/`limit` params.
            json!({"ids": ["<uuid-a>", "<uuid-b>"], "title": "Distilled summary", "namespace": "team/alpha"}),
            "Curator distils the listed memories into one consolidated memory.",
        )],
        tn::MEMORY_ATOMISE => vec![ex(
            json!({"memory_id": "<long-uuid>", "max_atom_tokens": 200}),
            "WT-1 decomposition; archives parent.",
        )],
        tn::MEMORY_FIND_PATHS => vec![ex(
            // #1644 — parser fields are `source_id`/`target_id`
            // (`handle_find_paths`), NOT `from_id`/`to_id`.
            json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "max_depth": 4}),
            "BFS over KG; returns path arrays of memory ids.",
        )],
        tn::MEMORY_KG_QUERY => vec![ex(
            // #1644 — the parser requires `source_id` and reads
            // `max_depth` (`handle_kg_query`); the previously-shipped
            // `start_id`/`relation`/`direction`/`depth` params are
            // never read by the handler.
            json!({"source_id": "<uuid>", "max_depth": 2}),
            "Typed KG walk; returns nodes+edges.",
        )],
        tn::MEMORY_EXPORT_REFLECTION => vec![ex(
            json!({"memory_id": "<reflection-uuid>", "format": "md"}),
            "QW-1 export; returns {content, suggested_filename}.",
        )],
        tn::MEMORY_SMART_LOAD => vec![ex(
            // #1644 sweep — the handler reads `intent`/`namespace`/`k`
            // only; the previously-shipped `include_schema` was inert.
            json!({"intent": "inspect the knowledge graph", "k": 10}),
            "B2 intent routing.",
        )],
        tn::MEMORY_LOAD_FAMILY => vec![ex(
            // #1644 sweep — the handler reads `family`/`namespace`/`k`
            // only; the previously-shipped `include_schema` was inert.
            json!({"family": "graph", "k": 10}),
            "B1 explicit family load.",
        )],
        tn::MEMORY_SESSION_START => vec![ex(
            // #1644 — the handler reads only `namespace` + `limit`
            // (`handle_session_start`); the previously-shipped `topic`
            // param was inert.
            json!({"namespace": "ai-memory", "limit": 10}),
            "SessionStart bootstrap; returns memories+persona+rules.",
        )],
        tn::MEMORY_VERIFY => vec![
            // #1644 — memory_verify re-verifies LINK signatures, not
            // memory rows: `handle_verify` accepts either a composite
            // `link_id` or the explicit `source_id`+`target_id`
            // triple; `memory_id` is never read. The truthful return
            // key is `signature_verified` (not `verified`).
            ex(
                json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "relation": "derives_from"}),
                "H4 on-demand link-signature re-verify; returns {signature_verified, attest_level, signed_by, signed_at}.",
            ),
            ex(
                json!({"link_id": "<uuid-a>--derives_from--><uuid-b>"}),
                "Composite link_id form of the same re-verify call.",
            ),
        ],
        tn::MEMORY_NOTIFY => vec![ex(
            // #1644 — `handle_notify` requires `target_agent_id` +
            // `title`, and `payload` is a STRING (the message body);
            // the previously-shipped `event_type`/`ttl_seconds`
            // params (and the JSON-object payload) are never read.
            json!({"target_agent_id": "ai:claude@host-a", "title": "deploy completed", "payload": "prod deploy finished green"}),
            "Write a message to the target agent's inbox; read via memory_inbox.",
        )],
        // v0.7.0 #1327 — canonical example for `memory_skill_register`.
        // Parameter name is `folder_path` (NOT `skill_folder`); the
        // example payload is BYTE-EQUAL to what `handle_skill_register`
        // parses (see `src/mcp/tools/skill_register.rs:254-279`).
        // `inline_skill` is the alternative form for callers without a
        // filesystem path. Either field is required (not both).
        tn::MEMORY_SKILL_REGISTER => vec![
            ex(
                json!({"folder_path": "/path/to/skill-dir"}),
                "Register a SKILL.md folder (optional resources/ sub-dir); returns {id, digest, signed}.",
            ),
            ex(
                json!({"inline_skill": "---\nnamespace: example\nname: demo\ndescription: A demo skill.\n---\n\nBody.\n"}),
                "Register a SKILL.md from inline text (no filesystem dependency).",
            ),
        ],
        _ => Vec::new(),
    }
}

/// v0.7.0 A4 — compute the optional `agent_permitted_families` field
/// for a v3 capabilities response.
///
/// Returns:
/// - `Some(Vec<...>)` (possibly empty) when `[mcp.allowlist]` is
///   configured AND an `agent_id` was provided. The vector lists the
///   canonical family names the agent is permitted to access (per the
///   `Family::all()` registration order).
/// - `None` when the allowlist is disabled (no table, empty table, or
///   `mcp_config = None`) OR when no `agent_id` was provided.
///   `serde(skip_serializing_if = "Option::is_none")` on the field
///   means a `None` value drops the field from the wire entirely so
///   v2-shaped consumers don't see drift from A4 alone.
///
/// The wildcard pattern `"*"` participates in the per-family
/// allowlist_decision call — this matches the existing v0.6.4-008
/// resolution semantics, so a `"*" = ["core"]` row grants every agent
/// access to `core` even when their explicit row is missing.
#[must_use]
pub fn build_agent_permitted_families(
    mcp_config: Option<&crate::config::McpConfig>,
    agent_id: Option<&str>,
) -> Option<Vec<String>> {
    use crate::config::AllowlistDecision;
    use crate::profile::Family;

    // A4 spec: omit the field when allowlist disabled OR no agent_id.
    let cfg = mcp_config?;
    let aid = agent_id?;
    let table = cfg.allowlist.as_ref()?;
    if table.is_empty() {
        // Allowlist Disabled (per the v0.6.4-008 contract): omit.
        return None;
    }

    let permitted: Vec<String> = Family::all()
        .iter()
        .filter(|fam| {
            matches!(
                cfg.allowlist_decision(Some(aid), fam.name()),
                AllowlistDecision::Allow
            )
        })
        .map(|fam| fam.name().to_string())
        .collect();

    Some(permitted)
}

/// Return a stable label for a profile's summary string. Named profiles
/// (core/graph/admin/power/full) use their canonical name; custom
/// profiles use the comma-joined family list (matches the
/// `--profile core,graph,archive` CLI form).
fn profile_summary_label(profile: &crate::profile::Profile) -> String {
    use crate::profile::Profile;
    if *profile == Profile::full() {
        "full".to_string()
    } else if *profile == Profile::core() {
        "core".to_string()
    } else if *profile == Profile::graph() {
        "graph".to_string()
    } else if *profile == Profile::admin() {
        "admin".to_string()
    } else if *profile == Profile::power() {
        "power".to_string()
    } else {
        profile
            .families()
            .iter()
            .map(|f| f.name())
            .collect::<Vec<_>>()
            .join(",")
    }
}

/// Round-2 F13 — derive the runtime-effective tier label from the
/// presence of the LLM, embedder, and reranker handles. Mirrors the
/// boot banner string emitted by `serve_mcp` so the
/// `memory_capabilities` response and the daemon log agree on what
/// the daemon is actually doing — independent of `tier_config.tier`,
/// which only reflects the configured (build-time) tier and can lag
/// the runtime when an embedder/LLM fails to load.
#[must_use]
pub fn effective_tier_label(has_llm: bool, has_embedder: bool, has_reranker: bool) -> &'static str {
    if has_llm && has_embedder && has_reranker {
        "autonomous"
    } else if has_llm && has_embedder {
        "smart"
    } else if has_embedder {
        "semantic"
    } else {
        "keyword"
    }
}

/// Round-2 F13 — overlay per-tool `inputSchema` and/or `docstring`
/// onto the top-level `tools[]` array of a v2/v3 capabilities
/// response. Called on the no-family path when `include_schema=true`
/// and/or `verbose=true` is set on the top-level
/// `memory_capabilities` invocation. Without an overlay, those
/// flags were inert at the top level (only the family drilldown
/// honoured them).
///
/// `include_schema=true` — inject the canonical
/// `crate::mcp::tool_definitions()[name].inputSchema` for every tool entry.
/// `verbose=true` — inject `docstring` (sourced from the long-form
/// `docs` field on `crate::mcp::tool_definitions()`).
///
/// Tools that aren't currently loaded under the active profile (i.e.
/// `loaded=false` in the v3 `tools[]`) get the same overlay so a
/// caller can decide whether to drill in via
/// `memory_load_family`/`memory_smart_load`.
pub fn overlay_tool_payloads(
    obj: &mut serde_json::Map<String, Value>,
    _profile: &crate::profile::Profile,
    include_schema: bool,
    verbose: bool,
) {
    if !include_schema && !verbose {
        return;
    }

    // Build a name → (docs, inputSchema) lookup from the canonical
    // tool catalog. Done once per call; cheap (~50 entries).
    //
    // v0.7.0 #1059 (Agent-4 F5) — when `verbose=false` the caller is
    // asking for the trimmed wire shape. Pre-#1059 this function
    // injected the FULL unstripped schemars `inputSchema` regardless
    // of the verbose flag — including schemars-only metadata
    // (top-level `description`, `$schema`, `title`, nested
    // `definitions.*.description`, per-property `description`,
    // `default: null`) that the bare `tools/list` payload strips via
    // `strip_docs_from_tools`. The asymmetric gate meant a caller
    // sending `include_schema=true, verbose=false` got a noisier
    // payload than the bare `tools/list` they would have received
    // with no overlay.
    //
    // Post-#1059 the lookup runs through `strip_docs_from_tools`
    // when `verbose=false` so the overlay matches the bare wire
    // contract. When `verbose=true` the caller is explicitly asking
    // for the prose surface — preserve the un-stripped schemas.
    let defs = if verbose {
        crate::mcp::tool_definitions()
    } else {
        let mut defs = crate::mcp::tool_definitions();
        if let Some(arr) = defs.get_mut("tools").and_then(Value::as_array_mut) {
            crate::mcp::registry::strip_docs_from_tools(arr);
        }
        defs
    };
    let lookup: std::collections::HashMap<String, (Option<Value>, Option<Value>)> = defs
        .get("tools")
        .and_then(Value::as_array)
        .map(|tools| {
            tools
                .iter()
                .filter_map(|t| {
                    let name = t
                        .get(param_names::NAME)
                        .and_then(Value::as_str)?
                        .to_string();
                    let docs = t.get("docs").cloned();
                    let schema = t.get("inputSchema").cloned();
                    Some((name, (docs, schema)))
                })
                .collect()
        })
        .unwrap_or_default();

    // The v3 response carries a top-level `tools` array of
    // `ToolEntry` objects; the v2 response does not. For v2 callers
    // passing include_schema/verbose, synthesize a parallel
    // `tool_payloads` array so the overlay is still discoverable
    // without disturbing the v2 wire shape.
    if let Some(tools) = obj.get_mut("tools").and_then(Value::as_array_mut) {
        for tool in tools.iter_mut() {
            let Some(tool_obj) = tool.as_object_mut() else {
                continue;
            };
            let Some(name) = tool_obj.get(param_names::NAME).and_then(Value::as_str) else {
                continue;
            };
            let Some((docs, schema)) = lookup.get(name) else {
                continue;
            };
            if include_schema && let Some(s) = schema {
                tool_obj.insert("inputSchema".to_string(), s.clone());
            }
            if verbose && let Some(d) = docs {
                tool_obj.insert("docstring".to_string(), d.clone());
            }
        }
    } else {
        // v2 path — no `tools` field exists. Synthesize a flat
        // `tool_payloads` array so the overlay is still on the wire.
        let payloads: Vec<Value> = lookup
            .iter()
            .map(|(name, (docs, schema))| {
                let mut entry = serde_json::Map::new();
                entry.insert("name".to_string(), Value::String(name.clone()));
                if include_schema && let Some(s) = schema {
                    entry.insert("inputSchema".to_string(), s.clone());
                }
                if verbose && let Some(d) = docs {
                    entry.insert("docstring".to_string(), d.clone());
                }
                Value::Object(entry)
            })
            .collect();
        obj.insert("tool_payloads".to_string(), Value::Array(payloads));
    }
}

/// Compute the live `recall_mode_active` tag from the configured tier
/// and the runtime embedder-loaded signal. P1 honesty patch.
///
/// - Tier configured no embedder (keyword tier) → `Disabled`.
/// - Tier configured an embedder and it loaded → `Hybrid`.
/// - Tier configured an embedder but it did not load → `Degraded`.
/// - (Reserved) `KeywordOnly` is returned only when the daemon has an
///   embedder configured but the operator explicitly disabled hybrid
///   blending — not possible in v0.6.3.1, so unreachable today.
fn compute_recall_mode(
    tier_config: &TierConfig,
    embedder_loaded: bool,
) -> crate::config::RecallMode {
    use crate::config::RecallMode;
    if tier_config.embedding_model.is_none() {
        RecallMode::Disabled
    } else if embedder_loaded {
        RecallMode::Hybrid
    } else {
        RecallMode::Degraded
    }
}

#[cfg(test)]
mod example_validity_1606_tests {
    //! #1606 — capabilities examples must stay byte-equal to valid
    //! calls (the #1325 discipline). The pre-#1606 `memory_recall`
    //! example advertised `{"query": ...}`, a payload the MCP wire
    //! parser refuses with "context is required" (the `query` alias
    //! ladder is HTTP-only).

    #[test]
    fn recall_example_payload_parses_1606() {
        let examples = super::tool_examples(crate::mcp::registry::tool_names::MEMORY_RECALL);
        assert!(
            !examples.is_empty(),
            "memory_recall must carry a worked example (#803)"
        );
        for example in &examples {
            crate::models::RecallRequest::from_mcp_params(&example.call).unwrap_or_else(|e| {
                panic!(
                    "memory_recall capabilities example must be byte-equal to a \
                     valid MCP call (#1606/#1325); parser said: {e}"
                )
            });
        }
    }
}

#[cfg(test)]
mod example_validity_1644_tests {
    //! #1644 — class-closing generalization of
    //! `recall_example_payload_parses_1606` (above) and
    //! `tests/issue_1327_skill_register_docstring_example.rs`: EVERY
    //! payload in the `tool_examples()` worked-example catalog must
    //! round-trip through its tool's actual parser / param-extraction
    //! layer (the #1325 byte-valid discipline).
    //!
    //! **Parse-level validation suffices here (documented per #1644):**
    //! most handlers need a live DB / LLM / embedder to execute
    //! end-to-end, but the failure class this closes — wrong param
    //! names (`from_id` vs `source_id`), wrong param types (object
    //! `payload` vs string), and inert params the handler never reads
    //! (`topic`, `ttl_seconds`) — is fully visible at the serde +
    //! schema layer. Each example deserializes through the SAME
    //! request struct whose schemars derive is the tool's wire
    //! `inputSchema`, and (because #1052 keeps the structs permissive
    //! to unknown fields) every example key is additionally checked
    //! against the declared `inputSchema.properties` set so an inert
    //! param cannot hide behind serde leniency.

    use serde_json::Value;

    /// Every tool name in the FULL catalog (all registered tools +
    /// the always-on bootstraps) that carries at least one worked
    /// example. Enumerated from the catalog — not hand-listed — so a
    /// future example added for ANY tool cannot dodge this test.
    fn example_bearing_tools() -> Vec<String> {
        let defs = crate::mcp::tool_definitions();
        let mut names: Vec<String> = defs["tools"]
            .as_array()
            .expect("tool_definitions must emit `tools` array")
            .iter()
            .filter_map(|t| t.get("name").and_then(Value::as_str))
            .map(str::to_string)
            .collect();
        for name in crate::profile::ALWAYS_ON_TOOLS {
            if !names.iter().any(|n| n == name) {
                names.push((*name).to_string());
            }
        }
        names.retain(|n| !super::tool_examples(n).is_empty());
        assert!(
            !names.is_empty(),
            "the #803 worked-example catalog must not be empty"
        );
        names
    }

    /// Deserialize `call` through `T` — the same request struct whose
    /// schemars derive is the tool's wire `inputSchema` — panicking
    /// with the tool name on refusal.
    fn parses_as<T: serde::de::DeserializeOwned>(name: &str, call: &Value) {
        if let Err(e) = serde_json::from_value::<T>(call.clone()) {
            panic!(
                "{name} capabilities example must be byte-equal to a valid \
                 MCP call (#1644/#1325); parser said: {e}"
            );
        }
    }

    /// Round-trip every worked example through its tool's canonical
    /// parser. The fallthrough arm panics, so a tool that GAINS a
    /// worked example without a parser pin here fails this test —
    /// that is the class guard.
    #[test]
    fn issue_1644_every_example_round_trips_through_its_parser() {
        use crate::mcp::registry::tool_names as tn;
        for name in example_bearing_tools() {
            for example in &super::tool_examples(&name) {
                let call = &example.call;
                match name.as_str() {
                    x if x == tn::MEMORY_STORE => {
                        parses_as::<crate::mcp::store::StoreRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_RECALL => {
                        // The one real no-DB parser entry point — same
                        // contract as `recall_example_payload_parses_1606`.
                        crate::models::RecallRequest::from_mcp_params(call).unwrap_or_else(|e| {
                            panic!("memory_recall example must parse (#1644): {e}")
                        });
                    }
                    x if x == tn::MEMORY_SEARCH => {
                        parses_as::<crate::mcp::search::SearchRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_LINK => {
                        parses_as::<crate::mcp::link::LinkRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_REFLECT => {
                        parses_as::<crate::mcp::reflect::ReflectRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_PERSONA_GENERATE => {
                        parses_as::<crate::mcp::persona::PersonaGenerateRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_CONSOLIDATE => {
                        parses_as::<crate::mcp::consolidate::ConsolidateRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_ATOMISE => {
                        parses_as::<crate::mcp::atomise::AtomiseRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_FIND_PATHS => {
                        parses_as::<crate::mcp::find_paths::FindPathsRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_KG_QUERY => {
                        parses_as::<crate::mcp::kg_query::KgQueryRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_EXPORT_REFLECTION => {
                        parses_as::<crate::mcp::export_reflection::ExportReflectionRequest>(
                            &name, call,
                        );
                    }
                    x if x == tn::MEMORY_SMART_LOAD => {
                        parses_as::<crate::mcp::load_family::SmartLoadRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_LOAD_FAMILY => {
                        parses_as::<crate::mcp::load_family::LoadFamilyRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_SESSION_START => {
                        parses_as::<crate::mcp::session_start::SessionStartRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_VERIFY => {
                        parses_as::<crate::mcp::verify::VerifyRequest>(&name, call);
                        // Mirror `handle_verify`'s param extraction:
                        // link_id OR source_id+target_id is required,
                        // and a composite link_id must satisfy
                        // `parse_link_id`.
                        let obj = call.as_object().expect("verify example is an object");
                        if let Some(lid) = obj.get("link_id").and_then(Value::as_str) {
                            assert!(
                                crate::mcp::link::parse_link_id(lid).is_some(),
                                "memory_verify link_id example must satisfy parse_link_id (#1644): {lid}"
                            );
                        } else {
                            assert!(
                                obj.contains_key("source_id") && obj.contains_key("target_id"),
                                "memory_verify example must carry link_id or source_id+target_id (#1644)"
                            );
                        }
                    }
                    x if x == tn::MEMORY_NOTIFY => {
                        parses_as::<crate::mcp::notify::NotifyRequest>(&name, call);
                    }
                    x if x == tn::MEMORY_SKILL_REGISTER => {
                        parses_as::<crate::mcp::skill_register::SkillRegisterRequest>(&name, call);
                    }
                    other => panic!(
                        "tool `{other}` carries worked examples but has no parser \
                         round-trip arm in this test — add one so the example \
                         stays byte-valid (#1644 class guard)"
                    ),
                }
            }
        }
    }

    /// Every key on every example must be a declared
    /// `inputSchema.properties` entry, and every schema-`required`
    /// property must be present on the example. This is the
    /// inert-param guard: #1052 keeps request structs permissive to
    /// unknown fields, so serde alone cannot catch a `topic`-class
    /// param the handler never reads.
    #[test]
    fn issue_1644_example_keys_match_declared_schema() {
        let defs = crate::mcp::tool_definitions();
        let tools = defs["tools"]
            .as_array()
            .expect("tool_definitions must emit `tools` array");
        for name in example_bearing_tools() {
            let tool = tools
                .iter()
                .find(|t| t.get("name").and_then(Value::as_str) == Some(name.as_str()))
                .unwrap_or_else(|| panic!("`{name}` must be in the tool catalog"));
            let props: std::collections::BTreeSet<&str> = tool
                .pointer("/inputSchema/properties")
                .and_then(Value::as_object)
                .unwrap_or_else(|| panic!("`{name}` must carry inputSchema.properties"))
                .keys()
                .map(String::as_str)
                .collect();
            let required: Vec<&str> = tool
                .pointer("/inputSchema/required")
                .and_then(Value::as_array)
                .map(|a| a.iter().filter_map(Value::as_str).collect())
                .unwrap_or_default();
            for (idx, example) in super::tool_examples(&name).iter().enumerate() {
                let obj = example
                    .call
                    .as_object()
                    .unwrap_or_else(|| panic!("{name} example {idx} call must be an object"));
                for key in obj.keys() {
                    assert!(
                        props.contains(key.as_str()),
                        "{name} example {idx}: key `{key}` is not a declared \
                         inputSchema property — the handler never reads it \
                         (#1644 inert-param class); declared: {props:?}"
                    );
                }
                // memory_verify's either/or gate is checked in the
                // round-trip test; its schema declares no `required`.
                for req in &required {
                    assert!(
                        obj.contains_key(*req),
                        "{name} example {idx}: missing required property `{req}` (#1644)"
                    );
                }
            }
        }
    }

    /// Pin the two #1644 return-shape corrections so the false claims
    /// cannot regress.
    #[test]
    fn issue_1644_return_shape_claims_are_truthful() {
        use crate::mcp::registry::tool_names as tn;
        let store = super::tool_examples(tn::MEMORY_STORE);
        assert!(
            store[0]
                .description
                .contains("{id, tier, title, namespace, agent_id}"),
            "memory_store example must claim the real success envelope (#1644)"
        );
        assert!(
            !store[0].description.contains("{id, status}"),
            "memory_store example must not claim the fictitious {{id, status}} shape (#1644)"
        );
        let link = super::tool_examples(tn::MEMORY_LINK);
        assert!(
            !link[0].description.contains("link_id"),
            "memory_link's success envelope carries no link_id (#1644)"
        );
        assert!(
            link[0].description.contains("invalidation_notified")
                && link[0].description.contains("attest_level"),
            "memory_link example must claim the real success envelope (#1644)"
        );
        let verify = super::tool_examples(tn::MEMORY_VERIFY);
        assert!(
            verify[0].description.contains("signature_verified"),
            "memory_verify's truthful return key is signature_verified (#1644)"
        );
    }
}

#[cfg(test)]
mod d1_2_983_tests {
    //! D1.2 (#983) — parity contract between the schemars-derived
    //! `memory_capabilities` schema and the legacy hand-coded entry in
    //! [`crate::mcp::registry::tool_definitions`]. Run via
    //! `cargo test --lib d1_2_983`.
    //!
    //! Allowed diffs (documented + asserted-tolerated):
    //!
    //! 1. `type`: legacy `"string"` / `"boolean"`; schemars
    //!    `["string","null"]` / `["boolean","null"]` because Rust
    //!    `Option<T>` round-trips through nullable JSON. Wire clients
    //!    consume the same shape.
    //! 2. `default`: legacy carries typed defaults (`"v2"` /
    //!    `false`); schemars emits `null` for every `Option<T>`. The
    //!    handler's runtime `unwrap_or_*` calls supply the v0.7.0 A5
    //!    defaults (V3 for `accept`, `false` for booleans), so the
    //!    wire-level None reaches the same code path.
    //! 3. `enum`: legacy carries `["v1","v2"]` for `accept` (stale —
    //!    the runtime has supported V3 since A5) and a curated
    //!    family list. The D1.1 PoC intentionally drops these to fix
    //!    the schema/runtime drift (see CapabilitiesRequest doc).
    //!    A future enum-tightening pass can reintroduce them via
    //!    typed enum structs + `#[schemars(with = "...")]`.
    //! 4. `additionalProperties: false`: schemars emits it (from
    //!    is a tightening — strictly safer for clients.
    //!
    //! Match-exactly contracts:
    //!
    //! - Property names: every property in the legacy entry MUST be
    //!   present in the schemars-derived schema; vice versa.
    //! - Per-property `description`: byte-equal.
    //! - Base `type: "object"`.
    //! - No spurious top-level keys (e.g. legacy never had `required`;
    //!   schemars omits it for all-Option<T> requests).

    use super::*;
    use serde_json::Value;

    /// Resolve the schemars-derived `properties` object regardless of
    /// whether schemars emits it directly or under a `$ref`-resolved
    /// `definitions/.../properties` path. schemars 0.8 emits direct;
    /// 1.0 may relocate; this helper insulates downstream tests.
    fn derived_properties() -> serde_json::Map<String, Value> {
        let schema = CapabilitiesTool::input_schema();
        if let Some(props) = schema.get("properties").and_then(Value::as_object) {
            return props.clone();
        }
        if let Some(props) = schema
            .pointer("/definitions/CapabilitiesRequest/properties")
            .and_then(Value::as_object)
        {
            return props.clone();
        }
        panic!("schemars schema must emit properties at a known path; got {schema:#}")
    }

    /// Pull the legacy hand-coded `memory_capabilities` entry's
    /// `inputSchema.properties` map out of
    /// [`crate::mcp::registry::tool_definitions`]. This is the
    /// source-of-truth we're migrating away from in D1.6 (#987).
    fn legacy_properties() -> serde_json::Map<String, Value> {
        let defs = crate::mcp::registry::tool_definitions();
        let tools = defs
            .get("tools")
            .and_then(Value::as_array)
            .expect("tool_definitions must emit `tools` array");
        let cap = tools
            .iter()
            .find(|t| t.get("name").and_then(Value::as_str) == Some("memory_capabilities"))
            .expect("memory_capabilities must be in the legacy tool catalog");
        cap.pointer("/inputSchema/properties")
            .and_then(Value::as_object)
            .expect("memory_capabilities.inputSchema.properties must be an object")
            .clone()
    }

    #[test]
    fn capabilities_parity_property_set_983() {
        let legacy = legacy_properties();
        let derived = derived_properties();
        let legacy_keys: std::collections::BTreeSet<&str> =
            legacy.keys().map(String::as_str).collect();
        let derived_keys: std::collections::BTreeSet<&str> =
            derived.keys().map(String::as_str).collect();
        assert_eq!(
            legacy_keys,
            derived_keys,
            "schemars-derived schema must cover every legacy property; missing/extra: {:?}",
            legacy_keys
                .symmetric_difference(&derived_keys)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn no_reranker_handle_flips_cross_encoder_flag_1647() {
        // #1647 — a surface with no live reranker handle (the HTTP
        // daemon) must not advertise the tier preset's
        // cross_encoder_reranking; pre-fix the envelope was
        // self-contradictory (flag true beside reranker_active "off").
        let tier_config = crate::config::FeatureTier::Autonomous.config();
        let caps = build_capabilities_overlay(
            &tier_config,
            &crate::config::ResolvedModels::default(),
            None,
            false,
            None,
        );
        assert!(
            !caps.features.cross_encoder_reranking,
            "#1647: no handle ⇒ flag false"
        );
        assert_eq!(caps.features.reranker_active, RerankerMode::Off);
    }

    #[test]
    fn capabilities_parity_descriptions_983() {
        let legacy = legacy_properties();
        let derived = derived_properties();
        for (name, legacy_prop) in &legacy {
            let legacy_desc = legacy_prop.get("description").and_then(Value::as_str);
            let derived_desc = derived
                .get(name)
                .and_then(|p| p.get("description"))
                .and_then(Value::as_str);
            // Legacy property may not have a description (rare); only
            // assert when it does.
            if let Some(want) = legacy_desc {
                assert_eq!(
                    derived_desc,
                    Some(want),
                    "property `{name}`: legacy description must match the schemars-derived one byte-for-byte"
                );
            }
        }
    }

    #[test]
    fn capabilities_parity_top_level_object_983() {
        let schema = CapabilitiesTool::input_schema();
        assert_eq!(
            schema.get("type").and_then(Value::as_str),
            Some("object"),
            "top-level type must be `object`"
        );
    }

    #[test]
    fn capabilities_parity_no_required_fields_983() {
        let schema = CapabilitiesTool::input_schema();
        let required = schema.get("required");
        // Legacy entry doesn't carry `required`; schemars also omits
        // when every field is `Option<T>`. Either absent or empty
        // array is acceptable; a non-empty array is a regression.
        if let Some(arr) = required.and_then(Value::as_array) {
            assert!(
                arr.is_empty(),
                "schemars-derived schema must not require any field; got {arr:?}"
            );
        }
    }

    #[test]
    fn capabilities_parity_allowed_diffs_documented_983() {
        // Sanity-asserts the explicit allowed-diffs catalog. If the
        // schemars output structurally drifts away from the
        // documented set, this test pins the regression.
        let derived = derived_properties();
        // Each Option<T> property must have a nullable type AND a
        // null default. Both are byproducts of the Option<T> wrap.
        for name in &["accept", "family", "include_schema", "verbose"] {
            let prop = derived
                .get(*name)
                .unwrap_or_else(|| panic!("derived property `{name}` missing"));
            let type_value = prop.get("type").expect("each property has `type`");
            // Type is an array containing both the concrete type and "null".
            let arr = type_value
                .as_array()
                .unwrap_or_else(|| panic!("`{name}.type` must be an array (Option<T> nullable)"));
            assert!(
                arr.iter().any(|v| v.as_str() == Some("null")),
                "`{name}.type` must include `\"null\"` (Option<T> derive)"
            );
            assert_eq!(
                prop.get("default"),
                Some(&Value::Null),
                "`{name}.default` must be `null` (Option<T>::None)"
            );
        }
    }
}