openvtc-core 0.5.0

OpenVTC Core Library
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
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
/*!
 * Multi-community account model (config v2).
 *
 * Replaces the single-persona / single-VTA singleton with an `Account` that
 * owns a collection of [`PersonaRecord`]s and a collection of
 * [`CommunityRecord`]s. See `docs/design/multi-community-support.md` and
 * `docs/design/t1-active-identity-api.md`.
 *
 * Scope note: this module defines the **persisted metadata** model, stored
 * encrypted in the `ProtectedConfig` tier and treated by `Config::load_step2`
 * as the source of truth for the active persona. The account admin credential
 * (a secret) stays in `SecuredConfig`/keyring; persona key material is
 * VTA-managed (`key_refs` are non-secret ids, D12). Runtime resolution lives in
 * [`crate::identity`] (`IdentityContext` / `IdentityRegistry`).
 */

use crate::CredentialKind;
use crate::config::KeyTypes;
use crate::errors::OpenVTCError;
use crate::relationships::Relationships;
use crate::tasks::Tasks;
use crate::vrc::Vrcs;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use uuid::Uuid;

/// A VTC community is keyed by its DID (`did:webvh:...`).
pub type VtcDid = String;

/// Stable, rotation-safe identifier for a persona.
///
/// Decoupled from the persona's `did:webvh` (which can rotate) so that a
/// community's `persona_ref` survives DID rotation (fork resolution: stable
/// UUID).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PersonaId(pub Uuid);

impl PersonaId {
    /// Mint a fresh persona id.
    pub fn new() -> Self {
        PersonaId(Uuid::new_v4())
    }
}

impl Default for PersonaId {
    fn default() -> Self {
        PersonaId::new()
    }
}

impl std::fmt::Display for PersonaId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// A non-secret reference to a VTA-managed key (D12).
///
/// Key material lives at the VTA and is fetched at runtime; only the opaque
/// `key_id`, its purpose, and creation time are persisted locally.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyRef {
    /// Opaque VTA key identifier.
    pub key_id: String,
    /// What the key is used for.
    pub purpose: KeyTypes,
    /// When the key was created.
    pub created_at: DateTime<Utc>,
}

/// An account-level persona — a self-contained `did:webvh` identity that one or
/// more communities may present (D6: context-independent; D1: reusable).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PersonaRecord {
    /// Stable identifier (rotation-safe).
    pub persona_id: PersonaId,
    /// The persona's `did:webvh`.
    pub did: String,
    /// Cached resolved DID document (PERF #3: startup uses this instead of a
    /// fresh network resolve when present — did:webvh documents change rarely
    /// between launches, so a cached doc only goes stale if the persona rotated
    /// keys out-of-band). Persisted with the record; populated at mint/setup and
    /// whenever the persona is resolved. `None` for records minted before this
    /// field existed, in which case load falls back to a network resolve.
    #[serde(default)]
    pub did_document: Option<affinidi_tdk::did_common::Document>,
    /// Non-secret references to this persona's VTA-managed keys.
    pub key_refs: Vec<KeyRef>,
    /// Mediator DID; defaults to the VTA mediator, optional override at mint (D7).
    pub mediator_did: Option<String>,
    /// The VTA context the persona's keys and DID were minted in. A persona can
    /// only be presented from here, so joining a community with it shares this
    /// context ([`crate::config::community_context`]). Empty for a persona
    /// minted before per-community contexts, whose keys are in the account's
    /// top context.
    pub origin_context_id: String,
    /// When the persona was created.
    pub created_at: DateTime<Utc>,
    /// Optional human-friendly label.
    pub label: Option<String>,

    /// Fields written by a build newer than this one, preserved verbatim.
    ///
    /// **D19.** Without this, serde silently drops what it does not know, and a
    /// round trip through an older build is data loss: read a record, discard
    /// the new fields, write it back without them. Harmless while a single
    /// writer owns the config — and exactly why it has never bitten — but the
    /// moment this record is shared with another instance (E2), an older build
    /// would quietly strip a newer one's work.
    ///
    /// Carried, never interpreted. `skip_serializing_if` keeps it off the wire
    /// when empty so existing configs are byte-identical.
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Lifecycle state of a community membership (D8). Only [`Active`] is live; all
/// other states are read-only (D14).
///
/// [`Active`]: CommunityStatus::Active
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CommunityStatus {
    /// Join request submitted, awaiting the VTC's decision.
    Pending {
        /// The join request id from the submit receipt.
        request_id: Uuid,
    },
    /// Member in good standing (the only live state).
    Active,
    /// Member voluntarily left (`MEMBER_SELF_REMOVE`).
    Left,
    /// Applicant cancelled a `Pending` join before the VTC decided — the
    /// request is withdrawn. A voluntary, user-chosen outcome (like `Left`), so
    /// it never raises the actions-required badge. The protocol's status
    /// vocabulary calls this `withdrawn`.
    Withdrawn,
    /// Join request denied by the VTC.
    Rejected,
    /// Member removed by the VTC (involuntary).
    Removed,
    /// Pending join unanswered past the 7-day client timeout (D16).
    Expired,
}

impl CommunityStatus {
    /// True only for [`Active`](CommunityStatus::Active) — the single live state.
    pub fn is_active(&self) -> bool {
        matches!(self, CommunityStatus::Active)
    }

    /// True for every non-[`Active`](CommunityStatus::Active) state (read-only, D14).
    pub fn is_read_only(&self) -> bool {
        !self.is_active()
    }

    /// True for terminal/inactive states eligible for archive or delete (R-C-8):
    /// `Left`, `Withdrawn`, `Rejected`, `Removed`, `Expired`. (`Pending` is not —
    /// it is still in flight; cancelling it transitions to `Withdrawn` first.)
    pub fn is_inactive(&self) -> bool {
        matches!(
            self,
            CommunityStatus::Left
                | CommunityStatus::Withdrawn
                | CommunityStatus::Rejected
                | CommunityStatus::Removed
                | CommunityStatus::Expired
        )
    }

    /// The set of *statuses* that can raise the actions-required indicator
    /// (R-C-3 / R-S-2): `Pending` and the terminal `Rejected` / `Removed` /
    /// `Expired`. This is status-only; the acknowledgement-aware,
    /// per-membership predicate is [`CommunityRecord::needs_attention`], which
    /// layers the `acknowledged` flag on top.
    pub fn needs_attention(&self) -> bool {
        matches!(
            self,
            CommunityStatus::Pending { .. }
                | CommunityStatus::Rejected
                | CommunityStatus::Removed
                | CommunityStatus::Expired
        )
    }

    /// True when the membership needs a live DIDComm session: `Active` (to
    /// operate) and `Pending` (so the VTC's join reply is receivable, D16).
    pub fn requires_live_session(&self) -> bool {
        matches!(
            self,
            CommunityStatus::Active | CommunityStatus::Pending { .. }
        )
    }
}

/// Why a membership ended, as the deciding community stated it.
///
/// Populated on the two terminal transitions a *community* drives —
/// [`CommunityRecord::reject`] (join denied) and [`CommunityRecord::remove`]
/// (member removed) — from whatever the wire carried. It is the member's own
/// account of an outcome they did not choose, kept so a rejected or removed
/// membership can say *by whom*, *why*, and *when* rather than only *that* it
/// ended.
///
/// Every field is optional because the inbound paths differ in what they
/// expose: a verdict or a polled rejection carries `code`/`reason`, a removal
/// notice adds `decided_by`/`decided_at`/`disposition`, and the oldest poll
/// path carried nothing at all. An all-absent value is a deliberate "no reason
/// given" (proposal 4) — an absence the operator can read, distinct from a
/// record that predates this field.
///
/// **`decided_by` is not independently verifiable on a join rejection.** The
/// decision document is unsigned and its `issuer` is echoed from a field the
/// *applicant* set, so only a removal notice — which names the deciding
/// administrator on the wire — populates this. It is stated authority, not
/// attested authority; the doc comments on the wire types say the same.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DecisionEvidence {
    /// Stable machine code for the decision — a join policy's own code on an
    /// auto-deny, `admin-reject` when a human denied a join, or a removal
    /// notice's `adminRemoved` / `purged`. `None` when the path carried none.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
    /// The operator's stated reason, verbatim. `None` — never `""` — when none
    /// was given: an omitted reason and an empty one are different claims, and
    /// keeping them distinct is the whole point of rendering "no reason given"
    /// rather than a blank.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    /// The deciding authority's DID, when the wire names one. A removal notice
    /// names the deciding administrator (`decidedBy`); a join rejection does
    /// not, so this stays `None` there rather than being guessed from the
    /// unsigned, applicant-influenced `issuer`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub decided_by: Option<String>,
    /// When the decision was taken — distinct from when we recorded it
    /// ([`CommunityRecord::receipt_at`]), which for an offline member can lag
    /// the decision arbitrarily. `None` when the path carried no decision time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub decided_at: Option<DateTime<Utc>>,
    /// How the community handled our published record on removal
    /// (`purge` / `tombstone` / `historical`). Removal-only; `None` for a join
    /// rejection, which leaves no member record to dispose of.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub disposition: Option<String>,
}

impl DecisionEvidence {
    /// True when the community told us nothing beyond the bare outcome — no
    /// code, reason, authority, time or disposition. Drives the "no reason
    /// given" rendering, and is what a record written before this field existed
    /// (`decision: None`) is treated as.
    pub fn is_empty(&self) -> bool {
        self.code.is_none()
            && self.reason.is_none()
            && self.decided_by.is_none()
            && self.decided_at.is_none()
            && self.disposition.is_none()
    }
}

/// Which identifier form a community declares it expects members to use when
/// issuing relationship credentials (`relationshipIdentifierDefault` on the
/// community profile; issue #241).
///
/// `Attributed` means the member's persona/membership DID, so a relationship
/// edge names them; `Pairwise` means a Relationship DID unique to each
/// counterparty. It is a **declaration, not an enforcement**: the member still
/// chooses per relationship, and a community that wants to *require* a form does
/// so in its own policy. OpenVTC uses it only to seed the new-relationship
/// form's default (still toggle-able).
///
/// An absent value on the wire is *not* this enum's problem: the field on
/// [`CommunityRecord`] is `Option<Self>`, and `None` (unread or undeclared)
/// means "default to `Pairwise`", matching the DTG Credentials recommendation
/// the spec cites.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RelationshipIdentifierDefault {
    /// Relationships default to the persona DID — correlatable edges, which a
    /// public community that wants a legible graph asks for.
    Attributed,
    /// Relationships default to a per-counterparty Relationship DID (the
    /// codebase-wide default when nothing is declared).
    Pairwise,
}

