net-cli 0.44.0

Unified `net-mesh` command-line tool for the Net mesh
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
//! `net-mesh org (keygen|issue-cert|issue-floors|grant-dispatcher|
//! grant-capability)` — organization root authority authoring (OA-1
//! belonging + OA-2 grant issuance, `ORG_CAPABILITY_AUTH_PLAN.md`).
//!
//! The org root key is OFFLINE key material: it lives on an
//! operator machine, signs membership certificates and
//! revocation-floor bundles through these verbs, and never touches
//! a mesh node. Key files are TOML at
//! `$XDG_CONFIG_HOME/net-mesh/orgs/` by default, mode 0600 with
//! the same ssh-style permission gate as operator identities:
//!
//! ```toml
//! org_id_hex = "..."                   # 64 hex chars (ed25519 vk)
//! seed_hex   = "..."                   # 64 hex chars (32-byte seed)
//! created_at = "2026-07-16T12:34:56Z"
//! note       = "Payments-fleet owner org"
//! ```
//!
//! Certificates and bundles are NOT secrets (they're signed public
//! statements) and are written as versioned JSON envelopes that
//! `net-mesh node adopt` and config management consume.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use clap::{Args, Subcommand};
use net_sdk::org::{
    CapabilityAuthorityId, DispatcherScope, GrantRights, GrantTargetScope, OrgCapabilityGrant,
    OrgDispatcherGrant, OrgId, OrgKeypair, OrgMembershipCert, OrgRevocationBundle,
    ORG_CERT_TTL_SECS_RECOMMENDED,
};
use serde::{Deserialize, Serialize};

// NB: `write_identity_atomically` is deliberately NOT imported here any more.
// `run_keygen` was its only caller in this file, and its unconditional `rename`
// is exactly why keygen had no race-free clobber boundary (§2). Every publish
// in this module now goes through `stage_beside` + `publish_staged` /
// `publish_staged_replace`.
use crate::commands::identity::{
    enforce_strict_permissions, now_iso8601, parse_entity_hex, read_secret_key_file,
};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
use crate::secret::{zeroize_slice, zeroize_string, ScrubbedBytes, ScrubbedString};

/// Format version of the cert / floors / grant JSON envelopes.
pub(crate) const ORG_FILE_VERSION: u32 = 1;

/// Default dispatcher/capability grant TTL — 7 days. Grant lifetimes are
/// days–weeks (renewal is re-issue + revoke in v1); the primitive hard-caps at
/// 30 days (`MAX_ORG_GRANT_TTL_SECS`), rejected at issue AND at every verifier.
const GRANT_TTL_SECS_DEFAULT: u64 = 7 * 24 * 60 * 60;

#[derive(Subcommand, Debug)]
pub enum OrgCommand {
    /// Generate a fresh org root keypair (offline key material).
    Keygen(KeygenArgs),
    /// Issue a membership certificate: "node `<member>` belongs to
    /// this org". Certificates prove belonging only — never
    /// invocation authority.
    IssueCert(IssueCertArgs),
    /// Issue a signed revocation-floor bundle: every certificate
    /// issued to a listed member below its floor generation is
    /// revoked. Nodes merge bundles monotonically — a lower floor
    /// never rolls back a higher one, including across restart.
    IssueFloors(IssueFloorsArgs),
    /// Issue a dispatcher grant: "entity `<dispatcher>` may act FOR
    /// this org" over a capability (or any). A -> S, org-root-signed;
    /// the caller carries it inside the org-admission proof. Holding
    /// one is never invocation authority — the provider still
    /// verifies the full per-call proof.
    GrantDispatcher(GrantDispatcherArgs),
    /// Issue a capability grant: "org `<grantee>` holds `<rights>` on
    /// `<capability>` over `<target>`", signed by THIS (provider) org.
    /// B -> A cross-org access. With --discover a fresh audience
    /// secret is minted and written owner-only (0600 on Unix; parent
    /// DACL on Windows); only its commitment rides in the signed
    /// grant (the raw key never touches the wire).
    GrantCapability(GrantCapabilityArgs),
    /// Mint the org's shared owner audience once: the key every member uses
    /// to open (and be found in) the org's private announcements. Written
    /// owner-only; keep it with the org root. `org approve --audience` and
    /// `node adopt --audience` hand it to members.
    AudienceKeygen(OrgAudienceKeygenArgs),
    /// Approve a device's pending org invite: sign its membership
    /// certificate here, with the offline org root, for exactly the device
    /// that claimed the invite, and hand it to the running enrolling node,
    /// which delivers it. The root key never reaches a node.
    Approve(OrgApproveArgs),
    /// Create a standalone org link: org membership only, for a device
    /// already on this mesh, redeemed over its session with this node.
    /// Always approval-gated (`org approve`).
    Invite(OrgInviteArgs),
    /// Join an org with a standalone link, through this device's running
    /// `up`. Until the operator approves, the node keeps asking by itself;
    /// once issued it adopts the membership and installs it live.
    Join(OrgJoinArgs),
    /// Remove one member: sign a floor here with the org root (every
    /// membership certificate of that member below `--minimum-generation`
    /// is revoked) and have each named node apply it. Reported per node
    /// from that node's own signed attestation; `complete` only when every
    /// named node attested the floor applied and persisted. Nodes not named
    /// are never assumed.
    Remove(OrgRemoveArgs),
    /// Leave this device's org: record it durably, then stop the running
    /// node; its next `up` runs on the mesh without the org. Local only —
    /// the org still accepts this device's certificate until `org remove`.
    /// Rejoining takes a new link approved with the org root.
    Leave(OrgLeaveArgs),
    /// What the node of `--state-dir` issued for this org (when it enrolls)
    /// and each member's standing against its own floors — explicitly not a
    /// claim about other nodes or about activity.
    Members(OrgMembersArgs),
}

/// `org members` arguments.
#[derive(Args, Debug)]
pub struct OrgMembersArgs {
    /// The org (its 64-hex org id).
    pub org: String,
    /// State directory of the node to ask (as given to `net-mesh up`).
    #[arg(long, value_name = "DIR")]
    pub state_dir: Option<PathBuf>,
    /// Also ask these nodes for each issued member's standing there (signed
    /// observations): `self` or `ENTITY_HEX@HOST:PORT#NOISE_PUBKEY_HEX`.
    /// Needs `--org-key`: only the org may read its inventory.
    #[arg(long = "verifier", value_name = "NODE", requires = "org_key")]
    pub verifiers: Vec<String>,
    /// The org root key file that signs the requests (stays here).
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: Option<PathBuf>,
    /// How long to wait for each node's answer.
    #[arg(long, value_name = "DURATION", default_value = "10s", value_parser = crate::humantime::parse_duration)]
    pub wait: std::time::Duration,
    /// Accept a group/world-readable org key file (Unix).
    #[arg(long)]
    pub insecure_permissions: bool,
}

/// `org leave` arguments.
#[derive(Args, Debug)]
pub struct OrgLeaveArgs {
    /// State directory of this device (as given to `join` and `up`).
    #[arg(long, value_name = "DIR")]
    pub state_dir: Option<PathBuf>,
    /// How long to wait for a running node to stop after recording.
    #[arg(long, value_name = "DURATION", default_value = "15s", value_parser = crate::humantime::parse_duration)]
    pub wait: std::time::Duration,
}

/// `org remove` arguments.
#[derive(Args, Debug)]
pub struct OrgRemoveArgs {
    /// The member's full 64-hex entity id.
    pub member: String,
    /// The org root key file (`org keygen`); stays on this machine.
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,
    /// Revoke every certificate of the member below this generation.
    /// Re-admission later needs `org approve --generation` at or above it.
    #[arg(long, value_name = "N")]
    pub minimum_generation: u32,
    /// A node to apply the floor at: `self` (the node of `--state-dir`) or
    /// `ENTITY_HEX@HOST:PORT#NOISE_PUBKEY_HEX`. Repeat for each enforcement
    /// point.
    #[arg(long = "verifier", value_name = "NODE", required = true)]
    pub verifiers: Vec<String>,
    /// State directory of the operator's running node, which carries the
    /// requests (as given to `net-mesh up`).
    #[arg(long, value_name = "DIR")]
    pub state_dir: Option<PathBuf>,
    /// How long to wait for each node's answer.
    #[arg(long, value_name = "DURATION", default_value = "10s", value_parser = crate::humantime::parse_duration)]
    pub wait: std::time::Duration,
    /// Show what would be signed and asked, without signing or sending.
    #[arg(long)]
    pub dry_run: bool,
    /// Accept a group/world-readable org key file (Unix).
    #[arg(long)]
    pub insecure_permissions: bool,
}

/// `org audience-keygen` arguments.
#[derive(Args, Debug)]
pub struct OrgAudienceKeygenArgs {
    /// The org root key file (names the org the audience belongs to).
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,
    /// Where to write the audience (a new owner-only file).
    #[arg(long, value_name = "PATH")]
    pub out: PathBuf,
    /// Accept a group/world-readable org key file (Unix).
    #[arg(long)]
    pub insecure_permissions: bool,
}

/// `org approve` arguments.
#[derive(Args, Debug)]
pub struct OrgApproveArgs {
    /// Offer id from `invite create` / `invite status`.
    pub offer_id: String,
    /// The full 64-hex device entity you expect to approve; must match the
    /// pending claim.
    #[arg(long, value_name = "ENTITY")]
    pub subject: String,
    /// The org root key file (`org keygen`); stays on this machine.
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,
    /// Membership generation (raise it to re-admit after a revocation floor).
    #[arg(long, default_value_t = 0)]
    pub generation: u32,
    /// The org's shared owner audience (`org audience-keygen`), delivered
    /// with the membership so the device can discover other members'
    /// private services. Without it the device's audience is node-local.
    #[arg(long, value_name = "PATH")]
    pub audience: Option<PathBuf>,
    /// Membership certificate lifetime in seconds.
    #[arg(long = "ttl-secs", default_value_t = ORG_CERT_TTL_SECS_RECOMMENDED)]
    pub ttl_secs: u64,
    /// Accept a group/world-readable org key file (Unix).
    #[arg(long)]
    pub insecure_permissions: bool,
    /// State directory of the enrolling node (as given to `net-mesh up`).
    #[arg(long, value_name = "DIR")]
    pub state_dir: Option<PathBuf>,
}