/// A community membership — one per State-B join, referencing an account persona.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(from = "CommunityRecordShadow")]
pub struct CommunityRecord {
    /// The community's VTC DID.
    pub vtc_did: VtcDid,
    /// Display name resolved from the VTC DID document, if available.
    pub display_name: Option<String>,
    /// Sub-context id under the account's top context (`<top>/<slug>`, D9).
    pub sub_context_id: String,
    /// Which account persona is presented to this VTC (must resolve, R-P-1).
    pub persona_ref: PersonaId,
    /// Membership lifecycle state.
    pub status: CommunityStatus,
    /// User-starred favourite (sorts to top; R-C-4).
    #[serde(default)]
    pub favourite: bool,
    /// User-archived (hidden from the default list; R-C-8).
    #[serde(default)]
    pub archived: bool,
    /// Whether the user has acknowledged a terminal outcome
    /// (`Rejected`/`Removed`/`Expired`), clearing the actions-required badge
    /// (R-C-3 / R-S-2). Reset whenever the membership returns to a live state.
    #[serde(default)]
    pub acknowledged: bool,
    /// Set when the membership first becomes `Active` (member-since; R-C-2).
    pub member_since: Option<DateTime<Utc>>,
    /// When the join request was submitted — anchors the 7-day timeout (D16).
    pub requested_at: Option<DateTime<Utc>>,
    /// When the VTC first acknowledged the join (any correlated response: verdict
    /// refer/request_more, status deferred, recoverable trust-task-error, or the
    /// legacy submit-receipt). `Some` means the submit reached the VTC and is
    /// awaiting a decision; `None` while still `Pending` past the grace window
    /// means the submit may have been dropped (size limit / unhandled type) —
    /// surfaced in the UI so it isn't mistaken for a healthy wait (D16).
    #[serde(default)]
    pub receipt_at: Option<DateTime<Utc>>,
    /// When the VTC first told us it **approved** the join (a `status` poll
    /// answering `approved`).
    ///
    /// Distinct from [`receipt_at`](Self::receipt_at), which any correlated
    /// reply sets. The membership still becomes `Active` only when its
    /// credential arrives and verifies; this is what lets the UI say "approved,
    /// credential not received" rather than "may not have been received", and
    /// what entitles a member to ask the community to renew before it has.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approved_at: Option<DateTime<Utc>>,
    /// What the community last said about delivering this approved join's
    /// credentials (`join-requests/status/0.1`, trust-tasks-tf #709): whether
    /// it holds our acknowledgement, and its answer to the last
    /// `resendCredentials` we sent. Drives when we ask again, and what the row
    /// says. `None` until an `approved` reply reports delivery.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credential_delivery: Option<CredentialDelivery>,
    /// Which transport carried the join submit.
    ///
    /// Recorded so an unacknowledged join can say *which* transport went
    /// unanswered. Without it the warning reads identically whether the VTC
    /// ignored us, the mediator dropped the frame, or the peer could not
    /// decode the transport it advertised — which is exactly the ambiguity
    /// that cost a night of log-reading against a VTC advertising `#tsp` that
    /// its binary could not speak.
    ///
    /// `None` on records written before this existed, and on any future path
    /// that submits without going through the join flow.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub submit_transport: Option<crate::didcomm::MessagingTransport>,
    /// Whether the `Pending` request id is the **community's** id rather than
    /// our submit-time placeholder.
    ///
    /// A join is recorded against the id of the request document we sent,
    /// because that is the only handle we have until the VTC answers; the VTC
    /// mints its own (`Uuid::new_v4()`) and tells us in the first correlated
    /// reply. Which of the two the record currently holds decides whether the
    /// community can be *asked* about this join
    /// ([`crate::join::poll_join_status`]) — a poll quoting our placeholder is
    /// a request the VTC has never heard of, and is answered as such.
    ///
    /// `false` for a record written before this existed: those are never polled,
    /// which is the safe reading (we cannot tell whose id they hold).
    #[serde(default)]
    pub request_id_confirmed: bool,
    /// DIDComm relationships scoped to this community.
    #[serde(default)]
    pub relationships: Relationships,
    /// Reserved per-community inbox (protocol-workflow tasks). The eventual home
    /// for a physically per-community inbox; **not yet populated** — PR-1 scopes
    /// the main page by attribution (relationships/tasks carry an owning-persona
    /// tag and are filtered to the working community) while the collections stay
    /// in the global `ProtectedConfig` tier. Additive + serde(default)-tolerant
    /// so older configs load and a later physical-move migration can fill it.
    #[serde(default)]
    pub tasks: Tasks,
    /// VRCs we have issued within this community.
    #[serde(default)]
    pub vrcs_issued: Vrcs,
    /// VRCs we have received within this community.
    #[serde(default)]
    pub vrcs_received: Vrcs,
    /// Verifiable credentials this VTC has issued to us, keyed by
    /// [`CredentialKind`]. The membership credential (VMC) lands here on
    /// admission and activates the membership; the role credential (a
    /// community VAC) arrives alongside. Stored as the signed W3C VC JSON. Empty
    /// until the join is accepted and credentials arrive (R-B-8).
    ///
    /// Only conformant DTG credentials are held here: one stored by an earlier
    /// build in a pre-v1 shape is moved to
    /// [`retired_credentials`](Self::retired_credentials) on load.
    ///
    /// Persisted as a JSON object keyed by [`CredentialKind::config_key`].
    /// Configs written before R19 used flat `membership_credential` /
    /// `role_credential` fields; `CommunityRecordShadow` folds those in on
    /// load so older configs keep working.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub credentials: BTreeMap<CredentialKind, serde_json::Value>,

    /// The membership credential **we** issued to this community — the
    /// member → community half of the membership edge, acknowledging the grant
    /// in [`credentials`](Self::credentials).
    ///
    /// Kept rather than sent and forgotten. Three things need it:
    ///
    /// 1. **Answering "did I consent to this?"** This is the member's own
    ///    consent artifact, and it was the one credential in the exchange that
    ///    nothing on this side could show.
    /// 2. **Re-sending.** Without a copy, "send it again" means minting a
    ///    *different* credential with a different `id` — which the community
    ///    reads as a renewal, not a re-send.
    /// 3. **Knowing whether the edge still stands.** Its `digest` names one
    ///    grant. Once the community re-issues, this no longer matches and the
    ///    member owes a fresh acknowledgement — visible here, and nowhere else.
    ///
    /// Stored as the signed W3C VC JSON, exactly as it went out. `None` until
    /// we have issued one, and on configs written before this field existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub member_vmc: Option<serde_json::Value>,

    /// Why this membership ended, when the community told us — set by
    /// [`reject`](Self::reject) and [`remove`](Self::remove). `None` for a
    /// membership that never reached a community-driven terminal state, and for
    /// records written before this field existed (read as "no reason given").
    /// See [`DecisionEvidence`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub decision: Option<DecisionEvidence>,

    /// The identifier form this community declares it prefers for relationship
    /// credentials (issue #241), read from its community profile
    /// (`vtc/community/profile/show`). Seeds the new-relationship form's default;
    /// `None` (unread or undeclared) means "default to pairwise". See
    /// [`RelationshipIdentifierDefault`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relationship_identifier_default: Option<RelationshipIdentifierDefault>,

    /// Credentials this membership held that do not conform to the current DTG
    /// Credentials specification — the retired pre-v1 context, no
    /// `issuerScope`, a retired type such as the pre-v1 role endorsement —
    /// and were set aside on load. See [`RetiredCredential`].
    ///
    /// The credential itself is not kept: a non-conformant credential is not
    /// one this client may present or answer, and holding it would only invite
    /// that. What is kept is enough to tell the member what went and what to do.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub retired_credentials: Vec<RetiredCredential>,

    /// Fields written by a newer build, preserved verbatim (D19). See
    /// [`Account::extra`] for why.
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Deserialize-only shadow of [`CommunityRecord`] that folds pre-R19
/// `membership_credential` / `role_credential` fields into the typed
/// [`credentials`](CommunityRecord::credentials) registry, so older configs
/// keep loading (the project tolerates format evolution via shims, not
/// migrations). New configs deserialize straight through the `credentials`
/// object. Unknown credential keys (e.g. written by a newer version) are
/// dropped rather than failing the whole config load.
#[derive(Deserialize)]
struct CommunityRecordShadow {
    vtc_did: VtcDid,
    display_name: Option<String>,
    sub_context_id: String,
    persona_ref: PersonaId,
    status: CommunityStatus,
    #[serde(default)]
    favourite: bool,
    #[serde(default)]
    archived: bool,
    #[serde(default)]
    acknowledged: bool,
    member_since: Option<DateTime<Utc>>,
    requested_at: Option<DateTime<Utc>>,
    #[serde(default)]
    receipt_at: Option<DateTime<Utc>>,
    #[serde(default)]
    approved_at: Option<DateTime<Utc>>,
    #[serde(default)]
    credential_delivery: Option<CredentialDelivery>,
    #[serde(default)]
    submit_transport: Option<crate::didcomm::MessagingTransport>,
    #[serde(default)]
    request_id_confirmed: bool,
    #[serde(default)]
    relationships: Relationships,
    #[serde(default)]
    tasks: Tasks,
    #[serde(default)]
    vrcs_issued: Vrcs,
    #[serde(default)]
    vrcs_received: Vrcs,
    // Keyed by `String`, not `CredentialKind`, on purpose: this is the
    // durability boundary. A strict `CredentialKind` key would make an
    // unrecognised kind (e.g. from a newer build) a fatal whole-config load
    // error; the `From` impl below instead drops unknown keys with a warning.
    #[serde(default)]
    credentials: BTreeMap<String, serde_json::Value>,
    #[serde(default)]
    member_vmc: Option<serde_json::Value>,
    #[serde(default)]
    decision: Option<DecisionEvidence>,
    #[serde(default)]
    relationship_identifier_default: Option<RelationshipIdentifierDefault>,
    #[serde(default)]
    retired_credentials: Vec<RetiredCredential>,
    // Legacy pre-R19 flat fields, folded into `credentials` below.
    #[serde(default)]
    membership_credential: Option<serde_json::Value>,
    #[serde(default)]
    role_credential: Option<serde_json::Value>,
    // D19: the catch-all has to live HERE, not on `CommunityRecord`. The shadow
    // deserializes first, so anything it does not name is gone before the real
    // type is built.
    #[serde(flatten, default)]
    extra: serde_json::Map<String, serde_json::Value>,
}

impl From<CommunityRecordShadow> for CommunityRecord {
    fn from(shadow: CommunityRecordShadow) -> Self {
        // New-format `credentials` keys win; unknown keys are dropped (a newer
        // version may persist kinds this build doesn't know).
        let mut credentials: BTreeMap<CredentialKind, serde_json::Value> = BTreeMap::new();
        for (key, vc) in shadow.credentials {
            match CredentialKind::from_config_key(&key) {
                Some(kind) => {
                    credentials.insert(kind, vc);
                }
                None => tracing::warn!(
                    credential_kind = %key,
                    "dropping unknown stored credential kind",
                ),
            }
        }
        // Fold legacy flat fields in without clobbering a new-format value.
        if let Some(vmc) = shadow.membership_credential {
            credentials.entry(CredentialKind::Membership).or_insert(vmc);
        }
        if let Some(role) = shadow.role_credential {
            credentials.entry(CredentialKind::Role).or_insert(role);
        }
        // Hold every stored credential to the current specification. A pre-v1
        // one is set aside with its reason rather than kept (a verifier would
        // refuse it) or allowed to fail the load (one stale credential must not
        // lock the member out of their config).
        let mut retired_credentials = shadow.retired_credentials;
        let now = Utc::now();
        credentials.retain(|kind, vc| match crate::dtg::nonconformance(vc) {
            None => true,
            Some(reason) => {
                RetiredCredential::record(
                    &mut retired_credentials,
                    kind.config_key(),
                    vc,
                    reason,
                    now,
                );
                false
            }
        });
        let member_vmc = match shadow.member_vmc {
            Some(vc) => match crate::dtg::nonconformance(&vc) {
                None => Some(vc),
                Some(reason) => {
                    RetiredCredential::record(
                        &mut retired_credentials,
                        RetiredCredential::MEMBER_ACKNOWLEDGEMENT,
                        &vc,
                        reason,
                        now,
                    );
                    None
                }
            },
            None => None,
        };
        CommunityRecord {
            // D19: carry forward whatever a newer build wrote.
            extra: shadow.extra,
            vtc_did: shadow.vtc_did,
            display_name: shadow.display_name,
            sub_context_id: shadow.sub_context_id,
            persona_ref: shadow.persona_ref,
            status: shadow.status,
            favourite: shadow.favourite,
            archived: shadow.archived,
            acknowledged: shadow.acknowledged,
            member_since: shadow.member_since,
            requested_at: shadow.requested_at,
            receipt_at: shadow.receipt_at,
            approved_at: shadow.approved_at,
            credential_delivery: shadow.credential_delivery,
            submit_transport: shadow.submit_transport,
            request_id_confirmed: shadow.request_id_confirmed,
            relationships: shadow.relationships,
            tasks: shadow.tasks,
            vrcs_issued: shadow.vrcs_issued,
            vrcs_received: shadow.vrcs_received,
            credentials,
            member_vmc,
            decision: shadow.decision,
            relationship_identifier_default: shadow.relationship_identifier_default,
            retired_credentials,
        }
    }
}

/// A credential a membership held that was set aside on load because it does
/// not conform to the current DTG Credentials specification.
///
/// # Why credentials are set aside, not migrated
///
/// DTG Credentials v1 makes every credential issued before it non-conformant:
/// the context changed (`https://registry.trustoverip.org/dtg/context/v1`, with
/// no alias for the old one), `issuerScope` became REQUIRED, and the role endorsement
/// credential was retired in favour of a community VAC. Every
/// digest changed with them. A stored credential cannot be rewritten into the
/// new shape — its proof covers the old bytes, and only its issuer can sign new
/// ones — so the only honest options were to keep it (and present something
/// every current verifier refuses) or to drop it. It is dropped, **explicitly**:
///
/// - a `warn!` naming the membership, the kind and the parse error is logged;
/// - this record is persisted on the membership, so the member sees what went
///   and why in the Communities view, which offers to renew the membership
///   ([`crate::renewal`], `vtc/members/renew`: the community re-issues the VMC
///   and role VAC, and a fresh acknowledgement goes back);
/// - a conformant credential of the same kind arriving later clears it
///   ([`CommunityRecord::clear_retired`]).
///
/// The member's own acknowledgement (`member_vmc`) is held to the same rule; it
/// digests the grant it acknowledges, so once the grant is re-issued a fresh one
/// is owed anyway.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetiredCredential {
    /// [`CredentialKind::config_key`] of what it was stored as, or
    /// [`Self::MEMBER_ACKNOWLEDGEMENT`] for our own acknowledgement.
    pub kind: String,
    /// The credential's `id`, when it had one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credential_id: Option<String>,
    /// Why it does not conform, as `dtg-credentials` put it.
    pub reason: String,
    /// When it was set aside.
    pub retired_at: DateTime<Utc>,
}

impl RetiredCredential {
    /// [`Self::kind`] of the member-issued acknowledgement VMC.
    pub const MEMBER_ACKNOWLEDGEMENT: &'static str = "Acknowledgement";

    fn record(
        into: &mut Vec<RetiredCredential>,
        kind: &str,
        vc: &serde_json::Value,
        reason: String,
        now: DateTime<Utc>,
    ) {
        let credential_id = vc
            .get("id")
            .and_then(serde_json::Value::as_str)
            .map(str::to_string);
        tracing::warn!(
            credential_kind = %kind,
            credential_id = credential_id.as_deref().unwrap_or("-"),
            %reason,
            "setting aside a stored credential that does not conform to DTG Credentials v1 — \
             the community must re-issue it"
        );
        if into
            .iter()
            .any(|r| r.kind == kind && r.credential_id == credential_id)
        {
            return;
        }
        into.push(RetiredCredential {
            kind: kind.to_string(),
            credential_id,
            reason,
            retired_at: now,
        });
    }
}

/// Client-side timeout for an unanswered `Pending` join (D16 / R-B-7): a join
/// request with no decision after this many days transitions to `Expired`.
pub const PENDING_TIMEOUT_DAYS: i64 = 7;

/// Grace period before a `Pending` join with no VTC acknowledgement
/// ([`CommunityRecord::receipt_at`] still `None`) is flagged as possibly-dropped.
/// A verdict/receipt normally returns within seconds; 2 minutes absorbs a slow
/// but healthy round-trip while surfacing a dropped submit (size limit / unhandled
/// type) far earlier than the 7-day [`PENDING_TIMEOUT_DAYS`].
pub const PENDING_ACK_GRACE_SECS: i64 = 120;

impl CommunityRecord {
    /// Clear the [`RetiredCredential`] notices of `kind` (a
    /// [`CredentialKind::config_key`] or
    /// [`RetiredCredential::MEMBER_ACKNOWLEDGEMENT`]) once a conformant
    /// replacement has been stored.
    pub fn clear_retired(&mut self, kind: &str) {
        self.retired_credentials.retain(|r| r.kind != kind);
    }

    /// Build a fresh `Pending` join record (State-B join request, R-B-*).
    ///
    /// `request_id` correlates the VTC's asynchronous accept/reject decision
    /// (R-B-8); `requested_at` is stamped with `now` to anchor the 7-day timeout
    /// (D16). Starts unfavourited, unarchived, unacknowledged, with empty
    /// community-scoped relationship/VRC stores.
    pub fn new_pending(
        vtc_did: VtcDid,
        display_name: Option<String>,
        sub_context_id: String,
        persona_ref: PersonaId,
        request_id: Uuid,
        now: DateTime<Utc>,
    ) -> Self {
        CommunityRecord {
            extra: serde_json::Map::new(),
            vtc_did,
            display_name,
            sub_context_id,
            persona_ref,
            status: CommunityStatus::Pending { request_id },
            favourite: false,
            archived: false,
            acknowledged: false,
            member_since: None,
            requested_at: Some(now),
            receipt_at: None,
            approved_at: None,
            credential_delivery: None,
            // Set by the join flow once it knows which transport carried the
            // submit; `new_pending` itself is transport-agnostic.
            submit_transport: None,
            // `request_id` here is the id of the document we just sent. It only
            // becomes the community's once a reply says so — see
            // `confirm_request_id`.
            request_id_confirmed: false,
            relationships: Relationships::default(),
            tasks: Tasks::default(),
            vrcs_issued: Vrcs::default(),
            vrcs_received: Vrcs::default(),
            credentials: BTreeMap::new(),
            member_vmc: None,
            decision: None,
            // Unknown until the community's profile is read (issue #241);
            // `None` seeds the pairwise default.
            relationship_identifier_default: None,
            retired_credentials: Vec::new(),
        }
    }

    /// True for a membership that needs a live DIDComm session (Active or
    /// Pending) — so the VTC's asynchronous join reply is receivable (D16).
    pub fn is_live(&self) -> bool {
        self.status.requires_live_session()
    }

    /// Transition to `Active` on acceptance (R-B-8). Stamps `member_since` with
    /// `now` the first time the membership becomes active (R-C-2); leaves an
    /// existing timestamp untouched so a re-activation keeps the original date.
    /// Returning to a live state clears any prior acknowledgement (R-S-2).
    pub fn activate(&mut self, now: DateTime<Utc>) {
        if self.member_since.is_none() {
            self.member_since = Some(now);
        }
        self.status = CommunityStatus::Active;
        self.acknowledged = false;
    }

    /// Transition to `Rejected` — the VTC denied the join request (R-B-8). A
    /// fresh terminal outcome starts unacknowledged so it raises the
    /// actions-required badge until the user clears it (R-S-2).
    ///
    /// `evidence` records why, as the community stated it (issue #240); pass
    /// [`DecisionEvidence::default`] when the path carried none, which renders
    /// "no reason given" rather than a blank.
    pub fn reject(&mut self, evidence: DecisionEvidence) {
        self.status = CommunityStatus::Rejected;
        self.acknowledged = false;
        self.decision = Some(evidence);
    }

    /// Transition to `Removed` — the VTC removed an active member (R-B-8). Starts
    /// unacknowledged (R-S-2).
    ///
    /// `evidence` records why, as carried by the removal notice
    /// (`vtc/members/removal-notice/0.1`); pass [`DecisionEvidence::default`]
    /// when none was given.
    pub fn remove(&mut self, evidence: DecisionEvidence) {
        self.status = CommunityStatus::Removed;
        self.acknowledged = false;
        self.decision = Some(evidence);
    }

    /// Transition to `Left` — the member voluntarily left (R-L-1). `Left` never
    /// raises the actions-required badge (the user chose to leave).
    pub fn leave(&mut self) {
        self.status = CommunityStatus::Left;
        self.acknowledged = false;
    }

    /// Transition to `Withdrawn` — the applicant cancelled a `Pending` join
    /// before the VTC decided. Like `Left`, a voluntary outcome that never
    /// raises the actions-required badge. The record becomes inactive, so it can
    /// then be deleted or re-joined. No-op (returns `false`) unless currently
    /// `Pending`; callers gate the action to pending rows, and this re-checks so
    /// a stray call can't withdraw an active/terminal membership.
    pub fn withdraw(&mut self) -> bool {
        if !matches!(self.status, CommunityStatus::Pending { .. }) {
            return false;
        }
        self.status = CommunityStatus::Withdrawn;
        self.acknowledged = false;
        true
    }

    /// Acknowledge a terminal outcome (`Rejected`/`Removed`/`Expired`), clearing
    /// the actions-required badge for this community (R-S-2). No effect on the
    /// `Pending` badge, which only clears when the request resolves.
    pub fn acknowledge(&mut self) {
        self.acknowledged = true;
    }

    /// Whether this community raises the actions-required indicator (R-C-3):
    /// `Pending` always (a decision is awaited), or an **unacknowledged**
    /// terminal outcome `Rejected`/`Removed`/`Expired` (R-S-2). `Active` and
    /// `Left` never do.
    pub fn needs_attention(&self) -> bool {
        match self.status {
            CommunityStatus::Pending { .. } => true,
            CommunityStatus::Rejected | CommunityStatus::Removed | CommunityStatus::Expired => {
                !self.acknowledged
            }
            CommunityStatus::Active | CommunityStatus::Left | CommunityStatus::Withdrawn => false,
        }
    }

    /// Toggle the favourite/star flag (R-C-4). Returns the new value.
    pub fn toggle_favourite(&mut self) -> bool {
        self.favourite = !self.favourite;
        self.favourite
    }

    /// Whether this membership may be archived or deleted (R-C-8): only an
    /// **inactive** one (`Left`/`Rejected`/`Removed`/`Expired`). An active or
    /// pending membership must be left first.
    pub fn can_archive_or_delete(&self) -> bool {
        self.status.is_inactive()
    }

    /// Expire a stale `Pending` join past the [`PENDING_TIMEOUT_DAYS`] client
    /// timeout (R-B-7 / D16). No-op unless the membership is currently `Pending`
    /// with a `requested_at` at least the timeout old. Returns `true` if it
    /// transitioned to `Expired`.
    pub fn expire_if_stale(&mut self, now: DateTime<Utc>) -> bool {
        self.expire_if_stale_after(now, TimeDelta::days(PENDING_TIMEOUT_DAYS))
    }

    /// [`Self::expire_if_stale`] with the timeout given — a community's
    /// published `decisionSla` for a vetted join, which may be longer or shorter
    /// than the client default.
    pub fn expire_if_stale_after(&mut self, now: DateTime<Utc>, timeout: TimeDelta) -> bool {
        if matches!(self.status, CommunityStatus::Pending { .. })
            && let Some(requested) = self.requested_at
            && now - requested >= timeout
        {
            self.status = CommunityStatus::Expired;
            self.acknowledged = false;
            return true;
        }
        false
    }

    /// Record that the VTC has acknowledged this join — i.e. *some* correlated
    /// response arrived (verdict refer/request_more, status deferred, a
    /// recoverable trust-task-error, or the legacy submit-receipt). Stamps
    /// [`receipt_at`](Self::receipt_at) once with `now`; subsequent calls are
    /// no-ops (the first contact is what matters). Returns `true` if it set the
    /// timestamp (the caller should persist). Only meaningful while `Pending`.
    pub fn mark_acknowledged(&mut self, now: DateTime<Utc>) -> bool {
        if self.receipt_at.is_none() {
            self.receipt_at = Some(now);
            return true;
        }
        false
    }

    /// Adopt the community's own request id for a still-`Pending` join, replacing
    /// the submit-time placeholder.
    ///
    /// Every correlated VTC reply carries it — the submit-receipt, and the
    /// verdict envelope (`VerdictResponse.requestId`) that a `refer` /
    /// `request_more` arrives in. Adopting it from *whichever* lands first is
    /// what makes a join that is parked for human review askable-about later:
    /// the id is the only thing `join-requests/status` can name.
    ///
    /// Returns `true` if the record changed (the caller should persist). A no-op
    /// once the id is confirmed and unchanged, and on a non-`Pending` record —
    /// a terminal state has nothing left to poll for.
    pub fn confirm_request_id(&mut self, request_id: Uuid) -> bool {
        let CommunityStatus::Pending { request_id: held } = &self.status else {
            return false;
        };
        if *held == request_id && self.request_id_confirmed {
            return false;
        }
        self.status = CommunityStatus::Pending { request_id };
        self.request_id_confirmed = true;
        true
    }

    /// Whether the community can be asked about this join. Any `Pending` record
    /// qualifies.
    ///
    /// This used to also require [`Self::request_id_confirmed`], because the
    /// poll had to quote the community's own id and our placeholder would be
    /// answered `not found`. That made the mechanism unusable in the one case it
    /// exists for: a join whose first correlated reply was lost is exactly the
    /// join whose id we never learned, so it could never be asked about and sat
    /// `Pending` for good. The other recovery — collecting stored mail — is
    /// empty once that mail has been acked and deleted, so both failed together
    /// (#221).
    ///
    /// The community now answers an id-less poll from the authenticated
    /// applicant (`join-requests/status/0.1` with no `requestId`), and its reply
    /// carries the id, so an unconfirmed record repairs itself on the first
    /// answer. `request_id_confirmed` still decides *what we send* — see
    /// [`Account::pollable_pending`] — it just no longer decides whether we may
    /// ask at all.
    ///
    /// [`Account::pollable_pending`]: crate::config::account::Account::pollable_pending
    pub fn is_pollable_pending(&self) -> bool {
        matches!(self.status, CommunityStatus::Pending { .. })
    }