/// `org invite` arguments.
#[derive(Args, Debug)]
pub struct OrgInviteArgs {
    /// The org (its 64-hex org id).
    pub org: String,
    /// State directory of the enrolling node (as given to `net-mesh up`).
    #[arg(long, value_name = "DIR")]
    pub state_dir: Option<PathBuf>,
    /// Lifetime of the unredeemed link (default 24h).
    #[arg(long, value_name = "DURATION", value_parser = crate::humantime::parse_duration)]
    pub ttl: Option<std::time::Duration>,
    /// Bind the link to one device's full 64-hex entity id.
    #[arg(long = "for", value_name = "ENTITY")]
    pub for_subject: Option<String>,
    /// Write the link to this new owner-only file instead of stdout.
    #[arg(long, value_name = "PATH")]
    pub out: Option<PathBuf>,
}

/// `org join` arguments.
#[derive(Args, Debug)]
pub struct OrgJoinArgs {
    /// The standalone org link, or `-` to read it from stdin (then `--yes`).
    pub token: String,
    /// State directory of this device's running `net-mesh up`.
    #[arg(long, value_name = "DIR")]
    pub state_dir: Option<PathBuf>,
    /// Skip the interactive confirmation (scripts and agent tool use).
    #[arg(long)]
    pub yes: bool,
}

#[derive(Args, Debug)]
pub struct KeygenArgs {
    /// Inspect output selection without generating an org key.
    #[arg(long)]
    pub inspect_target: bool,
    /// Output path. Defaults to
    /// `$XDG_CONFIG_HOME/net-mesh/orgs/org-<id>.toml`.
    #[arg(long)]
    pub out: Option<PathBuf>,

    /// Free-form note saved alongside the key.
    #[arg(long)]
    pub note: Option<String>,

    /// Overwrite an existing file. Refuses by default.
    ///
    /// Replaces an org key at this path — that is what it is for. It will
    /// still refuse to replace a DIFFERENT kind of secret (an operator
    /// identity), however the path is spelled.
    #[arg(long)]
    pub force: bool,

    /// Acknowledge that the org key's mode is not enforced on Windows and
    /// suppress the warning.
    ///
    /// Same gate, same reasoning as `net-mesh org grant-capability --discover`:
    /// deliberately SEPARATE from `--insecure-permissions` (§16), which
    /// relaxes a check on an INPUT rather than silencing the only signal that
    /// a freshly written OUTPUT secret may be readable by others.
    #[arg(long = "accept-windows-dacl")]
    pub accept_windows_dacl: bool,
}

#[derive(Args, Debug)]
pub struct IssueCertArgs {
    /// Inspect signer and output selection without signing.
    #[arg(long)]
    pub inspect_target: bool,
    /// Path to the org root key file (from `net-mesh org keygen`).
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,

    /// The member node's entity id (32-byte ed25519 pubkey, 64 hex
    /// chars, optional `0x`).
    #[arg(long)]
    pub member: String,

    /// Revocation generation stamped into the certificate. Issue
    /// at a generation ≥ the org's current floor for this member;
    /// bump floors via `issue-floors` to retire outstanding certs.
    #[arg(long, default_value_t = 0)]
    pub generation: u32,

    /// Certificate TTL in seconds. Defaults to the recommended ~1
    /// year; hard-capped at 2 years (rejected at issue AND at
    /// every verifier).
    #[arg(long = "ttl-secs", default_value_t = ORG_CERT_TTL_SECS_RECOMMENDED)]
    pub ttl_secs: u64,

    /// Output path for the certificate JSON.
    #[arg(long)]
    pub out: PathBuf,

    /// Overwrite an existing file. Refuses by default.
    #[arg(long)]
    pub force: bool,

    /// Allow permissive org-key file modes on Unix. See
    /// `net-mesh identity show --insecure-permissions`.
    #[arg(long)]
    pub insecure_permissions: bool,
}

#[derive(Args, Debug)]
pub struct IssueFloorsArgs {
    /// Inspect signer and output selection without signing.
    #[arg(long)]
    pub inspect_target: bool,
    /// Path to the org root key file (from `net-mesh org keygen`).
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,

    /// A floor entry `<member-hex>=<generation>`; repeatable.
    /// Certificates for `<member>` with generation below the value
    /// are revoked on every node that merges this bundle.
    #[arg(long = "floor", value_name = "MEMBER=GEN", required = true)]
    pub floors: Vec<String>,

    /// Output path for the bundle JSON.
    #[arg(long)]
    pub out: PathBuf,

    /// Overwrite an existing file. Refuses by default.
    #[arg(long)]
    pub force: bool,

    /// Allow permissive org-key file modes on Unix.
    #[arg(long)]
    pub insecure_permissions: bool,
}

#[derive(Args, Debug)]
pub struct GrantDispatcherArgs {
    /// Inspect signer and output selection without signing.
    #[arg(long)]
    pub inspect_target: bool,
    /// Path to the org root key file for the org the dispatcher acts
    /// FOR — the grant is signed by THIS org.
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,

    /// The dispatcher's entity id (32-byte ed25519 pubkey, 64 hex
    /// chars, optional `0x`) empowered to act for the org.
    #[arg(long)]
    pub dispatcher: String,

    /// The capability tag the dispatcher may act on, e.g.
    /// `nrpc:my-service`. Mutually exclusive with `--any-capability`.
    #[arg(long)]
    pub capability: Option<String>,

    /// Grant the dispatcher EVERY capability (`DispatcherScope::Any`).
    /// Mutually exclusive with `--capability`.
    #[arg(long = "any-capability")]
    pub any_capability: bool,

    /// Grant TTL in seconds. Defaults to 7 days; hard-capped at 30
    /// days (rejected at issue AND at every verifier).
    #[arg(long = "ttl-secs", default_value_t = GRANT_TTL_SECS_DEFAULT)]
    pub ttl_secs: u64,

    /// Output path for the grant JSON.
    #[arg(long)]
    pub out: PathBuf,

    /// Refused: grant artifacts are published no-clobber. A forced replace is
    /// not crash-atomic and, on a case-insensitive filesystem, an aliased
    /// `--out` (e.g. `ORG.TOML` vs `org.toml`) could destroy the org key. Write
    /// to a fresh path, or remove the old file explicitly.
    #[arg(long)]
    pub force: bool,

    /// Allow permissive org-key file modes on Unix.
    #[arg(long)]
    pub insecure_permissions: bool,
}

#[derive(Args, Debug)]
pub struct GrantCapabilityArgs {
    /// Inspect signer and output selection without signing or minting secrets.
    #[arg(long)]
    pub inspect_target: bool,
    /// Path to the org root key file for the ISSUING (provider) org B
    /// — the grant is signed by THIS org.
    #[arg(long = "org-key", value_name = "PATH")]
    pub org_key: PathBuf,

    /// The grantee org id (OrgId of org A, 64 hex chars, optional
    /// `0x`).
    #[arg(long = "grantee-org")]
    pub grantee_org: String,

    /// The capability tag being granted, e.g. `nrpc:my-service`.
    #[arg(long)]
    pub capability: String,

    /// Grant INVOKE rights (call the capability).
    #[arg(long)]
    pub invoke: bool,

    /// Grant DISCOVER rights (privately find B-owned providers of the
    /// capability). Requires `--audience-out`: a fresh audience
    /// secret is minted and written owner-only (0600 on Unix; on
    /// Windows it inherits the parent directory's DACL and a warning
    /// is emitted — see `--insecure-permissions`).
    #[arg(long)]
    pub discover: bool,

    /// Target an EXACT provider node by entity id (64 hex chars).
    /// Mutually exclusive with `--target-any-owned-by`.
    #[arg(long = "target-node")]
    pub target_node: Option<String>,

    /// Target ANY node owned by this org id (64 hex chars). Mutually
    /// exclusive with `--target-node`.
    #[arg(long = "target-any-owned-by")]
    pub target_any_owned_by: Option<String>,

    /// Grant TTL in seconds. Defaults to 7 days; hard-capped at 30
    /// days (rejected at issue AND at every verifier).
    #[arg(long = "ttl-secs", default_value_t = GRANT_TTL_SECS_DEFAULT)]
    pub ttl_secs: u64,

    /// Output path for the grant JSON.
    #[arg(long)]
    pub out: PathBuf,

    /// Output path for the minted audience secret. REQUIRED with
    /// `--discover`; rejected without it. Written owner-only: mode
    /// 0600 on Unix; on Windows it inherits the parent directory's
    /// NTFS DACL (a loud warning is emitted unless
    /// `--insecure-permissions`), so point it at an owner-only parent.
    #[arg(long = "audience-out", value_name = "PATH")]
    pub audience_out: Option<PathBuf>,

    /// Refused: grant artifacts are published no-clobber (the grant +
    /// audience-secret pair is not crash-atomic, and a forced replace could
    /// destroy a case-variant alias of the org key). Write to fresh output
    /// paths, or remove the old files explicitly.
    #[arg(long)]
    pub force: bool,

    /// Allow permissive org-key file modes on Unix.
    #[arg(long)]
    pub insecure_permissions: bool,

    /// Silence the Windows warning that the 0600 audience-secret mode is
    /// unenforceable and the file inherits its parent's NTFS DACL.
    ///
    /// Deliberately SEPARATE from `--insecure-permissions` (§16). The two
    /// gates are unrelated: one relaxes a check on an INPUT the operator
    /// already controls (the org key's own mode), the other suppresses the
    /// only signal that a freshly minted OUTPUT secret may be readable by
    /// others. Sharing a flag meant an operator who added it once on Linux to
    /// get past a 0644 key checked out of git carried it onto Windows, where
    /// it silently suppressed the one warning that platform has.
    #[arg(long = "accept-windows-dacl")]
    pub accept_windows_dacl: bool,
}