    /// Record that the VTC approved this join, once. Returns whether anything
    /// changed, so the caller knows whether to persist.
    pub fn mark_approved(&mut self, now: DateTime<Utc>) -> bool {
        if self.approved_at.is_none() {
            self.approved_at = Some(now);
            return true;
        }
        false
    }

    /// A `Pending` join the VTC has approved whose membership credential has
    /// not arrived — the membership is decided, and delivery is what is
    /// missing.
    pub fn approved_awaiting_credential(&self) -> bool {
        matches!(self.status, CommunityStatus::Pending { .. })
            && self.approved_at.is_some()
            && !self
                .credentials
                .contains_key(&crate::CredentialKind::Membership)
    }

    /// Whether the join was submitted over TSP — the transport the member
    /// verbs that follow it ([`crate::members::Delivery`]) take too.
    pub fn joined_over_tsp(&self) -> bool {
        self.submit_transport == Some(crate::didcomm::MessagingTransport::Tsp)
    }

    /// Whether the member may ask the community to renew this membership
    /// (`vtc/members/renew/0.1`): an `Active` one, or a join the community has
    /// approved whose credential never arrived. The second is the manual
    /// rescue for a lost delivery — the community already holds the member on
    /// its list, which is all `renew` asks.
    pub fn can_renew(&self) -> bool {
        self.status.is_active() || self.approved_awaiting_credential()
    }

    /// Whether the next status poll should carry `resendCredentials`.
    ///
    /// Only for an approved join still waiting on its credential, only after
    /// [`CREDENTIAL_RESEND_GRACE_SECS`] — a delivery takes a moment — and only
    /// once the community has itself reported `credentialsDelivered: false`.
    /// That report is what proves it supports re-delivery: a community that
    /// predates the member refuses a poll carrying it as malformed, which would
    /// stop the poll answering at all.
    ///
    /// After that the community's own answer paces us: a `retryAfter` is waited
    /// out, a `queued` re-delivery is given [`CREDENTIAL_RESEND_GRACE_SECS`] to
    /// land, and `notNeeded` or an exhausted limit ends the asking. The poll is
    /// already backed off, so this never asks more often than the poll runs,
    /// and the poller caps how many polls carry it.
    pub fn wants_credential_resend(&self, now: DateTime<Utc>) -> bool {
        if !self.approved_awaiting_credential() {
            return false;
        }
        let grace = TimeDelta::seconds(CREDENTIAL_RESEND_GRACE_SECS);
        if self.approved_at.is_none_or(|at| now - at < grace) {
            return false;
        }
        let Some(delivery) = &self.credential_delivery else {
            return false;
        };
        if delivery.delivered {
            return false;
        }
        match delivery.last_answer {
            None => true,
            Some(CredentialResendAnswer::Queued { at }) => now - at >= grace,
            Some(CredentialResendAnswer::RateLimited {
                retry_after: Some(t),
            }) => now >= t,
            Some(
                CredentialResendAnswer::RateLimited { retry_after: None }
                | CredentialResendAnswer::NotNeeded,
            ) => false,
        }
    }

    /// Whether this is a `Pending` join the VTC has not acknowledged within
    /// [`PENDING_ACK_GRACE_SECS`] of submission — the signal that the submit may
    /// have been dropped (size limit / unhandled type) rather than healthily
    /// awaiting a decision. False once any response has set `receipt_at`, for
    /// non-`Pending` states, or while still inside the grace window.
    pub fn pending_unacknowledged(&self, now: DateTime<Utc>) -> bool {
        matches!(self.status, CommunityStatus::Pending { .. })
            && self.receipt_at.is_none()
            && self
                .requested_at
                .is_some_and(|r| now - r >= TimeDelta::seconds(PENDING_ACK_GRACE_SECS))
    }
}

/// One `Pending` join that can be reconciled with its community, flattened out
/// of the record so the poll can be driven without holding a `Config` borrow
/// across the network I/O.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingPoll {
    pub vtc_did: VtcDid,
    pub persona_ref: PersonaId,
    /// The **community's** request id when we hold it, `None` when we still
    /// hold our own submit-time placeholder (see
    /// [`CommunityRecord::request_id_confirmed`]).
    ///
    /// `None` is not a degraded poll — it asks the community "what is my open
    /// request?", which it answers from the authenticated applicant, and the
    /// answer carries the id. So the record repairs itself on the first reply
    /// and every later poll quotes the real id.
    pub request_id: Option<Uuid>,
    /// The transport the submit used, so the poll takes the same route. A
    /// community reachable only over TSP would never see a DIDComm poll.
    pub submit_transport: Option<crate::didcomm::MessagingTransport>,
    /// Ask the community to re-deliver the credentials it issued
    /// ([`CommunityRecord::wants_credential_resend`]).
    pub resend_credentials: bool,
}

/// How long after an approval — or after a re-delivery was queued — before we
/// ask for the credentials (again). Delivery is a push through a mediator, and
/// asking before it could have arrived only spends the community's limit.
pub const CREDENTIAL_RESEND_GRACE_SECS: i64 = 10 * 60;

/// What a community has said about delivering an approved join's credentials.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialDelivery {
    /// The community holds our acknowledgement that the credential arrived.
    pub delivered: bool,
    /// Its answer to the last `resendCredentials` we sent, if we sent one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_answer: Option<CredentialResendAnswer>,
}

/// A community's answer to `resendCredentials`, as recorded.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "answer", rename_all = "camelCase")]
pub enum CredentialResendAnswer {
    /// A re-delivery was queued at `at`.
    Queued { at: DateTime<Utc> },
    /// Too soon; `retry_after` is when to ask again, `None` once the
    /// community's limit is spent.
    #[serde(rename_all = "camelCase")]
    RateLimited { retry_after: Option<DateTime<Utc>> },
    /// The community says delivery is already acknowledged.
    NotNeeded,
}

/// The account — the OpenVTC ↔ VTA relationship (State-A bootstrap) plus its
/// personas and community memberships.
///
/// The account **admin credential** is a secret and is NOT stored here — it
/// lives in `SecuredConfig`/keyring (D12). This struct is the `ProtectedConfig`
/// (encrypted) metadata tier.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Account {
    /// DID of the VTA this account is provisioned against.
    pub vta_did: String,
    /// Base URL of the VTA (empty for DIDComm-only VTAs).
    pub vta_url: String,
    /// The top-level context this account administers.
    pub top_context_id: String,
    // There is no `org_did` any more. Setup used to stamp one operator's own
    // organisation DID into every account, and nothing but a Settings row ever
    // read it. A config written before its removal still loads: the key lands
    // in `extra` below and is carried verbatim, so a downgrade still finds it.
    /// Account personas, keyed by stable id.
    #[serde(default)]
    pub personas: HashMap<PersonaId, PersonaRecord>,
    /// Community memberships, grouped by VTC DID. A community may hold more than
    /// one membership, each presenting a **different** persona — the
    /// `(vtc_did, persona_ref)` pair is unique. Backward compatible:
    /// `de_communities` folds the legacy one-record-per-VTC shape (and a bare
    /// record) into the grouped form on load.
    #[serde(default, deserialize_with = "de_communities")]
    pub communities: HashMap<VtcDid, Vec<CommunityRecord>>,

    /// Fields written by a build newer than this one, preserved verbatim.
    ///
    /// **D19.** Without this, serde silently drops what it does not know, and a
    /// round trip through an older build is data loss: read a record, discard
    /// the new fields, write it back without them. Harmless while a single
    /// writer owns the config — and exactly why it has never bitten — but the
    /// moment this record is shared with another instance (E2), an older build
    /// would quietly strip a newer one's work.
    ///
    /// Carried, never interpreted. `skip_serializing_if` keeps it off the wire
    /// when empty so existing configs are byte-identical.
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Deserialize [`Account::communities`] tolerantly: each VTC's value may be a
/// list of memberships (the current shape) or a single record (legacy configs
/// written before multi-membership). Both fold into `Vec<CommunityRecord>`.
fn de_communities<'de, D>(d: D) -> Result<HashMap<VtcDid, Vec<CommunityRecord>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum OneOrMany {
        // Try the list shape first; a legacy object value fails the seq parse
        // and falls through to the single-record variant.
        Many(Vec<CommunityRecord>),
        One(Box<CommunityRecord>),
    }
    let raw: HashMap<VtcDid, OneOrMany> = HashMap::deserialize(d)?;
    Ok(raw
        .into_iter()
        .map(|(k, v)| {
            let list = match v {
                OneOrMany::Many(list) => list,
                OneOrMany::One(rec) => vec![*rec],
            };
            (k, list)
        })
        .collect())
}

impl Account {
    /// Every membership across all communities (flattened). A community may
    /// contribute more than one — one per presented persona.
    pub fn memberships(&self) -> impl Iterator<Item = &CommunityRecord> {
        self.communities.values().flatten()
    }

    /// Mutable iterator over every membership.
    pub fn memberships_mut(&mut self) -> impl Iterator<Item = &mut CommunityRecord> {
        self.communities.values_mut().flatten()
    }

    /// The memberships held with one community (empty if none).
    pub fn memberships_for(&self, vtc: &str) -> &[CommunityRecord] {
        self.communities.get(vtc).map(Vec::as_slice).unwrap_or(&[])
    }

    /// A specific membership: community `vtc` presented as `persona`. The
    /// `(vtc, persona)` pair is unique, so this resolves at most one.
    pub fn membership(&self, vtc: &str, persona: PersonaId) -> Option<&CommunityRecord> {
        self.memberships_for(vtc)
            .iter()
            .find(|c| c.persona_ref == persona)
    }

    /// Mutable [`Self::membership`] — for applying a lifecycle transition.
    pub fn membership_mut(
        &mut self,
        vtc: &str,
        persona: PersonaId,
    ) -> Option<&mut CommunityRecord> {
        self.communities
            .get_mut(vtc)?
            .iter_mut()
            .find(|c| c.persona_ref == persona)
    }

    /// The pending membership of community `vtc` awaiting the join reply with
    /// `request_id` — the disambiguator when several personas have joined the
    /// same community (each Pending submit carries a unique id). Resolves at most
    /// one membership.
    pub fn membership_by_pending_request(
        &mut self,
        vtc: &str,
        request_id: Uuid,
    ) -> Option<&mut CommunityRecord> {
        self.communities.get_mut(vtc)?.iter_mut().find(
            |c| matches!(&c.status, CommunityStatus::Pending { request_id: r } if *r == request_id),
        )
    }

    /// The pending membership of community `vtc` that is still holding our own
    /// placeholder id — the one an id-less status poll asked about, and so the
    /// one a reply quoting an id we have never seen is for.
    ///
    /// [`Self::membership_by_pending_request`] cannot find it: the reply carries
    /// the community's id and the record holds the placeholder, which is the
    /// whole reason the poll went out id-less. Several personas may each have an
    /// unconfirmed join with the same community; then the reply's recipients
    /// (`to`) decide, and an answer that still names more than one — or none —
    /// resolves nothing rather than guessing.
    pub fn membership_awaiting_request_id(
        &mut self,
        vtc: &str,
        recipients: &[String],
    ) -> Option<&mut CommunityRecord> {
        let unconfirmed: Vec<PersonaId> = self
            .communities
            .get(vtc)?
            .iter()
            .filter(|c| {
                matches!(c.status, CommunityStatus::Pending { .. }) && !c.request_id_confirmed
            })
            .map(|c| c.persona_ref)
            .collect();
        let persona = match unconfirmed.as_slice() {
            [only] => *only,
            [] => return None,
            several => {
                let addressed: Vec<PersonaId> = several
                    .iter()
                    .copied()
                    .filter(|p| {
                        self.personas
                            .get(p)
                            .is_some_and(|rec| recipients.contains(&rec.did))
                    })
                    .collect();
                let [only] = addressed.as_slice() else {
                    return None;
                };
                *only
            }
        };
        self.membership_mut(vtc, persona)
    }

    /// Every `Pending` join the community can be asked about, as the tuple a
    /// status poll needs: which community, which persona speaks to it, the
    /// community's request id **if we know it**, and the transport the submit
    /// went out on.
    ///
    /// Read-only and cheap — the caller (a periodic reconcile) runs this on
    /// every tick and expects an empty vector to be the normal answer.
    pub fn pollable_pending(&self, now: DateTime<Utc>) -> Vec<PendingPoll> {
        self.memberships()
            .filter(|c| c.is_pollable_pending())
            .filter_map(|c| {
                let CommunityStatus::Pending { request_id } = c.status else {
                    return None;
                };
                Some(PendingPoll {
                    vtc_did: c.vtc_did.clone(),
                    persona_ref: c.persona_ref,
                    // Only the community's own id is worth quoting. Our
                    // placeholder names a document the VTC has never heard of
                    // and is answered `not found`; omitting it asks the
                    // question that can actually be answered.
                    request_id: c.request_id_confirmed.then_some(request_id),
                    submit_transport: c.submit_transport,
                    resend_credentials: c.wants_credential_resend(now),
                })
            })
            .collect()
    }