pub async fn run(
    cmd: OrgCommand,
    output: Option<OutputFormat>,
    config_path: Option<&Path>,
    profile_name: &str,
) -> Result<(), CliError> {
    let selection = match &cmd {
        OrgCommand::Keygen(args) if args.inspect_target => {
            let profile = crate::context::resolve_profile(config_path, profile_name).await?;
            let mut view = crate::target::TargetInspection::local(&profile, "offline");
            view.unavailable_identity(
                "execution generates a new org root key; its public identity is not yet available",
            );
            view.provenance("identity", "runtime");
            if let Some(path) = &args.out {
                view.destination = Some(path.clone());
                view.provenance("destination", "flag");
            } else {
                view.destination_pattern = Some(
                    default_org_key_dir()
                        .ok_or_else(|| {
                            invalid_args("cannot resolve the platform config directory; pass --out")
                        })?
                        .join("org-<generated-org-id-prefix>.toml"),
                );
                view.provenance("destination", "runtime");
            }
            return view.emit(output);
        }
        OrgCommand::IssueCert(a) if a.inspect_target => {
            Some((&a.org_key, a.insecure_permissions, &a.out, None))
        }
        OrgCommand::IssueFloors(a) if a.inspect_target => {
            Some((&a.org_key, a.insecure_permissions, &a.out, None))
        }
        OrgCommand::GrantDispatcher(a) if a.inspect_target => {
            refuse_force(a.force)?;
            Some((&a.org_key, a.insecure_permissions, &a.out, None))
        }
        OrgCommand::GrantCapability(a) if a.inspect_target => {
            refuse_force(a.force)?;
            validate_audience_output(a.discover, a.audience_out.as_deref())?;
            Some((
                &a.org_key,
                a.insecure_permissions,
                &a.out,
                a.audience_out.as_ref(),
            ))
        }
        _ => None,
    };
    if let Some((key, insecure, destination, audience_destination)) = selection {
        let signer = load_org_key(key, insecure).await?;
        let profile = crate::context::resolve_profile(config_path, profile_name).await?;
        let mut target = crate::target::TargetInspection::local(&profile, "offline");
        target.configured_identity(signer.org_id().as_bytes());
        target.source = Some(key.clone());
        target.destination = Some(destination.clone());
        for field in ["identity", "source", "destination"] {
            target.provenance(field, "flag");
        }
        if audience_destination.is_some() {
            target.provenance("audience_destination", "flag");
        }
        return emit_value(
            OutputFormat::resolve_oneshot(output),
            &OrgInspection {
                target,
                audience_destination,
            },
        )
        .map_err(|e| generic(format!("write inspection: {e}")));
    }
    match cmd {
        OrgCommand::Keygen(args) => run_keygen(args, output).await,
        OrgCommand::IssueCert(args) => run_issue_cert(args, output).await,
        OrgCommand::IssueFloors(args) => run_issue_floors(args, output).await,
        OrgCommand::GrantDispatcher(args) => run_grant_dispatcher(args, output).await,
        OrgCommand::GrantCapability(args) => run_grant_capability(args, output).await,
        OrgCommand::AudienceKeygen(args) => run_audience_keygen(args, output).await,
        OrgCommand::Approve(args) => run_approve(args, output, profile_name).await,
        OrgCommand::Invite(args) => {
            super::enrollment::run_invite(
                super::enrollment::InviteCommand::Create(super::enrollment::CreateArgs {
                    state_dir: args.state_dir,
                    ttl: args.ttl,
                    require_approval: true,
                    for_subject: args.for_subject,
                    out: args.out,
                    addr: None,
                    subnet: None,
                    subnet_rights: None,
                    org: Some(args.org),
                    channel: None,
                    channel_rights: None,
                    standalone: true,
                }),
                output,
                profile_name,
            )
            .await
        }
        OrgCommand::Join(args) => run_org_join(args, output, profile_name).await,
        OrgCommand::Remove(args) => run_remove(args, output, profile_name).await,
        OrgCommand::Members(args) => {
            let org = super::enrollment::parse_org_id(&args.org).map_err(invalid_args)?;
            let remote = match (&args.org_key, args.verifiers.is_empty()) {
                (Some(key), false) => {
                    let keypair = load_org_key(key, args.insecure_permissions).await?;
                    if keypair.org_id() != org {
                        return Err(invalid_args("--org-key is the root of a different org"));
                    }
                    // The org root's key, as the signer the nodes verify
                    // against the org id.
                    let root = net::adapter::net::identity::EntityKeypair::from_bytes(
                        *keypair.secret_bytes(),
                    );
                    drop(keypair);
                    Some(super::lifecycle::RemoteMembers {
                        root,
                        authority: None,
                        verifiers: args.verifiers.clone(),
                        wait: args.wait,
                    })
                }
                _ => None,
            };
            super::lifecycle::run_members(
                "org",
                hex::encode(org.0),
                args.state_dir,
                remote,
                output,
                profile_name,
            )
            .await
        }
        OrgCommand::Leave(args) => {
            super::lifecycle::run_org_leave(args.state_dir, args.wait, output, profile_name).await
        }
    }
}

/// `org remove`: sign the floor here, carry it through the operator's node to
/// each named node, and report each node's verified attestation.
async fn run_remove(
    args: OrgRemoveArgs,
    output: Option<OutputFormat>,
    profile_name: &str,
) -> Result<(), CliError> {
    use net_sdk::org::floors::{OrgFloorAttestation, OrgFloorOutcome, OrgFloorRequest};
    let member = parse_entity_hex(&args.member)?;
    if args.minimum_generation == 0 {
        return Err(invalid_args(
            "--minimum-generation 0 revokes nothing (a floor n revokes certificates below n)",
        ));
    }
    let node_dir = super::lifecycle::state_dir(args.state_dir.clone(), profile_name)?
        .join(super::lifecycle::NODE_SUBDIR);
    let fmt = OutputFormat::resolve_oneshot(output);
    if args.dry_run {
        return emit_value(
            fmt,
            &serde_json::json!({
                "dry_run": true,
                "member": hex::encode(member.as_bytes()),
                "minimum_generation": args.minimum_generation,
                "verifiers": args.verifiers,
                "effect": "none: nothing was signed or sent",
            }),
        )
        .map_err(|e| generic(format!("write result: {e}")));
    }
    // `self` is the operator's node: its entity from its authenticated status.
    let mut targets = Vec::new();
    for v in &args.verifiers {
        if v == "self" {
            let (_, status) =
                super::lifecycle::control_call(&node_dir, serde_json::json!({ "op": "status" }))
                    .await
                    .map_err(|e| {
                        crate::error::connection_failure(format!("operator node: {e:?}"))
                    })?;
            let entity =
                parse_entity_hex(status["node"]["entity_id"].as_str().unwrap_or_default())?;
            targets.push((entity, None));
        } else {
            let contact = super::subnet::parse_verifier(v)?;
            targets.push((contact.entity.clone(), Some(contact)));
        }
    }
    let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
    let mut floors = BTreeMap::new();
    floors.insert(member.clone(), args.minimum_generation);
    let bundle = OrgRevocationBundle::try_issue(&keypair, &floors)
        .map_err(|e| invalid_args(format!("floor: {e}")))?;
    let org = hex::encode(keypair.org_id().as_bytes());
    drop(keypair);

    let mut rows = Vec::with_capacity(targets.len());
    let mut applied = 0usize;
    for (verifier, contact) in &targets {
        let request = OrgFloorRequest::new(&bundle, verifier.clone()).map_err(generic)?;
        let mut call = serde_json::json!({
            "op": "org_floor_forward",
            "request": hex::encode(request.to_bytes()),
            "wait_ms": args.wait.as_millis() as u64,
        });
        if let Some(c) = contact {
            call["addr"] = serde_json::json!(c.addr.to_string());
            call["noise_pubkey"] = serde_json::json!(hex::encode(c.noise_pubkey));
        }
        let entity = hex::encode(verifier.as_bytes());
        let row = match super::lifecycle::control_call_within(
            &node_dir,
            call,
            args.wait + std::time::Duration::from_secs(5),
        )
        .await
        {
            Err(e) => serde_json::json!({
                "verifier": entity, "state": "no_answer", "detail": format!("operator node: {e:?}"),
            }),
            Ok((_, reply)) => match reply["attestation"].as_str() {
                Some(hex_a) => match hex::decode(hex_a)
                    .map_err(|e| e.to_string())
                    .and_then(|b| OrgFloorAttestation::from_bytes(&b))
                    .and_then(|a| a.verify_for(&request).map(|()| a))
                {
                    Ok(a) => {
                        let floor = a.floor_of(&member);
                        let done = a.outcome == OrgFloorOutcome::Applied
                            && floor.is_some_and(|f| f >= args.minimum_generation);
                        if done {
                            applied += 1;
                        }
                        serde_json::json!({
                            "verifier": entity,
                            "state": if done { "applied" } else { a.outcome.as_str() },
                            "floor": floor,
                        })
                    }
                    Err(e) => serde_json::json!({
                        "verifier": entity, "state": "bad_attestation", "detail": e,
                    }),
                },
                None => serde_json::json!({
                    "verifier": entity,
                    "state": if reply["refused"].is_string() { "refused" } else { "no_answer" },
                    "detail": reply["refused"]
                        .as_str()
                        .or_else(|| reply["no_answer"].as_str())
                        .or_else(|| reply["error"].as_str()),
                }),
            },
        };
        rows.push(row);
    }
    emit_value(
        fmt,
        &serde_json::json!({
            "org": org,
            "member": hex::encode(member.as_bytes()),
            "minimum_generation": args.minimum_generation,
            "verifiers": rows,
            "applied": applied,
            "pending": targets.len() - applied,
            "complete": applied == targets.len(),
            // What removal does not do, stated rather than implied.
            "scope": "membership only: the member's transport (the mesh PSK) and any independently granted access are unaffected; nodes not named here were not asked",
        }),
    )
    .map_err(|e| generic(format!("write result: {e}")))
}

/// `org approve`: fetch the pending claim, sign its membership here with the
/// org root, and hand the certificate to the enrolling node.
async fn run_approve(
    args: OrgApproveArgs,
    output: Option<OutputFormat>,
    profile_name: &str,
) -> Result<(), CliError> {
    let expected = parse_entity_hex(&args.subject)?;
    let pending = super::enrollment::node_request(
        args.state_dir.clone(),
        profile_name,
        serde_json::json!({ "op": "invite_org_pending", "offer_id": args.offer_id }),
    )
    .await?;
    let claimed = parse_entity_hex(pending["subject"].as_str().unwrap_or_default())?;
    if claimed != expected {
        return Err(invalid_args(format!(
            "the pending claim is from {}, not the --subject you named",
            pending["subject"].as_str().unwrap_or_default()
        )));
    }
    let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
    let offered = pending["org"].as_str().unwrap_or_default();
    if hex::encode(keypair.org_id().as_bytes()) != offered {
        return Err(invalid_args(format!(
            "--org-key is the root of org {}, but the invite offers org {offered}",
            hex::encode(keypair.org_id().as_bytes())
        )));
    }
    let cert = OrgMembershipCert::try_issue(&keypair, claimed, args.generation, args.ttl_secs)
        .map_err(|e| invalid_args(format!("membership certificate: {e}")))?;
    let audience = match &args.audience {
        Some(path) => {
            let audience = load_org_audience(path, args.insecure_permissions).await?;
            if audience.owner_org != keypair.org_id() {
                return Err(invalid_args(format!(
                    "{} is the audience of another org",
                    path.display()
                )));
            }
            Some(ScrubbedString::new(hex::encode(audience.encode_config())))
        }
        None => None,
    };
    drop(keypair);
    let reply = super::enrollment::node_request(
        args.state_dir,
        profile_name,
        serde_json::json!({
            "op": "invite_org_approve",
            "offer_id": args.offer_id,
            "subject": hex::encode(expected.as_bytes()),
            "cert": hex::encode(cert.to_bytes()),
            "audience": audience.as_ref().map(|a| a.as_str()),
        }),
    )
    .await?;
    emit_value(OutputFormat::resolve_oneshot(output), &reply)
        .map_err(|e| generic(format!("write result: {e}")))
}

/// `org audience-keygen`: mint the org's shared owner audience, owner-only.
async fn run_audience_keygen(
    args: OrgAudienceKeygenArgs,
    output: Option<OutputFormat>,
) -> Result<(), CliError> {
    if args.out.exists() {
        return Err(invalid_args(format!(
            "{} already exists",
            args.out.display()
        )));
    }
    let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
    let audience = net::adapter::net::behavior::org_authority::OwnerAudienceCredential::generate(
        keypair.org_id(),
    );
    drop(keypair);
    let encoded = ScrubbedBytes::new(audience.encode_config().to_vec());
    let tmp = args.out.with_extension("tmp-netmesh-audience");
    crate::commands::identity::write_identity_atomically(&tmp, &args.out, encoded.as_slice())
        .await?;
    emit_value(
        OutputFormat::resolve_oneshot(output),
        &serde_json::json!({
            "org": hex::encode(audience.owner_org.as_bytes()),
            "audience_handle": hex::encode(audience.audience_handle),
            "file": args.out.display().to_string(),
        }),
    )
    .map_err(|e| generic(format!("write result: {e}")))
}

/// Read an org owner-audience file (`org audience-keygen`) through the
/// secret-file gate (regular file, owned by this user, owner-only, checked
/// on the opened descriptor), then decode exactly one credential.
pub(crate) async fn load_org_audience(
    path: &Path,
    insecure_permissions: bool,
) -> Result<net::adapter::net::behavior::org_authority::OwnerAudienceCredential, CliError> {
    use net::adapter::net::behavior::org_authority::OwnerAudienceCredential;
    use std::io::Read as _;
    let owned = path.to_path_buf();
    let read = tokio::task::spawn_blocking(move || {
        let mut file =
            net::adapter::net::secret_file::open_secret_file(&owned, insecure_permissions)
                .map_err(|e| e.to_string())?;
        let mut bytes = Vec::with_capacity(OwnerAudienceCredential::ENCODED_SIZE + 1);
        (&mut file)
            .take(OwnerAudienceCredential::ENCODED_SIZE as u64 + 1)
            .read_to_end(&mut bytes)
            .map_err(|e| e.to_string())?;
        Ok::<_, String>(ScrubbedBytes::new(bytes))
    })
    .await
    .map_err(|e| generic(format!("org audience {}: {e}", path.display())))?
    .map_err(|e| {
        invalid_args(format!(
            "org audience {}: {e}; or pass --insecure-permissions to override",
            path.display()
        ))
    })?;
    if read.as_slice().len() != OwnerAudienceCredential::ENCODED_SIZE {
        return Err(invalid_args(format!(
            "org audience {} is not an org audience file",
            path.display()
        )));
    }
    OwnerAudienceCredential::decode_config(read.as_slice())
        .map_err(|e| invalid_args(format!("org audience {}: {e}", path.display())))
}

/// Bound on the `org join` exchange with the running node.
const ORG_JOIN_CONTROL_WAIT: std::time::Duration = std::time::Duration::from_secs(28);

/// `org join`: show what is being joined, confirm, and hand the link to the
/// running node over its authenticated control endpoint.
async fn run_org_join(
    args: OrgJoinArgs,
    output: Option<OutputFormat>,
    profile_name: &str,
) -> Result<(), CliError> {
    use net_sdk::enrollment::standalone::is_standalone_org;
    let from_stdin = args.token == "-";
    if from_stdin && !args.yes {
        return Err(invalid_args(
            "reading the link from stdin needs --yes (stdin cannot also answer the prompt)",
        ));
    }
    let token = if from_stdin {
        use tokio::io::AsyncReadExt as _;
        let mut buf = String::new();
        tokio::io::stdin()
            .take(4096)
            .read_to_string(&mut buf)
            .await
            .map_err(|e| generic(format!("read link from stdin: {e}")))?;
        ScrubbedString::new(buf.trim().to_string())
    } else {
        ScrubbedString::new(args.token)
    };
    let invite = net_sdk::enrollment::invite::MembershipInvite::decode(token.as_str())
        .map_err(|e| invalid_args(format!("not a valid link: {e}")))?;
    let offer = match invite.org() {
        Some(offer) if is_standalone_org(&invite) => *offer,
        _ => {
            return Err(invalid_args(
                "not a standalone org link (a mesh invite is redeemed with `net-mesh join`)",
            ))
        }
    };
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    if now >= invite.policy().expires_at() {
        return Err(invalid_args("this link has expired; ask for a new one"));
    }
    eprintln!(
        "Joining org {} under issuer {}\n  membership only (no dispatcher or capability right); \
         the operator approves it with the org root",
        hex::encode(offer.org.as_bytes()),
        invite.issuer_fingerprint(),
    );
    let tty = {
        use std::io::IsTerminal as _;
        std::io::stdin().is_terminal() && !from_stdin
    };
    let yes = args.yes;
    tokio::task::spawn_blocking(move || {
        super::ice::check_confirm_gate(tty, yes, || {
            use std::io::{BufRead as _, Write as _};
            let mut err = std::io::stderr();
            write!(
                err,
                "Confirm the org and issuer are the ones you expect. Type YES to join: "
            )
            .and_then(|()| err.flush())
            .map_err(|e| generic(format!("prompt: {e}")))?;
            let mut line = String::new();
            std::io::stdin()
                .lock()
                .read_line(&mut line)
                .map_err(|e| generic(format!("prompt: {e}")))?;
            Ok(line.trim() == "YES")
        })
    })
    .await
    .map_err(|e| generic(format!("confirmation task failed: {e}")))??;

    let node_dir = super::lifecycle::state_dir(args.state_dir, profile_name)?
        .join(super::lifecycle::NODE_SUBDIR);
    let (_, reply) = super::lifecycle::control_call_within(
        &node_dir,
        serde_json::json!({ "op": "org_join", "token": token.as_str() }),
        ORG_JOIN_CONTROL_WAIT,
    )
    .await
    .map_err(|e| {
        crate::error::connection_failure(format!(
            "this device's node is not reachable ({e:?}); `org join` runs through a running \
             `net-mesh up`"
        ))
    })?;
    if let Some(e) = reply["error"].as_str() {
        return Err(generic(e.to_string()));
    }
    emit_value(OutputFormat::resolve_oneshot(output), &reply)
        .map_err(|e| generic(format!("write result: {e}")))
}

#[derive(Serialize)]
struct OrgInspection<'a> {
    #[serde(flatten)]
    target: crate::target::TargetInspection,
    #[serde(skip_serializing_if = "Option::is_none")]
    audience_destination: Option<&'a PathBuf>,
}

fn validate_audience_output(discover: bool, output: Option<&Path>) -> Result<(), CliError> {
    match (discover, output) {
        (true, None) => Err(invalid_args(
            "--discover requires --audience-out <PATH> (where to write the minted audience secret)",
        )),
        (false, Some(_)) => Err(invalid_args(
            "--audience-out is only valid with --discover (no secret is minted otherwise)",
        )),
        _ => Ok(()),
    }
}

// =========================================================================
// keygen
// =========================================================================