    /// Add a new membership. Callers gate on [`Self::has_live_membership`] first
    /// (R-B-9): a community may hold many memberships, but not two for the same
    /// persona.
    pub fn add_membership(&mut self, record: CommunityRecord) {
        self.communities
            .entry(record.vtc_did.clone())
            .or_default()
            .push(record);
    }

    /// Whether a *live* (Active/Pending) membership already exists for
    /// `(vtc, persona)`. Join idempotency is per-persona: a live membership as
    /// *this* persona blocks a duplicate, but the same community may still be
    /// joined as a different persona.
    pub fn has_live_membership(&self, vtc: &str, persona: PersonaId) -> bool {
        self.membership(vtc, persona).is_some_and(|c| c.is_live())
    }

    /// The id of the account persona whose `did` equals `did`, if any. Maps an
    /// addressed persona DID (e.g. an inbound message's recipient) back to its
    /// [`PersonaId`] for D10 attribution tagging.
    pub fn persona_id_for_did(&self, did: &str) -> Option<PersonaId> {
        self.personas
            .iter()
            .find(|(_, p)| p.did == did)
            .map(|(id, _)| *id)
    }

    /// Resolve the persona presented for a specific membership.
    pub fn membership_persona(&self, vtc: &str, persona: PersonaId) -> Option<&PersonaRecord> {
        self.membership(vtc, persona)
            .and_then(|c| self.personas.get(&c.persona_ref))
    }

    /// True if any membership references this persona.
    pub fn persona_referenced(&self, id: &PersonaId) -> bool {
        self.memberships().any(|c| &c.persona_ref == id)
    }

    /// Whether a persona may be deleted (R-P-1): it must exist and not be
    /// referenced by any membership.
    pub fn can_delete_persona(&self, id: &PersonaId) -> bool {
        self.personas.contains_key(id) && !self.persona_referenced(id)
    }

    /// Any `persona_ref`s that do not resolve to an existing persona — should
    /// always be empty (referential integrity, R-P-1).
    pub fn dangling_refs(&self) -> Vec<(&VtcDid, &PersonaId)> {
        self.communities
            .iter()
            .flat_map(|(vtc, list)| list.iter().map(move |c| (vtc, c)))
            .filter(|(_, c)| !self.personas.contains_key(&c.persona_ref))
            .map(|(vtc, c)| (vtc, &c.persona_ref))
            .collect()
    }

    /// Iterator over memberships in the `Active` (live) state.
    pub fn active_communities(&self) -> impl Iterator<Item = &CommunityRecord> {
        self.memberships().filter(|c| c.status.is_active())
    }

    /// Sweep all `Pending` memberships, expiring any past the client timeout
    /// (R-B-7 / D16). Returns the `(vtc, persona)` of each membership that
    /// transitioned to `Expired` so the caller can persist and raise the
    /// actions-required indicator (R-S-2).
    pub fn expire_stale_pending(&mut self, now: DateTime<Utc>) -> Vec<(VtcDid, PersonaId)> {
        self.expire_stale_pending_with(now, |_| TimeDelta::days(PENDING_TIMEOUT_DAYS))
    }

    /// [`Self::expire_stale_pending`] with a timeout chosen per membership, so a
    /// community that publishes a `decisionSla` is waited on for that long
    /// rather than the fixed client default (vetting-process.md §14.1).
    pub fn expire_stale_pending_with(
        &mut self,
        now: DateTime<Utc>,
        timeout_for: impl Fn(&CommunityRecord) -> TimeDelta,
    ) -> Vec<(VtcDid, PersonaId)> {
        let mut expired = Vec::new();
        for community in self.memberships_mut() {
            let timeout = timeout_for(community);
            if community.expire_if_stale_after(now, timeout) {
                expired.push((community.vtc_did.clone(), community.persona_ref));
            }
        }
        expired
    }

    /// Number of memberships currently raising the actions-required indicator
    /// (R-C-3): see [`CommunityRecord::needs_attention`]. Archived memberships
    /// are excluded — archiving hides one from the default list, so it no longer
    /// nags.
    pub fn actions_required_count(&self) -> usize {
        self.memberships()
            .filter(|c| !c.archived && c.needs_attention())
            .count()
    }

    /// Memberships for the overview page in display order (R-C-4): grouped by
    /// community (display name, case-insensitive, unnamed last; then VTC DID),
    /// favourites first within the list, then by presented persona for a stable
    /// order. Archived memberships are excluded unless `include_archived` (R-C-8).
    pub fn communities_for_display(&self, include_archived: bool) -> Vec<&CommunityRecord> {
        let mut list: Vec<&CommunityRecord> = self
            .memberships()
            .filter(|c| include_archived || !c.archived)
            .collect();
        list.sort_by(|a, b| {
            // Favourites first.
            b.favourite
                .cmp(&a.favourite)
                // Then group by display name, case-insensitive; unnamed last.
                .then_with(|| match (&a.display_name, &b.display_name) {
                    (Some(an), Some(bn)) => an.to_lowercase().cmp(&bn.to_lowercase()),
                    (Some(_), None) => std::cmp::Ordering::Less,
                    (None, Some(_)) => std::cmp::Ordering::Greater,
                    (None, None) => std::cmp::Ordering::Equal,
                })
                // Then the VTC DID, then the persona, for a stable order that
                // keeps a community's memberships adjacent (for grouped display).
                .then_with(|| a.vtc_did.cmp(&b.vtc_did))
                .then_with(|| a.persona_ref.cmp(&b.persona_ref))
        });
        list
    }

    /// The membership to use as the default working context (D10 / R-C-6/7) when
    /// the user hasn't explicitly selected one: the first **Active** membership
    /// in display order. Returns `None` when there is none. Deterministic so the
    /// working context is stable across launches.
    pub fn default_working_membership(&self) -> Option<(VtcDid, PersonaId)> {
        self.communities_for_display(false)
            .into_iter()
            .find(|c| c.status.is_active())
            .map(|c| (c.vtc_did.clone(), c.persona_ref))
    }

    /// Archive an inactive membership (R-C-8): retain its data but hide it from
    /// the default list. Errors if the membership is unknown or still
    /// active/pending (it must be left first).
    pub fn archive_membership(
        &mut self,
        vtc: &str,
        persona: PersonaId,
    ) -> Result<(), OpenVTCError> {
        let community = self
            .membership_mut(vtc, persona)
            .ok_or_else(|| OpenVTCError::Config(format!("Unknown membership: {vtc}")))?;
        if !community.can_archive_or_delete() {
            return Err(OpenVTCError::Config(format!(
                "Cannot archive an active/pending community ({vtc}); leave it first"
            )));
        }
        community.archived = true;
        Ok(())
    }