async fn run_keygen(args: KeygenArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
    let keypair = OrgKeypair::generate();
    let org_id_hex = hex::encode(keypair.org_id().as_bytes());

    let path = match args.out {
        Some(explicit) => explicit,
        None => default_org_key_path(&org_id_hex).ok_or_else(|| {
            invalid_args(
                "cannot resolve the platform config directory, and refusing to fall back to \
                 the working directory — this file holds the ORG ROOT SEED. Pass an explicit \
                 --out."
                    .to_string(),
            )
        })?,
    };
    // UX pre-check only — the real no-clobber boundary is the hard link in
    // `publish_staged` below. Before §2's closure this stat WAS the only
    // boundary keygen had, because it published through the identity helper's
    // unconditional `rename`.
    refuse_existing(&path, args.force).await?;
    if args.force {
        // `--force` here means "replace the org key at this path". It must not
        // mean "replace whatever happens to be at this path" — an operator
        // identity at the destination is a different secret and a different
        // mistake.
        refuse_replacing_foreign_seed(&path, SeedArtifact::OrgKey).await?;
    }

    let file = OrgKeyFile {
        org_id_hex: org_id_hex.clone(),
        seed_hex: hex::encode(keypair.secret_bytes()),
        created_at: now_iso8601(),
        note: args.note.clone(),
    };
    let toml_text = ScrubbedString::new(
        toml::to_string_pretty(&file)
            .map_err(|e| generic(format!("failed to serialize org key TOML: {e}")))?,
    );

    // Stage beside the destination, then publish — the same race-free path every
    // OTHER artifact in this file uses. keygen previously went through the
    // identity helper, whose `create_new` guards only the TEMP and whose
    // unconditional `rename` always replaces: the `try_exists` above was
    // therefore keygen's ONLY clobber protection, contradicting
    // `refuse_existing`'s own docstring, and it lost every race. Staging also
    // gets a `stage_nonce()` temp name, so a stale temp from a killed run no
    // longer wedges the path against `create_new` the way the bare-pid name did.
    //
    // `toml_text` carries the serialized seed and scrubs on EVERY exit via its
    // Drop guard — including a failed stage, publish, or permission-enforcement
    // step, not only the success tail (Kyra OA2-F). `file` scrubs its own
    // `seed_hex` on Drop.
    let tmp = stage_beside(&path, toml_text.as_bytes(), true).await?;
    if args.force {
        publish_staged_replace(&tmp, &path).await?;
    } else {
        publish_staged(&tmp, &path).await?;
    }
    enforce_strict_permissions(&path).await?;
    // The org root seed is the most sensitive file this CLI writes, and on
    // Windows it has no 0600 analog — it inherits the parent directory's DACL.
    // Both LESSER secrets already warn here (the minted audience secret via this
    // same helper, identity reads via `check_strict_permissions`); the key that
    // signs all membership and revocation was the one that stayed silent.
    warn_secret_permissions(&path, args.accept_windows_dacl);

    // Public summary only — never the seed.
    let summary = OrgKeySummary {
        path: path.display().to_string(),
        org_id_hex,
        created_at: file.created_at.clone(),
        note: file.note.clone(),
    };
    emit_value(OutputFormat::resolve_oneshot(output), &summary)
        .map_err(|e| generic(format!("write summary: {e}")))?;
    Ok(())
}

// =========================================================================
// issue-cert
// =========================================================================

async fn run_issue_cert(args: IssueCertArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
    let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
    let member = parse_entity_hex(&args.member)?;

    let cert =
        OrgMembershipCert::try_issue(&keypair, member.clone(), args.generation, args.ttl_secs)
            .map_err(|e| invalid_args(format!("issue-cert: {e}")))?;

    // The org root key must never be a publication target — with `--force` the
    // old `refuse_existing` + `tokio::fs::write` pair would truncate it in
    // place, destroying the only key that can issue certs or revocation floors
    // for this org, with every outstanding cert left valid until natural expiry
    // (§2). The alias guard runs REGARDLESS of `--force`.
    refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
    refuse_existing(&args.out, args.force).await?;
    let json = serialize_json(&OrgCertFile {
        version: ORG_FILE_VERSION,
        cert: cert.clone(),
    })?;
    publish_json_artifact(&args.out, &json, args.force).await?;

    let summary = IssueCertOutput {
        path: args.out.display().to_string(),
        org_id_hex: hex::encode(cert.org_id.as_bytes()),
        member_hex: hex::encode(member.as_bytes()),
        generation: cert.generation,
        not_before: cert.not_before,
        not_after: cert.not_after,
    };
    emit_value(OutputFormat::resolve_oneshot(output), &summary)
        .map_err(|e| generic(format!("write summary: {e}")))?;
    Ok(())
}

// =========================================================================
// issue-floors
// =========================================================================

async fn run_issue_floors(
    args: IssueFloorsArgs,
    output: Option<OutputFormat>,
) -> Result<(), CliError> {
    let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;

    let mut floors = BTreeMap::new();
    for raw in &args.floors {
        let (member_raw, gen_raw) = raw.split_once('=').ok_or_else(|| {
            invalid_args(format!("--floor '{raw}' must be <member-hex>=<generation>"))
        })?;
        let member = parse_entity_hex(member_raw)?;
        let generation: u32 = gen_raw
            .parse()
            .map_err(|e| invalid_args(format!("--floor '{raw}': generation must be a u32: {e}")))?;
        // Duplicate members: highest wins, silently merging two
        // entries would hide an operator typo — refuse instead.
        if floors.insert(member, generation).is_some() {
            return Err(invalid_args(format!(
                "--floor lists member {member_raw} more than once"
            )));
        }
    }

    let bundle = OrgRevocationBundle::try_issue(&keypair, &floors)
        .map_err(|e| invalid_args(format!("issue-floors: {e}")))?;

    // Same boundary as issue-cert: `--out` must never alias `--org-key`, with
    // or without `--force` (§2).
    refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
    refuse_existing(&args.out, args.force).await?;
    let json = serialize_json(&OrgFloorsFile {
        version: ORG_FILE_VERSION,
        bundle: bundle.clone(),
    })?;
    publish_json_artifact(&args.out, &json, args.force).await?;

    let summary = IssueFloorsOutput {
        path: args.out.display().to_string(),
        org_id_hex: hex::encode(bundle.org_id.as_bytes()),
        floors: bundle.floors().len(),
        issued_at: bundle.issued_at,
    };
    emit_value(OutputFormat::resolve_oneshot(output), &summary)
        .map_err(|e| generic(format!("write summary: {e}")))?;
    Ok(())
}

// =========================================================================
// grant-dispatcher
// =========================================================================

async fn run_grant_dispatcher(
    args: GrantDispatcherArgs,
    output: Option<OutputFormat>,
) -> Result<(), CliError> {
    refuse_force(args.force)?;
    let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
    let dispatcher = parse_entity_hex(&args.dispatcher)?;

    // Exactly one of --capability / --any-capability.
    let (scope, capability_label) = match (&args.capability, args.any_capability) {
        (Some(tag), false) => (
            DispatcherScope::Exact(CapabilityAuthorityId::for_tag(tag)),
            tag.clone(),
        ),
        (None, true) => (DispatcherScope::Any, "any".to_string()),
        (Some(_), true) => {
            return Err(invalid_args(
                "--capability and --any-capability are mutually exclusive",
            ))
        }
        (None, false) => {
            return Err(invalid_args(
                "one of --capability <tag> or --any-capability is required",
            ))
        }
    };

    let grant = OrgDispatcherGrant::try_issue(&keypair, dispatcher.clone(), scope, args.ttl_secs)
        .map_err(|e| invalid_args(format!("grant-dispatcher: {e}")))?;

    // Staged no-clobber publish — never overwrite the org key, never follow /
    // truncate a leaf symlink (Kyra OA2-F). `--force` is refused (see
    // `refuse_force`): a remove-then-link replace is not crash-atomic.
    refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
    let json = serialize_json(&OrgDispatcherGrantFile {
        version: ORG_FILE_VERSION,
        grant: grant.clone(),
    })?;
    let tmp = stage_beside(&args.out, &json, false).await?;
    publish_staged(&tmp, &args.out).await?;

    let summary = GrantDispatcherOutput {
        path: args.out.display().to_string(),
        org_id_hex: hex::encode(grant.org_id.as_bytes()),
        dispatcher_hex: hex::encode(dispatcher.as_bytes()),
        capability: capability_label,
        not_before: grant.not_before,
        not_after: grant.not_after,
    };
    emit_value(OutputFormat::resolve_oneshot(output), &summary)
        .map_err(|e| generic(format!("write summary: {e}")))?;
    Ok(())
}

// =========================================================================
// grant-capability
// =========================================================================

async fn run_grant_capability(
    args: GrantCapabilityArgs,
    output: Option<OutputFormat>,
) -> Result<(), CliError> {
    refuse_force(args.force)?;
    let issuer = load_org_key(&args.org_key, args.insecure_permissions).await?;
    let grantee_org = parse_org_hex(&args.grantee_org)?;
    let capability = CapabilityAuthorityId::for_tag(&args.capability);

    // Rights: at least one of --invoke / --discover.
    let rights = match (args.invoke, args.discover) {
        (false, false) => {
            return Err(invalid_args(
                "at least one of --invoke or --discover is required",
            ))
        }
        (true, false) => GrantRights::INVOKE,
        (false, true) => GrantRights::DISCOVER,
        (true, true) => GrantRights::INVOKE.union(GrantRights::DISCOVER),
    };
    let mut rights_labels = Vec::new();
    if args.invoke {
        rights_labels.push("invoke");
    }
    if args.discover {
        rights_labels.push("discover");
    }

    // Audience-secret discipline: an audience file is minted iff
    // --discover, so require --audience-out exactly then.
    validate_audience_output(args.discover, args.audience_out.as_deref())?;

    // Aliased paths would clobber the org key or collide the two output
    // artifacts (Kyra OA2-F).
    let mut alias_paths: Vec<(&str, &Path)> = vec![
        ("--org-key", args.org_key.as_path()),
        ("--out", args.out.as_path()),
    ];
    if let Some(audience_out) = &args.audience_out {
        alias_paths.push(("--audience-out", audience_out.as_path()));
    }
    refuse_aliased_paths(&alias_paths)?;

    // Target: exactly one of --target-node / --target-any-owned-by.
    let (target_scope, target_label) = match (&args.target_node, &args.target_any_owned_by) {
        (Some(entity_hex), None) => {
            let entity = parse_entity_hex(entity_hex)?;
            let label = format!("node:{}", hex::encode(entity.as_bytes()));
            (GrantTargetScope::ExactNode(entity), label)
        }
        (None, Some(org_hex)) => {
            let org = parse_org_hex(org_hex)?;
            let label = format!("any-owned-by:{}", hex::encode(org.as_bytes()));
            (GrantTargetScope::AnyNodeOwnedBy(org), label)
        }
        (Some(_), Some(_)) => {
            return Err(invalid_args(
                "--target-node and --target-any-owned-by are mutually exclusive",
            ))
        }
        (None, None) => {
            return Err(invalid_args(
                "one of --target-node <entity> or --target-any-owned-by <org> is required",
            ))
        }
    };

    let (grant, secret) = OrgCapabilityGrant::try_issue(
        &issuer,
        grantee_org,
        capability,
        rights,
        target_scope,
        args.ttl_secs,
    )
    .map_err(|e| invalid_args(format!("grant-capability: {e}")))?;

    // Publish as STAGED NO-CLOBBER with error rollback (Kyra OA2-F): stage both
    // artifacts in their destination dirs, then publish each no-clobber; if the
    // second publish fails, roll back the first so a partial run never leaves a
    // grant without its secret (or the reverse). This is NOT crash-atomic across
    // the two files (a crash between them can leave one), so `--force` is refused
    // (`refuse_force`) and operators write to fresh paths. Temps are cleaned up
    // synchronously on failure. The raw discovery key lives ONLY in the in-memory
    // scrub guard and the audience file — never in the grant, never on the wire.
    let grant_json = serialize_json(&OrgCapabilityGrantFile {
        version: ORG_FILE_VERSION,
        grant: grant.clone(),
    })?;
    let audience_out_label = match secret {
        Some(secret) => {
            let audience_out = args
                .audience_out
                .as_ref()
                .expect("--discover requires --audience-out (validated above)");
            // The guard scrubs this in-memory copy of the raw key on EVERY exit —
            // including the `?` on grant staging below (Kyra OA2-F). Scrub the
            // source array too so the `encode_config()` temporary isn't left in
            // freed stack memory.
            let mut raw = secret.encode_config();
            let secret_bytes = ScrubbedBytes::new(raw.to_vec());
            zeroize_slice(&mut raw);
            let grant_tmp = stage_beside(&args.out, &grant_json, false).await?;
            let secret_tmp = match stage_beside(audience_out, secret_bytes.as_slice(), true).await {
                Ok(t) => t,
                Err(e) => {
                    remove_file_or_warn(&grant_tmp, "staging temp").await;
                    return Err(e);
                }
            };
            // The secret is persisted to its staged file now; drop the in-memory
            // copy (scrubs).
            drop(secret_bytes);
            // Grant first (no-clobber).
            if let Err(e) = publish_staged(&grant_tmp, &args.out).await {
                remove_file_or_warn(
                    &secret_tmp,
                    "staging temp (holds a copy of the audience secret)",
                )
                .await;
                return Err(e);
            }
            // Then the secret; roll back the grant if it fails so we never leave
            // a grant without its matching secret.
            if let Err(e) = publish_staged(&secret_tmp, audience_out).await {
                remove_file_or_warn(
                    &args.out,
                    "grant (rollback: its audience secret failed to publish)",
                )
                .await;
                return Err(e);
            }
            warn_secret_permissions(audience_out, args.accept_windows_dacl);
            Some(audience_out.display().to_string())
        }
        None => {
            let tmp = stage_beside(&args.out, &grant_json, false).await?;
            publish_staged(&tmp, &args.out).await?;
            None
        }
    };

    let summary = GrantCapabilityOutput {
        path: args.out.display().to_string(),
        audience_out: audience_out_label,
        grant_id_hex: hex::encode(grant.grant_id),
        issuer_org_hex: hex::encode(grant.issuer_org.as_bytes()),
        grantee_org_hex: hex::encode(grant.grantee_org.as_bytes()),
        capability: args.capability.clone(),
        rights: rights_labels.join(","),
        target: target_label,
        not_before: grant.not_before,
        not_after: grant.not_after,
    };
    emit_value(OutputFormat::resolve_oneshot(output), &summary)
        .map_err(|e| generic(format!("write summary: {e}")))?;
    Ok(())
}

// =========================================================================
// Disk shapes
// =========================================================================

#[derive(Serialize, Deserialize)]
struct OrgKeyFile {
    org_id_hex: String,
    seed_hex: String,
    created_at: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    note: Option<String>,
}

impl Drop for OrgKeyFile {
    fn drop(&mut self) {
        // The org root seed rides in `seed_hex`; scrub it on drop so a lingering
        // copy isn't left in freed memory (Kyra OA2-F P1). No `Debug` derive:
        // this struct must never render the seed into a log line.
        zeroize_string(&mut self.seed_hex);
    }
}

/// Versioned JSON envelope for a membership certificate (the cert
/// itself renders as hex of its canonical 156-byte wire form).
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgCertFile {
    pub(crate) version: u32,
    pub(crate) cert: OrgMembershipCert,
}

/// Versioned JSON envelope for a revocation-floor bundle.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgFloorsFile {
    pub(crate) version: u32,
    pub(crate) bundle: OrgRevocationBundle,
}

/// Versioned JSON envelope for a dispatcher grant (the grant renders
/// as hex of its canonical 185-byte wire form).
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgDispatcherGrantFile {
    pub(crate) version: u32,
    pub(crate) grant: OrgDispatcherGrant,
}

/// Versioned JSON envelope for a capability grant (the grant renders
/// as hex of its canonical 318-byte wire form). The signed grant
/// carries only the audience-key COMMITMENT — never the raw key.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgCapabilityGrantFile {
    pub(crate) version: u32,
    pub(crate) grant: OrgCapabilityGrant,
}

#[derive(Debug, Serialize)]
struct OrgKeySummary {
    path: String,
    org_id_hex: String,
    created_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    note: Option<String>,
}

#[derive(Debug, Serialize)]
struct IssueCertOutput {
    path: String,
    org_id_hex: String,
    member_hex: String,
    generation: u32,
    not_before: u64,
    not_after: u64,
}

#[derive(Debug, Serialize)]
struct IssueFloorsOutput {
    path: String,
    org_id_hex: String,
    floors: usize,
    issued_at: u64,
}

#[derive(Debug, Serialize)]
struct GrantDispatcherOutput {
    path: String,
    org_id_hex: String,
    dispatcher_hex: String,
    capability: String,
    not_before: u64,
    not_after: u64,
}

#[derive(Debug, Serialize)]
struct GrantCapabilityOutput {
    path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    audience_out: Option<String>,
    grant_id_hex: String,
    issuer_org_hex: String,
    grantee_org_hex: String,
    capability: String,
    rights: String,
    target: String,
    not_before: u64,
    not_after: u64,
}

// =========================================================================
// Helpers
// =========================================================================

/// Grant artifacts are published NO-CLOBBER: `--force` is refused for the grant
/// verbs because a remove-then-link replace is not crash-atomic (a crash between
/// the two loses the old artifact) and, on a case-insensitive filesystem, an
/// aliased `--out` (e.g. `ORG.TOML` vs `org.toml`) could destroy the org key.
/// Write to a fresh path, or remove the old artifact explicitly (Kyra OA2-F).
fn refuse_force(force: bool) -> Result<(), CliError> {
    if force {
        return Err(invalid_args(
            "--force is refused for grant commands: publication is no-clobber (a forced replace \
             is not crash-atomic and, on a case-insensitive filesystem, an aliased output could \
             destroy the org key). Write to a fresh path, or remove the old artifact explicitly.",
        ));
    }
    Ok(())
}

/// Load + parse an org key file, honoring the ssh-style permission
/// gate (the seed is root-of-trust material for the whole org).
async fn load_org_key(path: &Path, insecure_permissions: bool) -> Result<OrgKeypair, CliError> {
    // The file text carries the raw seed. Scrub it on EVERY exit.
    let mut text = read_secret_key_file(path, "org key file", insecure_permissions).await?;
    let outcome = load_org_key_from_text(&text, path);
    zeroize_string(&mut text);
    outcome
}

/// Parse + validate the (secret-bearing) org key text. Kept separate so the
/// caller can scrub the source text unconditionally on return.
fn load_org_key_from_text(text: &str, path: &Path) -> Result<OrgKeypair, CliError> {
    // NEVER interpolate the `toml::de::Error`: its `Display` embeds the
    // offending source LINE, which for this file is the secret `seed_hex`
    // (Kyra OA2-F P1 — a malformed seed line was reproduced verbatim in
    // stderr). Report a sanitized category only. `parsed` scrubs `seed_hex`
    // on its own `Drop` at the end of this function.
    let parsed: OrgKeyFile = toml::from_str(text).map_err(|_| {
        invalid_args(format!(
            "org key file {} is not valid TOML (kind: parse_error)",
            path.display()
        ))
    })?;
    // Decode into an RAII-scrubbed buffer so the seed clears on EVERY return
    // (hex error, length error, mismatch, success, or unwind) — not only a
    // manually placed cleanup statement at the tail (Kyra OA2-F). On error report
    // the category, never the offending value.
    let seed_bytes = ScrubbedBytes::new(hex::decode(parsed.seed_hex.as_bytes()).map_err(|_| {
        invalid_args(format!(
            "org key file {} seed_hex is not valid hex (kind: bad_seed_encoding)",
            path.display()
        ))
    })?);
    if seed_bytes.as_slice().len() != 32 {
        return Err(invalid_args(format!(
            "org key file {} seed must be 32 bytes (64 hex chars), got {} (kind: bad_seed_length)",
            path.display(),
            seed_bytes.as_slice().len()
        )));
    }
    let mut seed = [0u8; 32];
    seed.copy_from_slice(seed_bytes.as_slice());
    let keypair = OrgKeypair::from_bytes(seed);
    zeroize_slice(&mut seed);
    // Consistency check: a hand-edited org_id_hex that disagrees with the seed
    // would otherwise sign as one org while claiming another.
    let derived = hex::encode(keypair.org_id().as_bytes());
    if !parsed.org_id_hex.eq_ignore_ascii_case(&derived) {
        return Err(invalid_args(format!(
            "org key file {}: org_id_hex does not match the key derived from seed_hex",
            path.display()
        )));
    }
    Ok(keypair)
}