    /// Delete an inactive membership's local record (R-C-8), returning the removed
    /// record. Errors if it is unknown or still active/pending. The presented
    /// persona is retained even if now unreferenced (R-P-2).
    pub fn delete_membership(
        &mut self,
        vtc: &str,
        persona: PersonaId,
    ) -> Result<CommunityRecord, OpenVTCError> {
        let list = self
            .communities
            .get_mut(vtc)
            .ok_or_else(|| OpenVTCError::Config(format!("Unknown membership: {vtc}")))?;
        let idx = list
            .iter()
            .position(|c| c.persona_ref == persona)
            .ok_or_else(|| OpenVTCError::Config(format!("Unknown membership: {vtc}")))?;
        if !list[idx].can_archive_or_delete() {
            return Err(OpenVTCError::Config(format!(
                "Cannot delete an active/pending community ({vtc}); leave it first"
            )));
        }
        let removed = list.remove(idx);
        // Drop the community's bucket once its last membership is gone.
        if list.is_empty() {
            self.communities.remove(vtc);
        }
        Ok(removed)
    }
}

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

    fn persona(label: &str) -> PersonaRecord {
        PersonaRecord {
            extra: serde_json::Map::new(),
            persona_id: PersonaId::new(),
            did: format!("did:webvh:example.com:{label}"),
            did_document: None,
            key_refs: vec![KeyRef {
                key_id: format!("key-{label}"),
                purpose: KeyTypes::PersonaSigning,
                created_at: Utc::now(),
            }],
            mediator_did: None,
            origin_context_id: format!("openvtc/{label}"),
            created_at: Utc::now(),
            label: Some(label.to_string()),
        }
    }

    /// `PersonaRecord.did_document` persists a whole DID Document into the
    /// encrypted config. `affinidi-did-common` 0.4 added `alsoKnownAs` to that
    /// struct (it is the field agent-name verification reads), so a config
    /// written by a pre-0.4 build has no such key. It must still load — the
    /// project tolerates format evolution via serde defaults, not migrations.
    #[test]
    fn persona_did_document_without_also_known_as_still_loads() {
        let legacy = serde_json::json!({
            "persona_id": PersonaId::new().0,
            "did": "did:webvh:example.com:dave",
            // A did-common 0.3-shaped document: no `alsoKnownAs` key at all.
            "did_document": { "id": "did:webvh:example.com:dave" },
            "key_refs": [],
            "mediator_did": null,
            "origin_context_id": "openvtc/dave",
            "created_at": Utc::now(),
            "label": "Dave",
        });

        let rec: PersonaRecord =
            serde_json::from_value(legacy).expect("pre-0.4 persona config must still deserialize");
        let doc = rec.did_document.expect("document should be present");
        assert!(
            doc.also_known_as.is_empty(),
            "a document that claimed no names must load as claiming none, not fail"
        );
    }

    fn community(vtc: &str, persona_ref: PersonaId, status: CommunityStatus) -> CommunityRecord {
        CommunityRecord {
            extra: serde_json::Map::new(),
            vtc_did: vtc.to_string(),
            display_name: Some(vtc.to_string()),
            sub_context_id: format!("openvtc/{vtc}"),
            submit_transport: None,
            request_id_confirmed: false,
            persona_ref,
            status,
            favourite: false,
            archived: false,
            acknowledged: false,
            member_since: None,
            requested_at: None,
            receipt_at: None,
            approved_at: None,
            credential_delivery: None,
            relationships: Relationships::default(),
            tasks: Tasks::default(),
            vrcs_issued: Vrcs::default(),
            vrcs_received: Vrcs::default(),
            credentials: BTreeMap::new(),
            member_vmc: None,
            decision: None,
            relationship_identifier_default: None,
            retired_credentials: Vec::new(),
        }
    }

    #[test]
    fn persona_id_for_did_maps_addressed_did_to_persona() {
        let pa = persona("alice");
        let pb = persona("bob");
        let (pid_a, did_a) = (pa.persona_id, pa.did.clone());
        let mut acct = Account::default();
        acct.personas.insert(pa.persona_id, pa);
        acct.personas.insert(pb.persona_id, pb);
        assert_eq!(acct.persona_id_for_did(&did_a), Some(pid_a));
        assert_eq!(acct.persona_id_for_did("did:web:nobody"), None);
    }

    #[test]
    fn default_working_community_prefers_favourite_active_and_skips_inactive() {
        let p = persona("p");
        let mut acct = Account::default();
        let vtc = |a: &Account| a.default_working_membership().map(|(v, _)| v);
        // No communities → no working context.
        assert_eq!(vtc(&acct), None);

        // An inactive (Left) community is never the working context.
        let left = community("did:web:left", p.persona_id, CommunityStatus::Left);
        acct.add_membership(left);
        assert_eq!(vtc(&acct), None);

        // A plain active community becomes the default.
        let act = community("did:web:active", p.persona_id, CommunityStatus::Active);
        acct.add_membership(act);
        assert_eq!(vtc(&acct).as_deref(), Some("did:web:active"));

        // A favourited active community wins (sorts first in display order).
        let mut fav = community("did:web:fav", p.persona_id, CommunityStatus::Active);
        fav.favourite = true;
        acct.add_membership(fav);
        acct.personas.insert(p.persona_id, p);
        assert_eq!(vtc(&acct).as_deref(), Some("did:web:fav"));
    }

    #[test]
    fn community_record_tasks_survive_json_round_trip() {
        use crate::tasks::TaskType;
        use std::sync::Arc;

        let pid = PersonaId::new();
        let mut comm = community("did:web:vtc-rt", pid, CommunityStatus::Active);
        comm.tasks.new_task(
            &Arc::new("task-rt".to_string()),
            TaskType::RelationshipRequestRejected,
        );
        let json = serde_json::to_string(&comm).expect("serialize");
        let back: CommunityRecord = serde_json::from_str(&json).expect("deserialize");
        assert!(
            back.tasks
                .get_by_id(&Arc::new("task-rt".to_string()))
                .is_some(),
            "per-community task should survive the round trip"
        );
    }

    /// Pre-R19 configs stored credentials as flat `membership_credential` /
    /// `role_credential` fields. They must still load, folded into the typed
    /// `credentials` registry (config round-trip — no migration) — and, like
    /// any stored credential, held to DTG Credentials v1 once folded in.
    #[test]
    fn legacy_flat_credential_fields_load_into_registry() {
        let mut vmc = crate::dtg::fixtures::grant("did:webvh:vtc.example", "did:webvh:m");
        vmc["id"] = "vmc-1".into();
        let legacy = serde_json::json!({
            "vtc_did": "did:webvh:vtc.example",
            "display_name": "Example VTC",
            "sub_context_id": "openvtc/example",
            "persona_ref": PersonaId::new().0,
            "status": { "state": "active" },
            "member_since": null,
            "requested_at": null,
            "membership_credential": vmc,
            "role_credential": crate::dtg::fixtures::retired_role_endorsement(
                "did:webvh:vtc.example", "did:webvh:m"
            ),
        });
        let rec: CommunityRecord = serde_json::from_value(legacy).unwrap();
        assert_eq!(
            rec.credentials
                .get(&CredentialKind::Membership)
                .and_then(|v| v.get("id"))
                .and_then(|v| v.as_str()),
            Some("vmc-1"),
        );
        // The pre-v1 role endorsement is set aside, with its reason, not kept.
        assert!(!rec.credentials.contains_key(&CredentialKind::Role));
        assert_eq!(rec.retired_credentials.len(), 1);
        assert_eq!(rec.retired_credentials[0].kind, "Role");
    }

    /// A stored credential that pre-dates DTG Credentials v1 neither fails the
    /// load nor survives it: it is set aside, the reason is kept for the UI, the
    /// notice survives a save, and a conformant replacement clears it.
    #[test]
    fn pre_v1_stored_credentials_are_set_aside_explicitly() {
        let vtc = "did:webvh:vtc.example";
        let mut old_vmc = crate::dtg::fixtures::grant(vtc, "did:webvh:m");
        old_vmc["@context"][1] = crate::dtg::fixtures::RETIRED_CONTEXT.into();
        old_vmc.as_object_mut().unwrap().remove("issuerScope");
        let stored = serde_json::json!({
            "vtc_did": vtc,
            "display_name": null,
            "sub_context_id": "openvtc/x",
            "persona_ref": PersonaId::new().0,
            "status": { "state": "active" },
            "member_since": null,
            "requested_at": null,
            "credentials": {
                "Membership": old_vmc,
                "Role": crate::dtg::fixtures::retired_role_endorsement(vtc, "did:webvh:m"),
            },
            "member_vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
        });
        let mut rec: CommunityRecord = serde_json::from_value(stored).expect("loads");
        assert!(rec.credentials.is_empty());
        assert!(rec.member_vmc.is_none());
        let kinds: Vec<&str> = rec
            .retired_credentials
            .iter()
            .map(|r| r.kind.as_str())
            .collect();
        assert_eq!(
            kinds,
            [
                "Membership",
                "Role",
                RetiredCredential::MEMBER_ACKNOWLEDGEMENT
            ]
        );
        assert!(rec.retired_credentials.iter().all(|r| !r.reason.is_empty()));

        let saved = serde_json::to_value(&rec).unwrap();
        let again: CommunityRecord = serde_json::from_value(saved).unwrap();
        assert_eq!(again.retired_credentials, rec.retired_credentials);

        rec.clear_retired("Role");
        assert_eq!(rec.retired_credentials.len(), 2);
    }

    /// The new `credentials` object round-trips, serializes under stable
    /// `config_key` names, and tolerates an unknown key (dropped, not fatal).
    #[test]
    fn typed_credentials_round_trip_and_tolerate_unknown() {
        let mut rec = community(
            "did:webvh:vtc.example",
            PersonaId::new(),
            CommunityStatus::Active,
        );
        let mut vmc = crate::dtg::fixtures::grant("did:webvh:vtc.example", "did:webvh:m");
        vmc["id"] = "vmc-1".into();
        rec.credentials.insert(CredentialKind::Membership, vmc);

        let json = serde_json::to_value(&rec).unwrap();
        assert!(
            json["credentials"]["Membership"]["id"] == "vmc-1",
            "credentials must persist under the config_key name: {json}",
        );

        let back: CommunityRecord = serde_json::from_value(json).unwrap();
        assert_eq!(back.credentials, rec.credentials);

        // An unrecognised kind (e.g. from a newer build) is dropped, not fatal.
        let with_unknown = serde_json::json!({
            "vtc_did": "did:webvh:vtc.example",
            "display_name": null,
            "sub_context_id": "openvtc/x",
            "persona_ref": PersonaId::new().0,
            "status": { "state": "active" },
            "member_since": null,
            "requested_at": null,
            "credentials": { "FutureKind": { "id": "x" } },
        });
        let rec: CommunityRecord = serde_json::from_value(with_unknown).unwrap();
        assert!(rec.credentials.is_empty());
    }

    #[test]
    fn new_pending_builds_a_live_pending_record() {
        let now = Utc::now();
        let pid = PersonaId::new();
        let req = Uuid::new_v4();
        let rec = CommunityRecord::new_pending(
            "did:webvh:vtc.example".to_string(),
            Some("Example VTC".to_string()),
            "openvtc/example".to_string(),
            pid,
            req,
            now,
        );
        assert!(matches!(rec.status, CommunityStatus::Pending { request_id } if request_id == req));
        assert_eq!(rec.persona_ref, pid);
        assert_eq!(rec.requested_at, Some(now));
        assert_eq!(rec.member_since, None);
        assert!(rec.is_live());
        assert!(rec.needs_attention());
        assert!(!rec.favourite && !rec.archived && !rec.acknowledged);

        // Idempotency (R-B-9): a pending join is a live membership, so a re-join
        // attempt as the same persona finds it.
        let mut acct = Account::default();
        let pref = rec.persona_ref;
        let vtc = rec.vtc_did.clone();
        acct.add_membership(rec.clone());
        assert!(acct.has_live_membership(&vtc, pref));
    }

    #[test]
    fn status_classification() {
        assert!(CommunityStatus::Active.is_active());
        assert!(!CommunityStatus::Active.is_read_only());
        assert!(!CommunityStatus::Active.needs_attention());

        for s in [
            CommunityStatus::Left,
            CommunityStatus::Withdrawn,
            CommunityStatus::Rejected,
            CommunityStatus::Removed,
            CommunityStatus::Expired,
        ] {
            assert!(s.is_read_only(), "{s:?} should be read-only");
            assert!(s.is_inactive(), "{s:?} should be inactive (archive/delete)");
        }

        let pending = CommunityStatus::Pending {
            request_id: Uuid::new_v4(),
        };
        assert!(pending.is_read_only());
        assert!(!pending.is_inactive(), "pending is in-flight, not inactive");
        assert!(pending.needs_attention());
        assert!(CommunityStatus::Rejected.needs_attention());
        assert!(!CommunityStatus::Left.needs_attention());
        // Withdrawn is a voluntary outcome (like Left) — it never nags.
        assert!(!CommunityStatus::Withdrawn.needs_attention());
    }

    #[test]
    fn persona_for_resolves_ref() {
        let mut acct = Account::default();
        let p = persona("alice");
        let pid = p.persona_id;
        acct.personas.insert(pid, p);
        acct.add_membership(community("vtc:a", pid, CommunityStatus::Active));

        let resolved = acct.membership_persona("vtc:a", pid).expect("resolves");
        assert_eq!(resolved.persona_id, pid);
        assert!(acct.membership_persona("vtc:missing", pid).is_none());
        assert!(acct.dangling_refs().is_empty());
    }

    #[test]
    fn referential_integrity_blocks_persona_delete() {
        let mut acct = Account::default();
        let p = persona("bob");
        let pid = p.persona_id;
        acct.personas.insert(pid, p);

        // Unreferenced: deletable.
        assert!(acct.can_delete_persona(&pid));

        // Now referenced by an active community: not deletable (R-P-1).
        acct.add_membership(community("vtc:b", pid, CommunityStatus::Active));
        assert!(acct.persona_referenced(&pid));
        assert!(!acct.can_delete_persona(&pid));

        // Unknown persona is never deletable.
        assert!(!acct.can_delete_persona(&PersonaId::new()));
    }

    #[test]
    fn active_communities_filters() {
        let mut acct = Account::default();
        let p = persona("carol");
        let pid = p.persona_id;
        acct.personas.insert(pid, p);
        acct.add_membership(community("a", pid, CommunityStatus::Active));
        acct.add_membership(community("b", pid, CommunityStatus::Left));
        acct.add_membership(community(
            "c",
            pid,
            CommunityStatus::Pending {
                request_id: Uuid::new_v4(),
            },
        ));
        assert_eq!(acct.active_communities().count(), 1);
    }

    #[test]
    fn account_json_round_trip_preserves_shape() {
        let mut acct = Account {
            vta_did: "did:webvh:vta.example".into(),
            vta_url: "https://vta.example".into(),
            top_context_id: "openvtc".into(),
            ..Account::default()
        };
        let p = persona("dave");
        let pid = p.persona_id;
        let req = Uuid::new_v4();
        acct.personas.insert(pid, p);
        acct.add_membership(community(
            "vtc:x",
            pid,
            CommunityStatus::Pending { request_id: req },
        ));

        let json = serde_json::to_string(&acct).expect("serialize");
        let back: Account = serde_json::from_str(&json).expect("deserialize");

        assert_eq!(back.vta_did, acct.vta_did);
        assert_eq!(back.top_context_id, "openvtc");
        assert_eq!(back.personas.len(), 1);
        let bp = back.personas.get(&pid).expect("persona survives");
        assert_eq!(bp.did, "did:webvh:example.com:dave");
        let bc = back.membership("vtc:x", pid).expect("community survives");
        assert_eq!(bc.persona_ref, pid);
        assert_eq!(bc.status, CommunityStatus::Pending { request_id: req });
    }

    #[test]
    fn community_status_tag_is_stable() {
        // The serde tag is part of the on-disk format; pin it.
        let j = serde_json::to_string(&CommunityStatus::Active).unwrap();
        assert_eq!(j, r#"{"state":"active"}"#);
        let j = serde_json::to_string(&CommunityStatus::Expired).unwrap();
        assert_eq!(j, r#"{"state":"expired"}"#);
        let j = serde_json::to_string(&CommunityStatus::Withdrawn).unwrap();
        assert_eq!(j, r#"{"state":"withdrawn"}"#);
    }

    fn pending() -> CommunityStatus {
        CommunityStatus::Pending {
            request_id: Uuid::new_v4(),
        }
    }

    #[test]
    fn activate_stamps_member_since_once() {
        let pid = PersonaId::new();
        let mut c = community("v", pid, pending());
        let t0 = Utc::now();
        c.activate(t0);
        assert_eq!(c.status, CommunityStatus::Active);
        assert_eq!(c.member_since, Some(t0));

        // Re-activating keeps the original member-since date.
        c.activate(t0 + TimeDelta::days(5));
        assert_eq!(c.member_since, Some(t0), "member_since must not be reset");
    }

    #[test]
    fn terminal_transitions_set_status() {
        let pid = PersonaId::new();

        let mut r = community("v", pid, pending());
        r.reject(DecisionEvidence::default());
        assert_eq!(r.status, CommunityStatus::Rejected);

        let mut rm = community("v", pid, CommunityStatus::Active);
        rm.remove(DecisionEvidence::default());
        assert_eq!(rm.status, CommunityStatus::Removed);

        let mut l = community("v", pid, CommunityStatus::Active);
        l.leave();
        assert_eq!(l.status, CommunityStatus::Left);
    }

    #[test]
    fn withdraw_cancels_a_pending_join() {
        let pid = PersonaId::new();

        // Pending → Withdrawn: succeeds, becomes inactive, and never nags.
        let mut p = community("v", pid, pending());
        p.acknowledged = false;
        assert!(p.withdraw(), "withdraw applies to a pending join");
        assert_eq!(p.status, CommunityStatus::Withdrawn);
        assert!(
            p.can_archive_or_delete(),
            "withdrawn is deletable/archivable"
        );
        assert!(!p.needs_attention(), "a withdrawn join must not nag");
        assert!(!p.is_live(), "withdrawn needs no live session");

        // Idempotent / guarded: a second call (now Withdrawn) is a no-op, and an
        // Active membership can't be withdrawn (only left).
        assert!(!p.withdraw(), "withdraw is a no-op once not pending");
        let mut active = community("v", pid, CommunityStatus::Active);
        assert!(
            !active.withdraw(),
            "withdraw must not touch an active membership"
        );
        assert_eq!(active.status, CommunityStatus::Active);
    }

    #[test]
    fn expire_if_stale_only_fires_for_old_pending() {
        let pid = PersonaId::new();
        let now = Utc::now();

        // Fresh pending (just under the timeout): not expired.
        let mut fresh = community("v", pid, pending());
        fresh.requested_at = Some(now - TimeDelta::days(PENDING_TIMEOUT_DAYS - 1));
        assert!(!fresh.expire_if_stale(now));
        assert!(matches!(fresh.status, CommunityStatus::Pending { .. }));

        // Stale pending (at the timeout): expires.
        let mut stale = community("v", pid, pending());
        stale.requested_at = Some(now - TimeDelta::days(PENDING_TIMEOUT_DAYS));
        assert!(stale.expire_if_stale(now));
        assert_eq!(stale.status, CommunityStatus::Expired);

        // Active is never expired, however old.
        let mut active = community("v", pid, CommunityStatus::Active);
        active.requested_at = Some(now - TimeDelta::days(365));
        assert!(!active.expire_if_stale(now));
        assert_eq!(active.status, CommunityStatus::Active);

        // Pending with no requested_at can't be judged stale.
        let mut no_ts = community("v", pid, pending());
        no_ts.requested_at = None;
        assert!(!no_ts.expire_if_stale(now));
    }

    #[test]
    fn mark_acknowledged_stamps_once() {
        let pid = PersonaId::new();
        let now = Utc::now();
        let mut c = community("v", pid, pending());
        assert!(c.receipt_at.is_none());
        // First contact stamps; returns true (caller persists).
        assert!(c.mark_acknowledged(now));
        assert_eq!(c.receipt_at, Some(now));
        // A later contact is a no-op — first contact is what matters.
        assert!(!c.mark_acknowledged(now + TimeDelta::seconds(10)));
        assert_eq!(c.receipt_at, Some(now));
    }

    /// `resendCredentials` is carried only for an approved join still without
    /// its credential, after the grace, once the community has reported
    /// delivery — and then on the community's own terms.
    #[test]
    fn credential_resend_is_asked_only_when_the_community_offers_it() {
        let pid = PersonaId::new();
        let now = Utc::now();
        let grace = TimeDelta::seconds(CREDENTIAL_RESEND_GRACE_SECS);
        let approved = |delivery: Option<CredentialDelivery>| {
            let mut c = community("v", pid, pending());
            c.mark_approved(now - grace);
            c.credential_delivery = delivery;
            c
        };
        let undelivered = |last_answer| {
            Some(CredentialDelivery {
                delivered: false,
                last_answer,
            })
        };

        // Not approved, or approved but the community never reported delivery
        // (a community that predates the member would refuse the flag).
        assert!(!community("v", pid, pending()).wants_credential_resend(now));
        assert!(!approved(None).wants_credential_resend(now));

        // Reported undelivered, past the grace: ask.
        assert!(approved(undelivered(None)).wants_credential_resend(now));
        // Inside the grace: not yet.
        let mut early = approved(undelivered(None));
        early.approved_at = Some(now);
        assert!(!early.wants_credential_resend(now));
        // The community holds our acknowledgement: nothing to ask for.
        assert!(
            !approved(Some(CredentialDelivery {
                delivered: true,
                last_answer: None
            }))
            .wants_credential_resend(now)
        );

        // Its answers pace us.
        let queued = |at| undelivered(Some(CredentialResendAnswer::Queued { at }));
        assert!(!approved(queued(now)).wants_credential_resend(now));
        assert!(approved(queued(now - grace)).wants_credential_resend(now));
        let limited =
            |retry_after| undelivered(Some(CredentialResendAnswer::RateLimited { retry_after }));
        assert!(!approved(limited(Some(now + grace))).wants_credential_resend(now));
        assert!(approved(limited(Some(now))).wants_credential_resend(now));
        assert!(
            !approved(limited(None)).wants_credential_resend(now),
            "limit spent"
        );
        assert!(
            !approved(undelivered(Some(CredentialResendAnswer::NotNeeded)))
                .wants_credential_resend(now)
        );

        // And it reaches the poll.
        let mut acct = Account::default();
        acct.add_membership(approved(undelivered(None)));
        assert!(acct.pollable_pending(now)[0].resend_credentials);
    }

    /// The member verbs follow the join's transport: a join submitted over TSP
    /// is spoken to over TSP afterwards, anything else over DIDComm.
    #[test]
    fn a_join_submitted_over_tsp_is_spoken_to_over_tsp() {
        use crate::didcomm::MessagingTransport;
        let pid = PersonaId::new();
        let mut c = community("v", pid, pending());
        assert!(!c.joined_over_tsp(), "no transport recorded: DIDComm");
        c.submit_transport = Some(MessagingTransport::DidComm);
        assert!(!c.joined_over_tsp());
        c.submit_transport = Some(MessagingTransport::Tsp);
        assert!(c.joined_over_tsp());
    }

    #[test]
    fn pending_unacknowledged_flags_only_unacked_pending_past_grace() {
        let pid = PersonaId::new();
        let now = Utc::now();
        let grace = TimeDelta::seconds(PENDING_ACK_GRACE_SECS);

        // Pending, no receipt, within grace: not yet flagged.
        let mut fresh = community("v", pid, pending());
        fresh.requested_at = Some(now - grace + TimeDelta::seconds(1));
        assert!(!fresh.pending_unacknowledged(now));

        // Pending, no receipt, past grace: flagged (possibly dropped).
        let mut stuck = community("v", pid, pending());
        stuck.requested_at = Some(now - grace);
        assert!(stuck.pending_unacknowledged(now));

        // Pending but acknowledged: never flagged, however old.
        let mut acked = community("v", pid, pending());
        acked.requested_at = Some(now - TimeDelta::days(1));
        acked.mark_acknowledged(now - TimeDelta::days(1));
        assert!(!acked.pending_unacknowledged(now));

        // Non-Pending is never flagged.
        let mut active = community("v", pid, CommunityStatus::Active);
        active.requested_at = Some(now - TimeDelta::days(1));
        assert!(!active.pending_unacknowledged(now));
    }

    #[test]
    fn confirm_request_id_adopts_the_communitys_id_only_while_pending() {
        let pid = PersonaId::new();
        let real = Uuid::new_v4();

        let mut c = community("v", pid, pending());
        assert!(
            c.is_pollable_pending(),
            "a record still holding our submit id is exactly the one worth asking \
             about — it asks id-less, and the answer carries the id"
        );
        assert!(
            c.confirm_request_id(real),
            "adopting is a change to persist"
        );
        assert!(matches!(c.status, CommunityStatus::Pending { request_id } if request_id == real));
        assert!(c.is_pollable_pending());
        assert!(
            !c.confirm_request_id(real),
            "re-confirming the same id changes nothing, so it must not force a save"
        );

        // A terminal record has nothing left to poll for, so a late reply
        // carrying an id must not resurrect it as pollable.
        let mut rejected = community("v", pid, CommunityStatus::Rejected);
        assert!(!rejected.confirm_request_id(real));
        assert!(!rejected.is_pollable_pending());
    }

    /// Every `Pending` join is askable; only what we *quote* differs.
    ///
    /// This used to list confirmed records only, which meant the joins most in
    /// need of reconciling — the ones whose reply was lost, so whose id we never
    /// learned — were the exact ones never polled (#221).
    #[test]
    fn pollable_pending_covers_unconfirmed_joins_and_omits_their_id() {
        let mut acct = Account::default();
        let pid = PersonaId::new();
        let real = Uuid::new_v4();

        acct.add_membership(community("unconfirmed", pid, pending()));
        // Active — nothing to ask.
        acct.add_membership(community("active", pid, CommunityStatus::Active));
        let mut confirmed = community("confirmed", pid, pending());
        confirmed.confirm_request_id(real);
        confirmed.submit_transport = Some(crate::didcomm::MessagingTransport::Tsp);
        acct.add_membership(confirmed);

        let pollable = acct.pollable_pending(Utc::now());
        assert_eq!(
            pollable.len(),
            2,
            "both pending joins are askable: {pollable:?}"
        );

        let unconfirmed = pollable
            .iter()
            .find(|p| p.vtc_did == "unconfirmed")
            .expect("the unconfirmed join must be polled");
        assert_eq!(
            unconfirmed.request_id, None,
            "our placeholder names a document the VTC has never heard of; omitting \
             it is what makes the poll answerable"
        );

        let confirmed = pollable
            .iter()
            .find(|p| p.vtc_did == "confirmed")
            .expect("the confirmed join is still polled");
        assert_eq!(
            confirmed.request_id,
            Some(real),
            "quote the community's own id"
        );
        assert_eq!(
            confirmed.submit_transport,
            Some(crate::didcomm::MessagingTransport::Tsp),
            "the poll must take the transport the submit took"
        );
    }

    #[test]
    fn live_community_filters_inactive() {
        let mut acct = Account::default();
        let pid = PersonaId::new();
        acct.add_membership(community("active", pid, CommunityStatus::Active));
        acct.add_membership(community("left", pid, CommunityStatus::Left));
        acct.add_membership(community("pend", pid, pending()));

        assert!(acct.has_live_membership("active", pid));
        assert!(acct.has_live_membership("pend", pid));
        assert!(
            !acct.has_live_membership("left", pid),
            "Left is not a live membership"
        );
        assert!(!acct.has_live_membership("missing", pid));
    }

    #[test]
    fn favourite_toggle_and_archive_delete_guard() {
        let pid = PersonaId::new();
        let mut c = community("v", pid, CommunityStatus::Active);
        assert!(!c.favourite);
        assert!(c.toggle_favourite());
        assert!(c.favourite);
        assert!(!c.toggle_favourite());

        // Active/pending cannot be archived/deleted; inactive can.
        assert!(!community("v", pid, CommunityStatus::Active).can_archive_or_delete());
        assert!(!community("v", pid, pending()).can_archive_or_delete());
        for s in [
            CommunityStatus::Left,
            CommunityStatus::Rejected,
            CommunityStatus::Removed,
            CommunityStatus::Expired,
        ] {
            assert!(community("v", pid, s).can_archive_or_delete());
        }
    }

    #[test]
    fn communities_for_display_orders_and_filters() {
        let mut acct = Account::default();
        let pid = PersonaId::new();

        let mut zebra = community("did:z", pid, CommunityStatus::Active);
        zebra.display_name = Some("Zebra".into());
        let mut acme = community("did:a", pid, CommunityStatus::Active);
        acme.display_name = Some("acme".into()); // lowercase: case-insensitive sort
        let mut fav = community("did:f", pid, CommunityStatus::Active);
        fav.display_name = Some("Middle".into());
        fav.favourite = true;
        let mut archived = community("did:x", pid, CommunityStatus::Left);
        archived.display_name = Some("Aardvark".into());
        archived.archived = true;

        for c in [zebra, acme, fav, archived] {
            acct.add_membership(c);
        }

        // Default: archived excluded; favourite first, then name (ci).
        let names: Vec<&str> = acct
            .communities_for_display(false)
            .iter()
            .map(|c| c.display_name.as_deref().unwrap())
            .collect();
        assert_eq!(names, vec!["Middle", "acme", "Zebra"]);

        // With archived included, "Aardvark" appears (still after the favourite).
        let with_archived: Vec<&str> = acct
            .communities_for_display(true)
            .iter()
            .map(|c| c.display_name.as_deref().unwrap())
            .collect();
        assert_eq!(with_archived, vec!["Middle", "Aardvark", "acme", "Zebra"]);
    }

    #[test]
    fn archive_and_delete_respect_guards() {
        let mut acct = Account::default();
        let pid = PersonaId::new();
        acct.add_membership(community("active", pid, CommunityStatus::Active));
        acct.add_membership(community("left", pid, CommunityStatus::Left));

        // Active cannot be archived or deleted.
        assert!(acct.archive_membership("active", pid).is_err());
        assert!(acct.delete_membership("active", pid).is_err());
        // Unknown errors too.
        assert!(acct.archive_membership("missing", pid).is_err());

        // Inactive archives, then deletes.
        acct.archive_membership("left", pid).unwrap();
        assert!(acct.membership("left", pid).unwrap().archived);
        let removed = acct.delete_membership("left", pid).unwrap();
        assert_eq!(removed.vtc_did, "left");
        assert!(acct.membership("left", pid).is_none());
    }

    #[test]
    fn a_published_decision_sla_replaces_the_client_timeout() {
        let now = Utc::now();
        let mut acct = Account::default();
        let mut record = CommunityRecord::new_pending(
            "did:webvh:vetted".into(),
            None,
            "top/vetted".into(),
            PersonaId::new(),
            Uuid::new_v4(),
            now,
        );
        // Older than the 7-day default, younger than a 30-day SLA.
        record.requested_at = Some(now - TimeDelta::days(PENDING_TIMEOUT_DAYS + 3));
        acct.add_membership(record);

        let expired = acct.expire_stale_pending_with(now, |c| {
            if c.vtc_did == "did:webvh:vetted" {
                TimeDelta::days(30)
            } else {
                TimeDelta::days(PENDING_TIMEOUT_DAYS)
            }
        });
        assert!(expired.is_empty(), "the community said it may take 30 days");
        assert!(
            !acct.expire_stale_pending(now).is_empty(),
            "the default would have expired it"
        );
    }

    #[test]
    fn expire_stale_pending_sweeps_and_reports() {
        let mut acct = Account::default();
        let pid = PersonaId::new();
        let now = Utc::now();

        let mut stale = community("stale", pid, pending());
        stale.requested_at = Some(now - TimeDelta::days(10));
        acct.add_membership(stale);

        let mut fresh = community("fresh", pid, pending());
        fresh.requested_at = Some(now - TimeDelta::days(1));
        acct.add_membership(fresh);

        acct.add_membership(community("active", pid, CommunityStatus::Active));

        let expired = acct.expire_stale_pending(now);
        assert_eq!(expired, vec![("stale".to_string(), pid)]);
        assert_eq!(
            acct.membership("stale", pid).unwrap().status,
            CommunityStatus::Expired
        );
        assert!(matches!(
            acct.membership("fresh", pid).unwrap().status,
            CommunityStatus::Pending { .. }
        ));
        assert_eq!(
            acct.membership("active", pid).unwrap().status,
            CommunityStatus::Active
        );
    }

    #[test]
    fn needs_attention_covers_pending_and_unacked_terminals() {
        let pid = PersonaId::new();
        assert!(community("v", pid, pending()).needs_attention());
        assert!(!community("v", pid, CommunityStatus::Active).needs_attention());
        assert!(
            !community("v", pid, CommunityStatus::Left).needs_attention(),
            "Left is voluntary — never an action"
        );
        for s in [
            CommunityStatus::Rejected,
            CommunityStatus::Removed,
            CommunityStatus::Expired,
        ] {
            let mut c = community("v", pid, s.clone());
            assert!(c.needs_attention(), "{s:?} should nag until acknowledged");
            c.acknowledge();
            assert!(!c.needs_attention(), "{s:?} clears once acknowledged");
        }
    }

    #[test]
    fn fresh_terminal_outcome_resets_acknowledgement() {
        let pid = PersonaId::new();
        // Acknowledge a Rejected, re-activate, then get Removed: the new terminal
        // outcome must nag again — acknowledgement does not carry across
        // transitions.
        let mut c = community("v", pid, CommunityStatus::Rejected);
        c.acknowledge();
        assert!(!c.needs_attention());
        c.activate(Utc::now());
        assert!(!c.acknowledged, "returning to a live state clears the ack");
        assert!(!c.needs_attention(), "Active never nags");
        c.remove(DecisionEvidence::default());
        assert!(
            c.needs_attention(),
            "a fresh Removed must nag despite the earlier ack"
        );
    }

    #[test]
    fn actions_required_count_excludes_acknowledged_and_archived() {
        let mut acct = Account::default();
        let pid = PersonaId::new();
        acct.add_membership(community("pending", pid, pending()));
        acct.add_membership(community("active", pid, CommunityStatus::Active));

        // Acknowledged Removed → does not count.
        let mut acked = community("acked", pid, CommunityStatus::Removed);
        acked.acknowledge();
        acct.add_membership(acked);

        // Archived (unacknowledged) Expired → hidden, so does not count.
        let mut archived = community("archived", pid, CommunityStatus::Expired);
        archived.archived = true;
        acct.add_membership(archived);

        // Unacknowledged Rejected → counts.
        acct.add_membership(community("rejected", pid, CommunityStatus::Rejected));
        // pending + rejected.
        assert_eq!(acct.actions_required_count(), 2);

        // Acknowledging the rejection drops the count to just the pending.
        acct.membership_mut("rejected", pid).unwrap().acknowledge();
        assert_eq!(acct.actions_required_count(), 1);
    }

    #[test]
    fn multiple_memberships_per_community_keyed_by_persona() {
        let mut acct = Account::default();
        let alice = PersonaId::new();
        let bob = PersonaId::new();
        let vtc = "did:web:acme";
        // The same community joined as two different personas — both coexist.
        acct.add_membership(community(vtc, alice, CommunityStatus::Active));
        acct.add_membership(community(vtc, bob, pending()));
        assert_eq!(acct.memberships_for(vtc).len(), 2);
        assert_eq!(acct.memberships().count(), 2);

        // Per-persona resolution + idempotency: a live membership blocks only the
        // same persona, never another.
        assert!(acct.membership(vtc, alice).is_some());
        assert!(acct.membership(vtc, bob).is_some());
        assert!(acct.has_live_membership(vtc, alice));
        assert!(acct.has_live_membership(vtc, bob));
        assert!(!acct.has_live_membership(vtc, PersonaId::new()));

        // Deleting one membership leaves the other (and the community bucket).
        acct.membership_mut(vtc, alice).unwrap().leave();
        acct.delete_membership(vtc, alice).unwrap();
        assert!(acct.membership(vtc, alice).is_none());
        assert!(acct.membership(vtc, bob).is_some());
    }

    #[test]
    fn legacy_single_record_config_loads_into_grouped_form() {
        // Pre-multi-membership configs stored ONE CommunityRecord per VTC DID —
        // the value was the record object, not a list. It must still load.
        let pid = PersonaId::new();
        let rec = community("did:web:acme", pid, CommunityStatus::Active);
        let legacy = serde_json::json!({
            "vta_did": "",
            "vta_url": "",
            "top_context_id": "",
            "communities": { "did:web:acme": rec },
        });
        let acct: Account = serde_json::from_value(legacy).expect("legacy config loads");
        assert_eq!(acct.memberships().count(), 1);
        assert!(acct.membership("did:web:acme", pid).is_some());

        // And a new-format config (value is a list) round-trips through the same path.
        let modern = serde_json::json!({
            "vta_did": "",
            "vta_url": "",
            "top_context_id": "",
            "communities": { "did:web:acme": [community("did:web:acme", pid, CommunityStatus::Active)] },
        });
        let acct: Account = serde_json::from_value(modern).expect("modern config loads");
        assert_eq!(acct.memberships().count(), 1);
    }
}

#[cfg(test)]
mod forward_compat_tests {
    //! D19 — a record written by a newer build must survive a round trip
    //! through this one. Harmless while a single writer owns the config; data
    //! loss the moment the record is shared (E2).
    use super::*;

    /// The failure this exists to stop: read, write back, and the newer build's
    /// fields are gone.
    #[test]
    fn unknown_persona_fields_survive_a_round_trip() {
        let json = serde_json::json!({
            "persona_id": Uuid::nil(),
            "did": "did:webvh:Qm:example.com:alice",
            "key_refs": [],
            "origin_context_id": "top",
            "created_at": "2026-08-20T00:00:00Z",
            "label": "Alice",
            // Written by a build that knows about something this one does not.
            "recovery_approver": "did:key:zApprover",
            "future_nested": { "a": 1, "b": [true, null] }
        });

        let record: PersonaRecord = serde_json::from_value(json).expect("deserialize");
        assert_eq!(record.label.as_deref(), Some("Alice"));

        let back = serde_json::to_value(&record).expect("serialize");
        assert_eq!(
            back.get("recovery_approver").and_then(|v| v.as_str()),
            Some("did:key:zApprover"),
            "an unknown field was dropped: {back}"
        );
        assert_eq!(
            back.get("future_nested"),
            Some(&serde_json::json!({ "a": 1, "b": [true, null] })),
            "nested structure was not preserved verbatim: {back}"
        );
    }

    /// `CommunityRecord` deserializes through a shadow, so the catch-all has to
    /// live there. This is the test that catches putting it on the wrong type —
    /// the record would compile, and silently drop everything.
    #[test]
    fn unknown_membership_fields_survive_the_shadow() {
        let json = serde_json::json!({
            "vtc_did": "did:webvh:Qm:vtc.example.com:acme",
            "display_name": "Acme",
            "sub_context_id": "top/acme",
            "persona_ref": Uuid::nil(),
            "status": { "state": "active" },
            "invented_by_a_newer_build": "keep me"
        });

        let record: CommunityRecord = serde_json::from_value(json).expect("deserialize");
        let back = serde_json::to_value(&record).expect("serialize");
        assert_eq!(
            back.get("invented_by_a_newer_build")
                .and_then(|v| v.as_str()),
            Some("keep me"),
            "the shadow dropped an unknown field: {back}"
        );
    }

    /// A record with nothing unknown must serialize byte-identically to before
    /// D19 — no empty `extra`, no new keys. Existing configs must not churn.
    #[test]
    fn a_known_record_gains_nothing_on_the_wire() {
        let (id, record) = {
            let id = PersonaId(Uuid::nil());
            (
                id,
                PersonaRecord {
                    persona_id: id,
                    did: "did:webvh:Qm:example.com:alice".to_string(),
                    did_document: None,
                    key_refs: Vec::new(),
                    mediator_did: None,
                    origin_context_id: "top".to_string(),
                    created_at: Utc::now(),
                    label: None,
                    extra: serde_json::Map::new(),
                },
            )
        };
        let _ = id;
        let json = serde_json::to_value(&record).expect("serialize");
        assert!(json.get("extra").is_none(), "{json}");
        let obj = json.as_object().expect("object");
        assert!(
            !obj.keys().any(|k| k.starts_with('_')),
            "unexpected synthetic key: {json}"
        );
    }

    /// The account itself carries unknowns too — it is the outermost shared
    /// record, so losing a field here loses whatever it described.
    #[test]
    fn unknown_account_fields_survive_a_round_trip() {
        let json = serde_json::json!({
            "vta_did": "did:webvh:Qm:vta.example.com:agent",
            "vta_url": "",
            "top_context_id": "top",
            "recovery_approver_set": ["did:key:zA", "did:key:zB"]
        });
        let account: Account = serde_json::from_value(json).expect("deserialize");
        let back = serde_json::to_value(&account).expect("serialize");
        assert_eq!(
            back.get("recovery_approver_set"),
            Some(&serde_json::json!(["did:key:zA", "did:key:zB"])),
            "{back}"
        );
    }

    /// Every account written before the Org DID was dropped carries an
    /// `org_did`. It must still load, and must come back out unchanged — an
    /// older build reading the file again finds what it wrote.
    #[test]
    fn a_legacy_org_did_still_loads_and_is_carried() {
        let json = serde_json::json!({
            "vta_did": "did:webvh:Qm:vta.example.com:agent",
            "vta_url": "",
            "top_context_id": "top",
            "org_did": "did:webvh:QmOrg:org.example.com"
        });
        let account: Account = serde_json::from_value(json).expect("deserialize");
        assert_eq!(account.top_context_id, "top");
        let back = serde_json::to_value(&account).expect("serialize");
        assert_eq!(
            back.get("org_did"),
            Some(&serde_json::json!("did:webvh:QmOrg:org.example.com")),
            "{back}"
        );
    }

    /// A new account no longer writes an `org_did` at all.
    #[test]
    fn a_new_account_writes_no_org_did() {
        let back = serde_json::to_value(Account::default()).expect("serialize");
        assert!(back.get("org_did").is_none(), "{back}");
    }
}