/// Parse a 32-byte org id from hex (optional `0x` prefix).
fn parse_org_hex(s: &str) -> Result<OrgId, CliError> {
    let s = s.strip_prefix("0x").unwrap_or(s);
    let bytes =
        hex::decode(s).map_err(|e| invalid_args(format!("org id is not valid hex: {e}")))?;
    let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
        invalid_args(format!(
            "org id must be 32 bytes (64 hex chars), got {}",
            bytes.len()
        ))
    })?;
    Ok(OrgId::from_bytes(arr))
}

/// Lexically normalize a path (absolute, `.`-collapsed) for alias comparison —
/// enough to catch a path passed as two outputs or aliased onto the input,
/// within the trustworthy-parent boundary (no fs access, so `..` / symlinks are
/// not resolved). This is a best-effort UX guard, case-sensitive; the actual
/// safety comes from no-clobber publication plus the `--force` refusal, not from
/// this comparison (Kyra OA2-F).
fn normalize_for_alias(p: &Path) -> PathBuf {
    std::path::absolute(p)
        .unwrap_or_else(|_| p.to_path_buf())
        .components()
        .collect()
}

/// Refuse aliased input/output paths (Kyra OA2-F): the org key must not be
/// overwritten, and two output artifacts must not collide (which would leave a
/// grant without its secret, or the reverse).
pub(crate) fn refuse_aliased_paths(paths: &[(&str, &Path)]) -> Result<(), CliError> {
    for i in 0..paths.len() {
        for j in (i + 1)..paths.len() {
            if normalize_for_alias(paths[i].1) == normalize_for_alias(paths[j].1) {
                return Err(invalid_args(format!(
                    "{} and {} resolve to the same path; refusing to alias them",
                    paths[i].0, paths[j].0
                )));
            }
        }
    }
    Ok(())
}

fn serialize_json<T: Serialize>(value: &T) -> Result<Vec<u8>, CliError> {
    serde_json::to_vec_pretty(value).map_err(|e| generic(format!("failed to serialize: {e}")))
}

/// A per-run stage nonce (pid + wall-clock nanos) so a stale temp left by a
/// crashed prior run never blocks a fresh `create_new`.
fn stage_nonce() -> String {
    let pid = std::process::id();
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    format!("{pid}.{nanos}")
}

/// Stage `bytes` as a temp file in the SAME directory as `final_path`
/// (`create_new` + `fsync`; on Unix mode 0600 when `secret`, else 0644; on
/// Windows the file inherits the parent directory's DACL). The temp is
/// hard-linked onto the final path at publish, so a pre-existing leaf (incl. a
/// symlink) is never followed or truncated. Returns the temp path.
pub(crate) async fn stage_beside(
    final_path: &Path,
    bytes: &[u8],
    secret: bool,
) -> Result<PathBuf, CliError> {
    if let Some(parent) = final_path.parent() {
        if !parent.as_os_str().is_empty() {
            tokio::fs::create_dir_all(parent).await.map_err(|e| {
                generic(format!(
                    "failed to create parent directory {}: {e}",
                    parent.display()
                ))
            })?;
        }
    }
    let file_name = final_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("artifact");
    let tmp = final_path.with_file_name(format!(".{file_name}.stage.{}", stage_nonce()));
    let tmp_owned = tmp.clone();
    // Wrap the copied payload so it scrubs on EVERY exit of the blocking task
    // (open/write/sync failure, success, or unwind) — not only a success tail
    // reached after all fallible steps (Kyra OA2-F).
    let payload = ScrubbedBytes::new(bytes.to_vec());
    tokio::task::spawn_blocking(move || -> std::io::Result<()> {
        let mut opts = std::fs::OpenOptions::new();
        opts.write(true).create_new(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            opts.mode(if secret { 0o600 } else { 0o644 });
        }
        #[cfg(not(unix))]
        {
            // On Windows the temp inherits the parent directory's NTFS DACL; the
            // 0600 request has no std analog (warned about at publish time).
            let _ = secret;
        }
        // `create_new`: on open failure we created nothing (never remove a file
        // we don't own — the AlreadyExists case is someone else's file). Once
        // open succeeds we own the temp, so remove it SYNCHRONOUSLY on any
        // subsequent write/sync failure — a partial (possibly secret) temp is
        // never left behind (Kyra OA2-F).
        let mut f = opts.open(&tmp_owned)?;
        let written = (|| -> std::io::Result<()> {
            std::io::Write::write_all(&mut f, payload.as_slice())?;
            f.sync_all()
        })();
        if let Err(e) = written {
            drop(f);
            let _ = std::fs::remove_file(&tmp_owned);
            return Err(e);
        }
        Ok(())
        // `payload` drops here (and on every early return above), scrubbing the
        // copied bytes.
    })
    .await
    .map_err(|e| generic(format!("stage-write task panicked: {e}")))?
    .map_err(|e| generic(format!("failed to stage {}: {e}", tmp.display())))?;
    Ok(tmp)
}

/// Publish a staged temp onto `final_path` with NO-CLOBBER semantics: hard-link
/// (fails if `final_path` exists — never follows/truncates a leaf), then unlink
/// the temp and `fsync` the parent dir. There is NO forced-replace path: a
/// remove-then-link is not crash-atomic (a crash between the two loses the old
/// artifact), so `--force` is refused upstream (`refuse_force`). On a hard-link
/// failure the temp is cleaned up SYNCHRONOUSLY, and a failure to remove the
/// temp after a SUCCESSFUL publish is surfaced LOUDLY (a lingering `*.stage.*`
/// may be an extra name for a secret payload) — never silently ignored (Kyra
/// OA2-F).
pub(crate) async fn publish_staged(tmp: &Path, final_path: &Path) -> Result<(), CliError> {
    let tmp_owned = tmp.to_path_buf();
    let final_owned = final_path.to_path_buf();
    let link = tokio::task::spawn_blocking(move || std::fs::hard_link(&tmp_owned, &final_owned))
        .await
        .map_err(|e| generic(format!("publish task panicked: {e}")))?;
    if let Err(e) = link {
        remove_file_or_warn(tmp, "staging temp").await;
        return Err(if e.kind() == std::io::ErrorKind::AlreadyExists {
            invalid_args(format!(
                "file already exists at {}; publication is no-clobber — write to a fresh path \
                 or remove the old artifact explicitly",
                final_path.display()
            ))
        } else {
            generic(format!("failed to publish {}: {e}", final_path.display()))
        });
    }
    // The final path is now an independent name for the inode; drop the temp.
    // A removal failure here is surfaced loudly (never ignored) — a lingering
    // secret stage temp is an extra owner-only name for that secret.
    remove_file_or_warn(tmp, "staging temp").await;
    sync_parent_dir(final_path).await;
    Ok(())
}

/// Remove a transient artifact, warning LOUDLY with the exact path if removal
/// fails (best-effort by nature — the publish already succeeded or is being
/// rolled back — but never silent). A lingering `*.stage.*` for a secret payload
/// is an extra owner-only name for that secret (Kyra OA2-F).
async fn remove_file_or_warn(path: &Path, what: &str) {
    match tokio::fs::remove_file(path).await {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => eprintln!(
            "warning: failed to remove {what} {}: {e}; remove it manually.",
            path.display()
        ),
    }
}

/// On Windows the CLI has no clean 0600 analog from `std::fs`, so a secret file
/// inherits its parent directory's NTFS DACL. Surface the SAME loud warning the
/// org-key read path uses so a permissive ACL is at least observable in operator
/// logs; `--accept-windows-dacl` suppresses it. NOT `--insecure-permissions`:
/// §16 split the two precisely because one relaxes a check on an INPUT the
/// operator already controls, while this silences the only signal that a
/// freshly written OUTPUT secret may be readable by others — and this doc
/// still named the flag that split was designed to steer them away from.
/// No-op on Unix, where the file was already created mode 0600. No new
/// ACL engine — the custom `--audience-out` parent is operator-asserted trusted
/// (Kyra OA2-F).
#[cfg(not(unix))]
pub(crate) fn warn_secret_permissions(path: &Path, accepted: bool) {
    if !accepted {
        eprintln!(
            "warning: the 0600 audience-secret mode is not enforced on Windows; {} inherits its \
             parent directory's NTFS DACL. Ensure the parent is owner-only (or pass \
             --accept-windows-dacl to silence).",
            path.display()
        );
    }
}

#[cfg(unix)]
pub(crate) fn warn_secret_permissions(_path: &Path, _accepted: bool) {}

/// `fsync` the parent directory so a link/rename is durable (Unix; best-effort
/// no-op where the platform doesn't support directory fsync).
async fn sync_parent_dir(path: &Path) {
    #[cfg(unix)]
    {
        let parent = path.parent().unwrap_or_else(|| Path::new("."));
        let parent = if parent.as_os_str().is_empty() {
            Path::new(".")
        } else {
            parent
        };
        if let Ok(dir) = std::fs::File::open(parent) {
            let _ = dir.sync_all();
        }
    }
    #[cfg(not(unix))]
    {
        let _ = path;
    }
}

/// Pre-check for a nicer error than the race-free `create_new`/`hard_link`
/// enforcement in [`stage_beside`] / [`publish_staged`] produces. This is UX,
/// NOT the safety boundary — the publish path fails closed on its own.
pub(crate) async fn refuse_existing(path: &Path, force: bool) -> Result<(), CliError> {
    if force {
        return Ok(());
    }
    match tokio::fs::try_exists(path).await {
        Ok(true) => Err(invalid_args(format!(
            "file already exists at {}; pass --force to overwrite",
            path.display()
        ))),
        Ok(false) => Ok(()),
        // Deliberately NO `--force` advice here. `--force` does not "override"
        // a stat failure — it skips the existence check entirely and publishes
        // over whatever is at the path. Steering the operator toward it on the
        // one path where we cannot see what we are about to replace is exactly
        // backwards (§17).
        Err(e) => Err(generic(format!("failed to stat {}: {e}", path.display()))),
    }
}

/// Publish a non-secret JSON artifact (a membership certificate, a revocation
/// bundle) at `final_path`.
///
/// Both branches stage beside the destination first, so the destination is
/// never truncated in place and a leaf symlink is never followed or written
/// through. They differ only in replace policy:
///
/// - **without `--force`**: hard-link, which fails closed if anything already
///   exists at `final_path`;
/// - **with `--force`**: `rename`, which replaces the destination ATOMICALLY.
///   Unlike the remove-then-link that makes a forced replace unsafe for the
///   grant/secret pair, a rename cannot lose the old artifact on a crash — the
///   destination always names either the old inode or the new one, never
///   nothing.
///
/// Certificates and revocation bundles are renewable by design (≈annual cert
/// renewal, floor bumps), so `--force` stays available here rather than being
/// refused outright as it is for grants ([`refuse_force`]). What it must never
/// do is what `tokio::fs::write` did before this: truncate an arbitrary
/// `--out` in place, following a symlink, with no alias check against
/// `--org-key` (§2).
async fn publish_json_artifact(
    final_path: &Path,
    json: &[u8],
    force: bool,
) -> Result<(), CliError> {
    if force {
        // Only the forced path needs this: without `--force` the no-clobber
        // hard-link already refuses anything that exists at the destination,
        // whatever it contains. A certificate / revocation bundle / grant
        // envelope is not seed-bearing, so it may replace nothing seed-bearing.
        refuse_replacing_foreign_seed(final_path, SeedArtifact::None).await?;
    }
    let tmp = stage_beside(final_path, json, false).await?;
    if force {
        publish_staged_replace(&tmp, final_path).await
    } else {
        publish_staged(&tmp, final_path).await
    }
}

/// What seed-bearing artifact, if any, currently lives at `path`.
///
/// [`refuse_aliased_paths`] catches the common spelling, but it is a lexical,
/// case-sensitive comparison that resolves neither `..` nor symlinks — so on a
/// case-insensitive filesystem `--out ORG.TOML` against `--org-key org.toml`
/// slips past it. Rather than reach for a platform file-identity API
/// (`st_dev`/`st_ino` vs `GetFileInformationByHandle`), classify on what
/// actually matters: the CONTENT at the destination. One check covers case
/// variants, symlinks, hard links, and `..` traversal alike.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SeedArtifact {
    /// Absent, binary, unreadable, or not seed-bearing.
    None,
    /// An org ROOT key file (`seed_hex` + `org_id_hex`). Signs every membership
    /// certificate and every revocation floor; unrecoverable if destroyed.
    OrgKey,
    /// An operator identity file (`seed_hex`, no `org_id_hex`).
    Identity,
    /// A subnet authority key file (`seed_hex` + the explicit
    /// `kind = "subnet-authority-key"` marker, SSDK S3). Signs subnet
    /// grants, issuer grants, floors, and control facts; unrecoverable
    /// if destroyed.
    SubnetKey,
}

impl SeedArtifact {
    fn describe(self) -> &'static str {
        match self {
            SeedArtifact::None => "no seed material",
            SeedArtifact::OrgKey => "org root key material",
            SeedArtifact::Identity => "operator identity key material",
            SeedArtifact::SubnetKey => "subnet authority key material",
        }
    }
}

/// Classify the artifact at `path` by content.
pub(crate) async fn classify_seed_artifact(path: &Path) -> SeedArtifact {
    // Absent, binary, or unreadable — nothing we can identify as key material,
    // and any real failure surfaces from the publish itself.
    let Ok(mut text) = tokio::fs::read_to_string(path).await else {
        return SeedArtifact::None;
    };
    // The text may BE the seed; scrub it before returning on EVERY branch.
    // NEVER interpolate the parse error — its `Display` embeds the offending
    // source line, which for these files is `seed_hex` (same reasoning as
    // `load_org_key_from_text`).
    let parsed = toml::from_str::<toml::Value>(&text).ok();
    let has_seed = parsed
        .as_ref()
        .and_then(|v| v.get("seed_hex"))
        .is_some_and(|v| v.is_str());
    let has_org_id = parsed
        .as_ref()
        .and_then(|v| v.get("org_id_hex"))
        .is_some_and(|v| v.is_str());
    // The subnet key marks its kind EXPLICITLY rather than being inferred
    // from field shape, so it can never be confused with an operator
    // identity (both carry a bare seed).
    let is_subnet_key = parsed
        .as_ref()
        .and_then(|v| v.get("kind"))
        .and_then(|v| v.as_str())
        .is_some_and(|k| k == "subnet-authority-key");
    zeroize_string(&mut text);
    match (has_seed, has_org_id, is_subnet_key) {
        (true, true, _) => SeedArtifact::OrgKey,
        (true, false, true) => SeedArtifact::SubnetKey,
        (true, false, false) => SeedArtifact::Identity,
        _ => SeedArtifact::None,
    }
}

/// The publish-time rule for every verb that can replace a file:
///
/// > `--force` may replace an artifact of its OWN kind. It must never replace
/// > a DIFFERENT kind of secret.
///
/// `publishing` is what the calling verb is about to write —
/// [`SeedArtifact::None`] for the non-secret JSON artifacts (certificates,
/// revocation bundles, grant envelopes), which may replace nothing seed-bearing
/// at all.
///
/// This generalizes the original §2 fix, which guarded only `issue-cert` /
/// `issue-floors` and keyed on "is there ANY `seed_hex` here". That left the
/// two verbs which themselves WRITE seed files — `net-mesh org keygen` and
/// `net-mesh identity generate` — unguarded on their `--force` path, so
/// `net-mesh identity generate --out "$KEY" --force` with `$KEY` drifted onto the
/// org key path replaced the org root with an operator identity and exited 0.
/// Keying on the KIND rather than on mere presence closes that without
/// refusing the legitimate same-kind replace each verb's `--force` exists for.
pub(crate) async fn refuse_replacing_foreign_seed(
    path: &Path,
    publishing: SeedArtifact,
) -> Result<(), CliError> {
    let found = classify_seed_artifact(path).await;
    if found == SeedArtifact::None || found == publishing {
        return Ok(());
    }
    Err(invalid_args(format!(
        "refusing to overwrite {}: it contains {}. --out must not name that file, however the \
         path is spelled (case variant, symlink, hard link, or relative path). If you really \
         mean to discard it, remove it explicitly first.",
        path.display(),
        found.describe(),
    )))
}

/// Atomic REPLACE publish for renewable artifacts: `rename` the staged temp
/// onto `final_path`. Crash-atomic (the destination names the old or the new
/// inode, never nothing) and it replaces a leaf symlink rather than writing
/// through it. The rename consumes the temp, so there is no post-publish
/// cleanup to leak.
pub(crate) async fn publish_staged_replace(tmp: &Path, final_path: &Path) -> Result<(), CliError> {
    let tmp_owned = tmp.to_path_buf();
    let final_owned = final_path.to_path_buf();
    let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp_owned, &final_owned))
        .await
        .map_err(|e| generic(format!("publish task panicked: {e}")))?;
    if let Err(e) = renamed {
        remove_file_or_warn(tmp, "staging temp").await;
        return Err(generic(format!(
            "failed to publish {}: {e}",
            final_path.display()
        )));
    }
    sync_parent_dir(final_path).await;
    Ok(())
}

// `write_json_envelope` (a `tokio::fs::write` wrapper) was deliberately
// REMOVED rather than left unused. It truncated its target in place and wrote
// THROUGH a leaf symlink, and it was the mechanism by which
// `issue-cert --force` / `issue-floors --force` could destroy the org root key
// (§2). Every artifact publish now goes through `stage_beside` +
// `publish_staged` (no-clobber) or `publish_staged_replace` (atomic replace),
// both of which stage beside the destination first. Do not reintroduce a
// direct-write helper here.

/// The default org key path, or `None` when the platform config directory
/// cannot be resolved (§19).
///
/// Deliberately NOT falling back to `PathBuf::from(".")`. This file holds the
/// ORG ROOT SEED — the key that signs every membership certificate and every
/// revocation floor, and the single most sensitive artifact this CLI writes. A
/// CWD fallback silently writes it wherever the operator happened to be
/// standing: a git checkout, an archived CI workspace, a shared build
/// directory. On Windows it then inherits that directory's DACL and
/// `enforce_strict_permissions` is a no-op, so a world-readable CWD yields a
/// world-readable org root with no warning.
///
/// `node.rs::default_authority_dir` refused this fallback for
/// `owner-audience.key`; the org root is strictly more sensitive and kept it.
fn default_org_key_path(org_id_hex: &str) -> Option<PathBuf> {
    let short = &org_id_hex[..org_id_hex.len().min(16)];
    Some(default_org_key_dir()?.join(format!("org-{short}.toml")))
}

fn default_org_key_dir() -> Option<PathBuf> {
    Some(dirs::config_dir()?.join("net-mesh").join("orgs"))
}