omni-dev 0.46.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
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
//! Folder-scoped write-permission gate (issue #1574).
//!
//! `omni-dev`'s own local policy layer bounding `drive create`/`upload`/
//! `edit`, independent of and enforced *in addition to* whatever the OAuth
//! scope (`crate::drive::auth::DriveGrantedScopes`) would technically
//! allow. Google's Drive scopes are all-or-nothing across a user's whole
//! Drive — there is no Google-side way to say "this credential may only
//! write inside folder X" — so this module fills that gap.
//!
//! Deliberately pure: zero `DriveClient`/network dependency, mirroring
//! `crate::drive::visibility`'s contract exactly. Fetching the ancestor
//! folder chain a target lives in is `crate::drive::folder_ancestry`'s job;
//! this module only classifies an already-resolved chain.
//!
//! Named `write_gate`, not `permission(s)`, to avoid any confusion with
//! `crate::drive::permissions_api` — Google's own sharing/ACL wrapper,
//! a completely unrelated concept.
//!
//! # The algorithm
//!
//! A target (the `--parent` folder for `create`/`upload`, or a file's
//! current parent folder(s) for `edit`/`sheets write`) is identified by
//! its **ancestor chain**: `chain[0]` is the target folder itself,
//! `chain[1]` its parent, `chain[2]` its grandparent, and so on up to
//! Drive's root. [`resolve`] walks that chain looking for the closest
//! (lowest-depth) rule naming any folder in it — a non-recursive rule only
//! ever matches at depth 0, its own folder; a recursive rule matches at
//! any depth. Two tie-breaks, both security-relevant and each covered by a
//! dedicated test:
//!
//! - **Closest ancestor wins**: a rule on a subfolder overrides a broader
//!   rule on its parent — the more specific grant/restriction is assumed
//!   the more deliberate one.
//! - **Deny beats allow at equal depth**: if two rules at the same depth
//!   disagree, the safe direction wins.
//!
//! A rule may instead name a **file id** (issue #1612), which matches the
//! target itself at **depth −1** — strictly closer than depth 0, so it
//! beats every folder rule in both directions: a file `deny` overrides a
//! recursive folder `allow`, and a file `allow` overrides a folder `deny`.
//! That is the same "closest wins" principle, not an exception to it. Two
//! consequences worth stating outright:
//!
//! - Because nothing in any chain can beat a file rule, a decisive one
//!   short-circuits the ancestor walk entirely — see
//!   [`resolve_for_file`] and
//!   `crate::drive::folder_ancestry::resolve_decision_for_file_target`.
//!   This is what makes a file **shared by link or email** grantable at
//!   all: `files.get` returns only the parents the caller can see, so such
//!   a file has none, and no folder rule could ever apply to it.
//! - `combine_across_parents`' "deny wins" is a tie-break among *peers* —
//!   a target's several legacy parents, all at the same level. A file rule
//!   is not a peer, so a file `allow` still beats a denying parent.
//!
//! When no rule anywhere in the chain names `op`, [`DriveOperation::default_policy`]
//! decides it: `Read` defaults to [`Verdict::Allow`], every write operation
//! defaults to [`Verdict::Deny`]. This is where "disabled by default" for
//! writes actually lives — there is deliberately no separate enabled/
//! disabled toggle; an absent or empty rule list already means "deny
//! everywhere" via this table alone.

use std::collections::HashSet;

use serde::{Deserialize, Serialize};

/// A Drive operation this gate can permit or refuse. Reused directly as the
/// settings-file rule shape (`crate::utils::settings::WritePermissionsSettings`)
/// — no separate wire type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DriveOperation {
    /// List/read/export/download.
    Read,
    /// Create a new file or folder.
    Create,
    /// Upload local content into a new file.
    Upload,
    /// Replace an existing file's content.
    Edit,
    /// Write cells into an existing Google Sheet via the Sheets API
    /// (issue #1589, [ADR-0073](../../docs/adrs/adr-0073.md) §3).
    ///
    /// Deliberately **not** folded into [`Self::Edit`]. Every existing
    /// `allow: ["edit"]` rule was written when `drive edit` refused every
    /// Google-native document outright, so reusing `Edit` here would
    /// retroactively upgrade those rules into cell-write permission with no
    /// config change and no re-consent — the exact silent widening this
    /// gate's default-deny posture exists to prevent.
    ///
    /// Since issue #1798 ([ADR-0081](../../docs/adrs/adr-0081.md) §5), also
    /// required (alongside [`Self::SheetsStructure`]) for `add-pivot-table`
    /// — a pivot table is created by `updateCells`, writing through the
    /// grid with a server-computed rendered extent, which is this
    /// operation's territory by construction. `delete-pivot-table` needs
    /// this operation alone.
    ///
    /// Since issue #1839 ([ADR-0083](../../docs/adrs/adr-0083.md) §4), also
    /// the clipboard-style paste family — `cut-paste`/`copy-paste` with a
    /// value-only `--paste-type` (`values`/`formula`) and `paste-data`'s
    /// `values`/`formula` types — since each writes ordinary cell content
    /// into a range this crate already lets `sheets clear`/`write` reach.
    /// `cut-paste` additionally requires [`Self::SheetsStructure`] whatever
    /// `--paste-type` names, since it always clears its source's formats
    /// and merges too; `copy-paste`/`paste-data` require it alongside this
    /// operation only for `--paste-type normal`, per that variant's doc
    /// comment.
    /// Since issue #1840 ([ADR-0083](../../docs/adrs/adr-0083.md) §1), also
    /// covers `auto-fill` (`autoFill`) alone: it writes ordinary cell
    /// content into a range the request names or derives, exactly what
    /// this operation already permits via `sheets clear` followed by
    /// `sheets write` of the same range. Provisional on ADR-0083 §5's
    /// live-verification rule — if a fill is found to carry the source
    /// cells' formatting, `auto-fill` moves to the two-operation
    /// composition `add-pivot-table` uses, alongside [`Self::SheetsStructure`].
    ///
    /// Since issue #1841 ([ADR-0083](../../docs/adrs/adr-0083.md) §1), also
    /// required for `find-replace`. Its range, sheet and all-sheets scopes
    /// replace only cell values or formulas, already covered by `sheets
    /// write` and `sheets clear`; scope does not create a new permission
    /// boundary within a spreadsheet.
    ///
    /// Since issue #1842 ([ADR-0083](../../docs/adrs/adr-0083.md) §3), also
    /// one half of `sort-range`'s gate, alongside [`Self::SheetsStructure`]
    /// — re-gated by issue #1870 after live verification found a sorted row
    /// carries its formatting, notes and data-validation rules with it (see
    /// that variant's doc comment). It shipped first on this operation
    /// alone, on a values-only reading: it permutes values within a
    /// caller-named range, which a `sheets-write` grant could already
    /// replace or clear.
    ///
    /// Since issue #1843 ([ADR-0083](../../docs/adrs/adr-0083.md) §1),
    /// also one half of `text-to-columns`' gate, for the cell content the
    /// split writes — but **not** the whole of it. ADR-0083 §1 proposed
    /// this operation alone and §5 made that provisional on live
    /// verification; the live run found a split carries the source cell's
    /// formatting into the spill cells, so §5's fixed consequence
    /// applies and `text-to-columns` needs [`Self::SheetsStructure`] too,
    /// the two-operation composition `add-pivot-table` uses.
    ///
    /// Since issue #1845 ([ADR-0083](../../docs/adrs/adr-0083.md) §§3, 5),
    /// also one half of `randomize-range`'s gate, alongside
    /// [`Self::SheetsStructure`], for the cell values a reorder permutes.
    /// ADR-0083 §3 proposed this operation alone and §5 made that
    /// provisional on live verification, same as `text-to-columns`; the
    /// live run found a reordered row carries its formatting, notes and
    /// data-validation rules with it, so §5's fixed consequence applies
    /// here too.
    ///
    /// Since issue #1940, also one half of `set-basic-filter --sort-by`'s
    /// gate, alongside [`Self::SheetsStructure`]: a basic filter's sort is
    /// applied by physically reordering the rows, the same permutation as
    /// `randomize-range`, so it takes the same union (see that variant's
    /// doc comment).
    ///
    /// Since issue #1844 ([ADR-0083](../../docs/adrs/adr-0083.md) §1),
    /// also `trim-whitespace`: it rewrites cell content in place, which a
    /// `sheets write` of the same range could already replace outright.
    /// Unlike the reorder verbs above, it stays on this operation alone:
    /// issue #1877's live check found a trim moves no formatting, and even
    /// rebases rich-text runs to keep them on the characters that remain.
    /// Its sibling verb in that issue, `delete-duplicates`, takes
    /// [`Self::SheetsDelete`] instead — two verbs shipped together under
    /// two different operations, which is the per-verb mapping working as
    /// intended, not a special case.
    SheetsWrite,
    /// Structurally edit an existing Google Sheet via `spreadsheets.batchUpdate`
    /// (issue #1613, [ADR-0075](../../docs/adrs/adr-0075.md) §1) — adding,
    /// renaming and inserting rows or columns; since issue #1643
    /// ([ADR-0078](../../docs/adrs/adr-0078.md)), cell/border formatting,
    /// merging, auto-resize, column width/row height, data validation,
    /// `duplicateSheet`, and sheet reorder/hide. Since issue #1795
    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §4), also developer-metadata
    /// management (`set-developer-metadata`/`delete-developer-metadata`),
    /// restricted to `DOCUMENT` visibility — `PROJECT`-visibility metadata,
    /// which belongs to a different OAuth client, is never reachable
    /// through this surface; since issue #1793
    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §1), conditional formatting
    /// (`add-conditional-format`/`update-conditional-format`/
    /// `delete-conditional-format`); since issue #1796
    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §2), also named-range
    /// add/update/delete: a named range is a label over a region, not grid
    /// data, so removing one leaves every cell's stored value and formula
    /// text untouched — even though every cell formula referencing the
    /// removed name starts evaluating to `#REF!`. That
    /// visible-but-not-destructive effect is mitigated, not by a stronger
    /// gate, but by `delete-named-range` scanning the workbook's cell
    /// formulas for the name and reporting the count and A1 locations of
    /// every reference before it deletes, in both `--dry-run` and the real
    /// run; and, since issue #1797
    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §3), charts and slicers
    /// (`add-chart`/`update-chart`/`delete-chart`/`add-slicer`/
    /// `update-slicer`/`delete-slicer`) — including the two deletes, which
    /// destroy an unrecoverable embedded object rather than grid data, and
    /// so join this operation (not [`Self::SheetsDelete`]) on the same
    /// "property of the sheet, not the sheet's data" reasoning as
    /// `unmerge-cells`/`clear-data-validation`. None of that later set
    /// destroys *grid* data either, which is what earns it the same
    /// operation as the original three rather than one of its own.
    ///
    /// Since issue #1798 ([ADR-0081](../../docs/adrs/adr-0081.md) §5), also
    /// required (alongside [`Self::SheetsWrite`]) for `add-pivot-table` —
    /// the first capability in the crate resolving **two** operations
    /// together rather than one. A pivot table is also a named, persistent,
    /// structural feature of a sheet, which is why it needs this operation
    /// too and not `SheetsWrite` alone.
    ///
    /// Since issue #1832 ([ADR-0082](../../docs/adrs/adr-0082-banded-ranges.md)),
    /// also banded ranges (`add-banding`/`update-banding`/`delete-banding`)
    /// — alternating row/column colors applied to a range, the same
    /// "property of the sheet, not the sheet's data" reasoning as
    /// `unmerge-cells`/`clear-data-validation`; removing one destroys no
    /// grid data.
    ///
    /// Since issue #1833 ([ADR-0084](../../docs/adrs/adr-0084-dimension-groups.md)),
    /// also dimension groups (`add-dimension-group`/`update-dimension-group`/
    /// `delete-dimension-group`) — the collapsible +/- outline over a row
    /// or column span, the same reasoning again; removing a group destroys
    /// no grid data either.
    ///
    /// Since issue #1835 ([ADR-0085](../../docs/adrs/adr-0085.md)), also
    /// `update-sheet-properties` — frozen rows/columns, tab color,
    /// right-to-left layout, and hidden gridlines, all per-sheet view state
    /// rather than grid data.
    ///
    /// Since issue #1836 ([ADR-0086](../../docs/adrs/adr-0086-workbook-properties.md)),
    /// also `update-workbook-properties` — locale, time zone, automatic
    /// recalculation, and iterative calculation. The one capability in this
    /// variant with no *sheet* target at all, since it acts on the workbook
    /// itself; turning iterative calculation on changes what an unchanged
    /// circular-reference formula elsewhere *evaluates to* rather than
    /// touching any formula directly, the same shape of value effect
    /// ADR-0081 §2 already accepted here for named-range deletion.
    ///
    /// Since issue #1838, also `insert-range` — the exact inverse of
    /// `delete-range`'s cell-range half, inserting empty cells into a
    /// sub-rectangle and shifting the existing cells in that column/row
    /// extent right or down. It joins this operation, not
    /// [`Self::SheetsDelete`], because no cell value is discarded: every
    /// shifted cell keeps its content and the vacated cells become empty.
    /// The one asymmetry with `delete-range`: a cell pushed past the
    /// sheet's current grid extent by the shift is silently dropped by the
    /// Sheets API, so this capability can lose data at the grid edge even
    /// though it destroys none within it.
    ///
    /// Since issue #1837, also moving/resizing an existing chart or slicer
    /// (`move-chart`/`move-slicer`, `updateEmbeddedObjectPosition`) and
    /// setting or clearing a chart's border colour (`update-chart-border`,
    /// `updateEmbeddedObjectBorder`) — the same operation as the rest of
    /// this chart/slicer set (`add-chart`/`update-chart`/`delete-chart`/
    /// `add-slicer`/`update-slicer`/`delete-slicer`), since a move or a
    /// border change discards no data either.
    ///
    /// Since issue #1839 ([ADR-0083](../../docs/adrs/adr-0083.md) §4), also
    /// required (alongside [`Self::SheetsWrite`]) for `cut-paste`
    /// unconditionally, and for `copy-paste`/`paste-data` when
    /// `--paste-type` is `normal` (formats and merges travel with the cell
    /// content) — plus required *alone*, no [`Self::SheetsWrite`] needed,
    /// for `copy-paste`/`paste-data`'s presentation-only `format` type,
    /// which writes nothing this crate treats as a cell value. `cut-paste`
    /// needs this operation whatever it pastes because the source is
    /// always cleared of its formats and merges too, the same "property of
    /// the sheet, not the sheet's data" removal `unmerge-cells`/
    /// `clear-data-validation` already make under this operation.
    ///
    /// Deliberately **not** folded into [`Self::SheetsWrite`], for the same
    /// reason that one is not folded into [`Self::Edit`]. Every existing
    /// `allow: ["sheets-write"]` rule was written when structural edits were
    /// impossible, so reusing it here would retroactively upgrade those rules
    /// into permission to restructure a workbook with no config change and no
    /// re-consent.
    ///
    /// The same argument binds future work: row and sheet **deletion** must
    /// not later join this variant either, or granting it today would silently
    /// become consent to destroy data tomorrow. Nor may a *permission*
    /// change — see [`Self::SheetsProtection`], carved out of this same
    /// deferred set for exactly that reason.
    ///
    /// Since issue #1843 ([ADR-0083](../../docs/adrs/adr-0083.md) §§1, 5),
    /// also the second half of `text-to-columns`' gate, alongside
    /// [`Self::SheetsWrite`]. Not because the split is a structural edit
    /// in itself, but because it was measured **propagating the source
    /// cell's formatting** into spill cells that had none: a live split of
    /// a bold, pink column left every cell it wrote bold and pink. That is
    /// this operation's own subject matter, so a `sheets-write` grant
    /// alone cannot confer it.
    ///
    /// Since issue #1845 ([ADR-0083](../../docs/adrs/adr-0083.md) §§3, 5),
    /// also the second half of `randomize-range`'s gate, alongside
    /// [`Self::SheetsWrite`]. Not because a reorder is a structural edit in
    /// itself, but because it was measured carrying a row's **formatting,
    /// notes and data-validation rules** with it, and rewriting the
    /// relative references of any formula moved along with it — a live
    /// probe reordering a bold, pink row with a note, a validation rule
    /// and a relative formula moved all four with the row. That is this
    /// operation's own subject matter, so a `sheets-write` grant alone
    /// cannot confer it.
    ///
    /// Since issue #1870, `sort-range` needs this operation too, alongside
    /// [`Self::SheetsWrite`] — the same live-verification finding as
    /// `randomize-range`: formatting, notes and data-validation rules
    /// travel with a reordered row, and an in-range relative formula
    /// rewrites to keep pointing at its own row. It shipped first (#1842)
    /// on `SheetsWrite` alone, before this was measured.
    ///
    /// Since issue #1940, `set-basic-filter --sort-by` resolves this
    /// operation *and* [`Self::SheetsWrite`] — every other filter verb, and
    /// `set-basic-filter` without `--sort-by`, still resolves this one
    /// alone (ADR-0081 §1). A basic filter's sort physically reorders the
    /// filtered range's rows, the same permutation `randomize-range`
    /// performs, and the reorder outlives `clear-basic-filter`; so it takes
    /// `randomize-range`'s union rather than being treated as view state.
    SheetsStructure,
    /// Destructively edit an existing Google Sheet via `spreadsheets.batchUpdate`
    /// (issue #1623, [ADR-0077](../../docs/adrs/adr-0077-sheets-deletion-via-batchupdate.md)) — deleting a
    /// sheet, a row/column range, or an arbitrary cell range.
    ///
    /// Deliberately **not** folded into [`Self::SheetsStructure`], per the
    /// binding clause on that variant: every existing `allow:
    /// ["sheets-structure"]` rule was written when deletion was impossible,
    /// so reusing it here would retroactively upgrade those rules into
    /// permission to destroy data with no config change and no re-consent.
    ///
    /// The same argument binds future work: no other operation should later
    /// join this variant without the same re-consent reasoning.
    ///
    /// Since issue #1844 ([ADR-0083](../../docs/adrs/adr-0083.md) §2),
    /// also `delete-duplicates` — the first capability to join this
    /// variant since ADR-0077 defined it, and admitted under exactly the
    /// re-consent reasoning the clause above demands: an operator who
    /// granted `sheets-delete` consented to having cells removed and
    /// shifted up within a caller-named range by this tool, and
    /// `delete-duplicates` does just that. Content outside the range
    /// stays in place. It asks for nothing beyond that.
    ///
    /// One thing about it *is* new, and is recorded here rather than
    /// buried in the verb: every other capability under this operation
    /// removes cells the caller named **by address**, whereas
    /// `delete-duplicates` removes rows the **server** selects from the
    /// data — by an equality rule that ignores letter case, formatting and
    /// formulas, and that reaches rows hidden by a filter. A
    /// `sheets-delete` grant used through this verb therefore deletes
    /// whatever that rule finds. ADR-0083 §2 accepts that as within the
    /// grant's consent (rows removed from a range this tool was told to
    /// act on) and mitigates it where it belongs — in the verb, which
    /// states the rule in both the preview and the real run rather than
    /// implying the caller chose the rows.
    SheetsDelete,
    /// Add, change or remove a protected range on an existing Google Sheet
    /// via `spreadsheets.batchUpdate` (issue #1643,
    /// [ADR-0078](../../docs/adrs/adr-0078.md)).
    ///
    /// Deliberately **not** folded into [`Self::SheetsStructure`], and the
    /// reason is different in kind from every other split on this enum: a
    /// protected range is *who may edit*, not *what the sheet contains*.
    /// `updateProtectedRange` can widen the set of editors and
    /// `deleteProtectedRange` removes a guard someone deliberately placed —
    /// both are permission changes inside the document, closer in spirit to
    /// `drive permissions` than to any cell or structural write this tool
    /// makes. Folding it into `sheets-structure` would let a grant issued
    /// for "may reformat/validate/restructure this workbook" silently
    /// double as "may also change who can edit it" — a widening in kind,
    /// not merely in scope, which is why this earns its own operation
    /// rather than joining the set `sheets-structure`'s own doc comment
    /// just absorbed.
    ///
    /// This binds future work too: nothing reachable under this operation
    /// may ever touch a *file-level* Drive permission (`drive permissions`
    /// is that surface, and stays the only one).
    SheetsProtection,
    /// Replace or append *text* in an existing Google Doc via the Docs API
    /// (issue #1615, [ADR-0076](../../docs/adrs/adr-0076.md) §2).
    ///
    /// Folded into neither [`Self::Edit`] nor [`Self::SheetsWrite`], and the
    /// argument runs in both directions. Every `allow: ["edit"]` rule was
    /// written when `drive edit` refused every Google-native document
    /// outright — which it still does. Every `allow: ["sheets-write"]` rule
    /// was written when Docs were unreachable through this tool, and that
    /// operation is *lexically* about cells, so making it govern prose would
    /// be a lie told by the config vocabulary itself.
    ///
    /// **This binds future work.** A Docs *deletion* verb must not be folded
    /// into this variant either: a grant issued today for "may replace text"
    /// must not silently become consent to remove content tomorrow. When
    /// deletion is designed it needs its own operation, or explicit
    /// re-consent. Same rule ADR-0075 §1 records for `sheets-structure`.
    /// Anchored insertion joins this operation because, like append, it only
    /// adds text. Deletion is gated separately by `DocsDelete` (ADR-0094).
    DocsWrite,
    /// Moving an individual file to Trash or restoring it (ADR-0092).
    /// Default-deny and lease-exempt: recovery is provided by Drive Trash.
    /// A future permanent delete must use separate consent, never this variant.
    Trash,
    /// Replace text on ordinary Google Slides pages (ADR-0093).
    /// Existing Edit/DocsWrite/SheetsWrite grants never authorize Slides.
    /// Object deletion must receive its own operation and explicit consent.
    SlidesWrite,
    /// Anchor-addressed document content removal. Separate from `DocsWrite`:
    /// an existing text-write grant must never silently widen to deletion.
    /// Future structural/object deletion verbs need their own explicit decision.
    DocsDelete,
    /// Anchor-addressed styling; never implied by text-write or deletion grants.
    DocsFormat,
    /// Add empty tables, rows or columns, or create or delete named-range metadata.
    /// Never authorizes the removal of document text.
    DocsStructure,
    /// Remove table rows/columns under new explicit destructive consent.
    /// Neither DocsWrite nor the anchor-only DocsDelete grant authorizes this.
    DocsTableDelete,
}

impl std::fmt::Display for DriveOperation {
    /// Matches the `#[serde(rename_all = "kebab-case")]` wire form — used
    /// by `drive permissions show`/`check`'s rendering.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            Self::Read => "read",
            Self::Create => "create",
            Self::Upload => "upload",
            Self::Edit => "edit",
            Self::SheetsWrite => "sheets-write",
            Self::SheetsStructure => "sheets-structure",
            Self::SheetsDelete => "sheets-delete",
            Self::SheetsProtection => "sheets-protection",
            Self::DocsWrite => "docs-write",
            Self::Trash => "trash",
            Self::SlidesWrite => "slides-write",
            Self::DocsDelete => "docs-delete",
            Self::DocsFormat => "docs-format",
            Self::DocsStructure => "docs-structure",
            Self::DocsTableDelete => "docs-table-delete",
        };
        write!(f, "{s}")
    }
}

impl DriveOperation {
    /// The verdict when no configured rule names this operation anywhere in
    /// a target's ancestor chain. `Read` stays open by default (unchanged
    /// from today's behavior); every write defaults closed — the whole
    /// "disabled by default" requirement lives in this one match, not a
    /// separate on/off flag.
    pub(crate) fn default_policy(self) -> Verdict {
        match self {
            Self::Read => Verdict::Allow,
            Self::Create
            | Self::Upload
            | Self::Edit
            | Self::SheetsWrite
            | Self::SheetsStructure
            | Self::SheetsDelete
            | Self::SheetsProtection
            | Self::DocsWrite
            | Self::Trash
            | Self::SlidesWrite
            | Self::DocsStructure
            | Self::DocsTableDelete
            | Self::DocsDelete
            | Self::DocsFormat => Verdict::Deny,
        }
    }

    /// Whether an `Allow` verdict for this operation is ever backed by a
    /// Drive write lease requirement (issue #1917).
    ///
    /// `Read` never mutates anything, and `Create`/`Upload` write into a
    /// **new** file — there is no existing content for a lease to protect,
    /// and neither engine (`create.rs`, `upload.rs`) ever gates on
    /// `require_lease`; the field appears on their `LeasedWrite` values
    /// only in test fixtures. `Trash` is reversible through Drive Trash and
    /// exempt under ADR-0092, even with `require_lease: true`. Every other
    /// operation replaces or otherwise
    /// mutates a target's existing content and does gate on it via
    /// [`decided_rule_requires_lease`]. This is consulted only by
    /// `drive permissions check`'s diagnostic, to keep it from reporting a
    /// lease requirement an operation could never actually be asked to
    /// satisfy — it does not itself change what any engine enforces.
    #[must_use]
    pub const fn ever_requires_lease(self) -> bool {
        match self {
            Self::Read | Self::Create | Self::Upload | Self::Trash => false,
            Self::Edit
            | Self::SheetsWrite
            | Self::SheetsStructure
            | Self::SheetsDelete
            | Self::SheetsProtection
            | Self::DocsWrite
            | Self::SlidesWrite
            | Self::DocsStructure
            | Self::DocsTableDelete
            | Self::DocsDelete
            | Self::DocsFormat => true,
        }
    }
}

/// One configured permission rule, keyed on **either** a folder id or a
/// file id — exactly one of the two, enforced when the settings file is
/// deserialized (see the private `RawPermissionRule` below).
///
/// Ids are Drive's own canonical ids, not paths — Drive objects have no
/// stable, unique path (names collide, files can have multiple legacy
/// parents), so identity is the id, exactly as the browser bridge's
/// `OriginAllowlist` matches exact origin strings rather than URL
/// patterns.
///
/// A `file_id` rule is what makes a file shared by link or email
/// grantable at all (issue #1612): `files.get` returns only the parents
/// this account can *see*, so such a file arrives with none, and no
/// folder rule could ever apply to it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "RawPermissionRule")]
pub struct FolderPermissionRule {
    /// The Drive folder id this rule matches, for a folder rule.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub folder_id: Option<String>,
    /// The Drive file id this rule matches, for a file rule. Matched at
    /// depth −1 against the target itself, before any ancestor walk — see
    /// [`resolve_file_rule`].
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file_id: Option<String>,
    /// When `true`, this rule also matches every descendant of
    /// `folder_id`, not just the folder itself (depth > 0 in the ancestor
    /// chain). A non-recursive rule only ever matches at depth 0.
    /// Meaningless — and rejected — on a `file_id` rule: a file has no
    /// descendants.
    pub recursive: bool,
    /// Operations explicitly permitted at this target.
    pub allow: HashSet<DriveOperation>,
    /// Operations explicitly refused at this target.
    pub deny: HashSet<DriveOperation>,
    /// Whether a content-mutating write matching this rule requires a
    /// valid Drive write lease ([ADR-0080](../../docs/adrs/adr-0080.md)
    /// §1/§13) in addition to this gate's own verdict. Defaults to `true`;
    /// an operator sets it to `false` to relax the requirement for a
    /// specific folder, which skips **both** the backup and the Touch ID
    /// prompt (§13) — a rule that skipped only one of the two would either
    /// surprise an operator who explicitly relaxed this, or ask for
    /// consent to a guarantee it then doesn't provide. Independent of
    /// `allow`/`deny`: the lease is orthogonal to this gate, not another
    /// operation in its vocabulary.
    #[serde(default = "default_require_lease")]
    pub require_lease: bool,
}

/// The default for [`FolderPermissionRule::require_lease`] — a free
/// function because `#[serde(default)]` needs a path, not a literal, and
/// `true` is not `bool`'s own `Default`.
fn default_require_lease() -> bool {
    true
}

impl FolderPermissionRule {
    /// A folder rule naming `folder_id`.
    #[must_use]
    pub fn folder(folder_id: impl Into<String>) -> Self {
        Self {
            folder_id: Some(folder_id.into()),
            file_id: None,
            recursive: false,
            allow: HashSet::new(),
            deny: HashSet::new(),
            require_lease: default_require_lease(),
        }
    }

    /// A file rule naming `file_id`.
    #[must_use]
    pub fn file(file_id: impl Into<String>) -> Self {
        Self {
            folder_id: None,
            file_id: Some(file_id.into()),
            recursive: false,
            allow: HashSet::new(),
            deny: HashSet::new(),
            require_lease: default_require_lease(),
        }
    }

    /// Sets `recursive`, for builder-style construction in tests and
    /// callers assembling rules programmatically.
    #[must_use]
    pub fn recursive(mut self, recursive: bool) -> Self {
        self.recursive = recursive;
        self
    }

    /// Sets `require_lease`, for builder-style construction in tests and
    /// callers assembling rules programmatically.
    #[must_use]
    pub fn requiring_lease(mut self, require_lease: bool) -> Self {
        self.require_lease = require_lease;
        self
    }

    /// Sets the `allow` set.
    #[must_use]
    pub fn allowing(mut self, ops: impl IntoIterator<Item = DriveOperation>) -> Self {
        self.allow = ops.into_iter().collect();
        self
    }

    /// Sets the `deny` set.
    #[must_use]
    pub fn denying(mut self, ops: impl IntoIterator<Item = DriveOperation>) -> Self {
        self.deny = ops.into_iter().collect();
        self
    }
}

/// The wire shape [`FolderPermissionRule`] deserializes through, so that
/// "exactly one of `folder_id`/`file_id`" is a **load error** rather than a
/// rule that silently matches nothing.
///
/// This is also the backward-compatibility mechanism. An older binary
/// reading a config containing a file rule sees `folder_id` missing from a
/// required field and fails `Settings::load()` outright; `active_account_rules`
/// then degrades to an empty rule set, which denies every write. That is
/// blunt — it drops the account's credentials too — but it is the same
/// fail-closed path [ADR-0073](../../docs/adrs/adr-0073.md) §3 already
/// relies on for an unrecognised `DriveOperation`, and the alternative
/// (a second, separately-named rule list) would be *silently ignored* by
/// an older binary, dropping a file-level `deny` and letting a folder-level
/// `allow` win.
#[derive(Debug, Deserialize)]
struct RawPermissionRule {
    #[serde(default)]
    folder_id: Option<String>,
    #[serde(default)]
    file_id: Option<String>,
    #[serde(default)]
    recursive: bool,
    #[serde(default)]
    allow: HashSet<DriveOperation>,
    #[serde(default)]
    deny: HashSet<DriveOperation>,
    #[serde(default = "default_require_lease")]
    require_lease: bool,
}

impl TryFrom<RawPermissionRule> for FolderPermissionRule {
    type Error = String;

    fn try_from(raw: RawPermissionRule) -> Result<Self, Self::Error> {
        match (&raw.folder_id, &raw.file_id) {
            (None, None) => {
                return Err(
                    "a write-permission rule must set either `folder_id` or `file_id`".to_string(),
                )
            }
            (Some(_), Some(_)) => {
                return Err(
                    "a write-permission rule must set `folder_id` or `file_id`, not both \
                            — a rule keys on one target"
                        .to_string(),
                )
            }
            _ => {}
        }
        if raw.recursive && raw.file_id.is_some() {
            return Err(
                "`recursive` is meaningless on a `file_id` rule — a file has no descendants; \
                 drop it, or use `folder_id` to grant a whole subtree"
                    .to_string(),
            );
        }
        Ok(Self {
            folder_id: raw.folder_id,
            file_id: raw.file_id,
            recursive: raw.recursive,
            allow: raw.allow,
            deny: raw.deny,
            require_lease: raw.require_lease,
        })
    }
}

/// The result of resolving a single operation against a rule set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
    /// The operation is permitted.
    Allow,
    /// The operation is refused.
    Deny,
}

/// Which configured rule (if any) decided a [`Decision`].
///
/// `None` — the `Option` wrapping this, not a variant here — means no rule
/// matched anywhere and the bare default policy decided it instead.
/// Carried into the request log's `decided_by_folder_id`/
/// `decided_by_file_id`/`decided_by_depth` context fields so a refusal is
/// exactly as auditable as a success.
///
/// An enum rather than one struct with optional fields: a file rule has no
/// depth (it names the target itself, at depth −1) and a folder rule has no
/// file id, so "a file rule at depth 3" is a state that should not be
/// expressible at all.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum DecidingRule {
    /// A `folder_id` rule matched the target's ancestor chain.
    Folder {
        /// The folder id of the rule that decided the verdict.
        folder_id: String,
        /// How many levels above the target this rule's folder sits (0 =
        /// the target itself).
        depth: usize,
    },
    /// A `file_id` rule named the target itself.
    File {
        /// The file id of the rule that decided the verdict.
        file_id: String,
    },
}

impl DecidingRule {
    /// The id this rule keys on — a file id or a folder id.
    ///
    /// Deliberately *not* rendered here: the id is operator-supplied but
    /// the strings it sits beside are not, and this module stays free of
    /// the CLI layer. Every render site must therefore sanitize before the
    /// line reaches a terminal — the three CLI sites (`drive edit`,
    /// `upload`, `create`) run the id through `sanitize_for_terminal`
    /// directly; the two engine-layer `describe` functions (`sheets write`,
    /// `sheets create`) embed it raw and their CLI callers sanitize the
    /// whole rendered line instead.
    #[must_use]
    pub fn id(&self) -> &str {
        match self {
            Self::Folder { folder_id, .. } => folder_id,
            Self::File { file_id } => file_id,
        }
    }

    /// `"folder"` or `"file"` — the noun the operator-facing messages use.
    #[must_use]
    pub const fn kind_label(&self) -> &'static str {
        match self {
            Self::Folder { .. } => "folder",
            Self::File { .. } => "file",
        }
    }

    /// `" (depth 2)"` for a folder rule, `""` for a file rule.
    ///
    /// Exists so the five `describe`/`print_report` sites are one identical
    /// line — `"refused by rule on {kind} {id}{suffix}"` — and cannot drift
    /// into disagreeing about how a file rule is worded.
    #[must_use]
    pub fn depth_suffix(&self) -> String {
        match self {
            Self::Folder { depth, .. } => format!(" (depth {depth})"),
            Self::File { .. } => String::new(),
        }
    }
}

/// The outcome of [`resolve`]: whether an operation is permitted, and which
/// rule (if any) decided it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision {
    /// Whether the operation is permitted.
    pub verdict: Verdict,
    /// The configured rule that decided this, or `None` when the bare
    /// default policy decided it instead.
    pub decided_by: Option<DecidingRule>,
}

/// The verdict from a rule naming `file_id` itself, or `None` when no
/// configured rule names it.
///
/// A file rule sits at **depth −1**: strictly more specific than any
/// folder rule, including one on the file's own immediate parent. That
/// is what lets a file shared by link or email — whose parents this
/// account cannot see at all — be granted, and it is also why a decisive
/// file rule short-circuits the ancestor walk entirely
/// (`crate::drive::folder_ancestry::resolve_decision_for_file_target`), sparing
/// every `files.get` the walk would have cost.
///
/// Deny still beats allow among file rules naming the same id, the same
/// fail-closed direction every other tie-break in this module takes.
#[must_use]
pub fn resolve_file_rule(
    file_id: &str,
    op: DriveOperation,
    rules: &[FolderPermissionRule],
) -> Option<Decision> {
    let mut deny_here = false;
    let mut allow_here = false;
    for rule in rules {
        if rule.file_id.as_deref() != Some(file_id) {
            continue;
        }
        deny_here |= rule.deny.contains(&op);
        allow_here |= rule.allow.contains(&op);
    }
    if !deny_here && !allow_here {
        return None;
    }
    Some(Decision {
        verdict: if deny_here {
            Verdict::Deny
        } else {
            Verdict::Allow
        },
        decided_by: Some(DecidingRule::File {
            file_id: file_id.to_string(),
        }),
    })
}

/// Resolves `op` for a **file** target: a rule naming `file_id` if one
/// exists, else [`resolve`] against the file's ancestor `chain`.
///
/// The pure counterpart of
/// `crate::drive::folder_ancestry::resolve_decision_for_file_target`. Callers
/// whose target is a *folder* (`create`/`upload`'s `--parent`) call
/// [`resolve`] directly instead — there is no file id to name, so a file
/// rule can never participate.
#[must_use]
pub fn resolve_for_file(
    file_id: &str,
    chain: &[String],
    op: DriveOperation,
    rules: &[FolderPermissionRule],
) -> Decision {
    resolve_file_rule(file_id, op, rules).unwrap_or_else(|| resolve(chain, op, rules))
}

/// Resolves whether `op` is permitted against `chain` (depth 0 = the
/// target folder itself, then parent, grandparent, ...) under `rules`. See
/// the module doc for the full algorithm and its tie-breaks.
///
/// Only `folder_id` rules participate: a `file_id` rule names a target,
/// not an ancestor, so it can never match a chain entry. See
/// [`resolve_for_file`] for a file target.
#[must_use]
pub fn resolve(chain: &[String], op: DriveOperation, rules: &[FolderPermissionRule]) -> Decision {
    let mut best: Option<(usize, Verdict)> = None;
    for (depth, folder_id) in chain.iter().enumerate() {
        let mut deny_here = false;
        let mut allow_here = false;
        for rule in rules {
            if rule.folder_id.as_deref() != Some(folder_id.as_str()) {
                continue;
            }
            if depth > 0 && !rule.recursive {
                continue;
            }
            deny_here |= rule.deny.contains(&op);
            allow_here |= rule.allow.contains(&op);
        }
        if !deny_here && !allow_here {
            continue;
        }
        let verdict = if deny_here {
            Verdict::Deny
        } else {
            Verdict::Allow
        };
        let is_closer = match best {
            Some((best_depth, _)) => depth < best_depth,
            None => true,
        };
        if is_closer {
            best = Some((depth, verdict));
        }
    }
    match best {
        Some((depth, verdict)) => Decision {
            verdict,
            decided_by: Some(DecidingRule::Folder {
                folder_id: chain[depth].clone(),
                depth,
            }),
        },
        None => Decision {
            verdict: op.default_policy(),
            decided_by: None,
        },
    }
}

/// Combines per-parent [`Decision`]s into one, for a target with more than
/// one current parent (a legacy multi-parent file — Drive no longer
/// permits creating new ones).
///
/// Deny wins across parents, the same fail-closed direction every other
/// tie-break in this module takes. Callers with a single parent (the
/// common case) can call [`resolve`] directly instead; an *orphan* target
/// (zero parents) should call `resolve(&[], op, rules)` directly too,
/// rather than calling this with zero decisions — a target's chain
/// degenerates to "only the default policy applies," not to a policy
/// about *no* chain, so `first` requires at least one decision by
/// construction.
///
/// Shared by `drive edit` (whose target's chain starts at its *current*
/// parents, unioned) and `drive permissions check` (whose target may
/// itself be a file).
#[must_use]
pub fn combine_across_parents(
    first: Decision,
    rest: impl IntoIterator<Item = Decision>,
) -> Decision {
    rest.into_iter().fold(first, |acc, next| {
        if acc.verdict == Verdict::Deny {
            acc
        } else {
            next
        }
    })
}

/// The `(folder_id, file_id, depth)` triple
/// `crate::request_log::DriveMutationOutcome`'s `decided_by_folder_id`/
/// `decided_by_file_id`/`decided_by_depth` fields expect.
///
/// A named struct rather than a tuple: three same-shaped `Option`s in a
/// row is exactly the signature a caller silently mis-orders.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DecidedByLogFields {
    /// The deciding folder rule's id, when a folder rule decided it.
    pub folder_id: Option<String>,
    /// The deciding file rule's id, when a file rule decided it.
    pub file_id: Option<String>,
    /// The deciding folder rule's depth. Always `None` for a file rule,
    /// which matches the target itself rather than an ancestor.
    pub depth: Option<usize>,
}

/// Splits an optional [`DecidingRule`] into the log's flat fields.
///
/// Shared by `create`/`upload`/`edit`/`sheets write`'s otherwise
/// near-identical `record_attempt` functions — each still builds its own
/// `DriveMutationOutcome` (the verb-specific fields genuinely differ), but
/// this was the one piece of extraction logic that was byte-for-byte the
/// same in all of them. Kept here (rather than in `crate::request_log`,
/// which stays decoupled from any one integration's internal types) and
/// pure, matching this module's zero-I/O contract — it does not itself
/// call `crate::request_log::record_drive_mutation`.
#[must_use]
pub fn decided_by_log_fields(decided_by: Option<&DecidingRule>) -> DecidedByLogFields {
    match decided_by {
        Some(DecidingRule::Folder { folder_id, depth }) => DecidedByLogFields {
            folder_id: Some(folder_id.clone()),
            file_id: None,
            depth: Some(*depth),
        },
        Some(DecidingRule::File { file_id }) => DecidedByLogFields {
            folder_id: None,
            file_id: Some(file_id.clone()),
            depth: None,
        },
        None => DecidedByLogFields::default(),
    }
}

/// Whether the rule(s) that decided `decided_by` require a Drive write
/// lease ([ADR-0080](../../docs/adrs/adr-0080.md) §1/§13), in addition to
/// this gate's own verdict.
///
/// Deliberately a separate lookup rather than a field threaded through
/// [`resolve`]/[`resolve_file_rule`]/[`combine_across_parents`]: those are
/// well-exercised and security-critical, and `require_lease` is
/// orthogonal to the verdict they compute — a rule's lease requirement
/// never changes *whether* an operation is allowed, only whether a caller
/// must also present a valid lease before acting on an `Allow`. Re-deriving
/// it from `decided_by` against the same rule set keeps that code
/// untouched.
///
/// `decided_by: None` (the bare default policy decided it) is treated as
/// requiring a lease — moot in practice, since every write operation
/// defaults to `Deny` (`DriveOperation::default_policy`), so a `None`
/// `decided_by` never accompanies an `Allow` verdict a caller would act on.
/// When more than one configured rule matches `decided_by` itself (two
/// rules naming the same folder and depth, or the same file id), **any**
/// of them requiring a lease is enough — the same safe-direction tie-break
/// every other ambiguity in this gate resolves with.
///
/// This function only ever sees the **one** `decided_by` its caller passes
/// in — it cannot by itself account for a legacy multi-parent target,
/// where [`combine_across_parents`] keeps only the winning parent's
/// `decided_by` and discards the rest. A caller with more than one parent
/// decision in hand (`crate::drive::folder_ancestry::resolve_decision_for_parents`)
/// must call this once per parent and OR the results together *before*
/// folding the decisions, or a losing parent's own lease requirement would
/// be silently dropped along with its `decided_by`.
///
/// `op` narrows `matching` to rules that actually govern this operation
/// (`rule.allow`/`rule.deny` contains it) — without it, an unrelated rule
/// on the same folder/depth (or file id) governing a *different*
/// operation would still contribute its own `require_lease`, silently
/// overriding a same-scope rule that explicitly opted this operation out.
#[must_use]
pub fn decided_rule_requires_lease(
    decided_by: Option<&DecidingRule>,
    op: DriveOperation,
    rules: &[FolderPermissionRule],
) -> bool {
    let governs_op =
        |rule: &&FolderPermissionRule| rule.allow.contains(&op) || rule.deny.contains(&op);
    let matching: Vec<&FolderPermissionRule> = match decided_by {
        None => return true,
        Some(DecidingRule::Folder { folder_id, depth }) => rules
            .iter()
            .filter(|rule| {
                rule.folder_id.as_deref() == Some(folder_id.as_str())
                    && (*depth == 0 || rule.recursive)
            })
            .filter(governs_op)
            .collect(),
        Some(DecidingRule::File { file_id }) => rules
            .iter()
            .filter(|rule| rule.file_id.as_deref() == Some(file_id.as_str()))
            .filter(governs_op)
            .collect(),
    };
    if matching.is_empty() {
        return true;
    }
    matching.iter().any(|rule| rule.require_lease)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    fn rule(
        folder_id: &str,
        recursive: bool,
        allow: &[DriveOperation],
        deny: &[DriveOperation],
    ) -> FolderPermissionRule {
        FolderPermissionRule::folder(folder_id)
            .recursive(recursive)
            .allowing(allow.iter().copied())
            .denying(deny.iter().copied())
    }

    fn file_rule(
        file_id: &str,
        allow: &[DriveOperation],
        deny: &[DriveOperation],
    ) -> FolderPermissionRule {
        FolderPermissionRule::file(file_id)
            .allowing(allow.iter().copied())
            .denying(deny.iter().copied())
    }

    fn folder_id_of(decision: &Decision) -> &str {
        match decision.decided_by.as_ref().unwrap() {
            DecidingRule::Folder { folder_id, .. } => folder_id,
            DecidingRule::File { .. } => panic!("expected a folder rule, got a file rule"),
        }
    }

    fn chain(ids: &[&str]) -> Vec<String> {
        ids.iter().copied().map(ToString::to_string).collect()
    }

    #[test]
    fn default_policy_allows_read_with_no_rules() {
        let decision = resolve(&chain(&["a"]), DriveOperation::Read, &[]);
        assert_eq!(decision.verdict, Verdict::Allow);
        assert_eq!(decision.decided_by, None);
    }

    #[test]
    fn default_policy_denies_create_upload_edit_trash_with_no_rules() {
        for op in [
            DriveOperation::Create,
            DriveOperation::Upload,
            DriveOperation::Edit,
            DriveOperation::Trash,
        ] {
            let decision = resolve(&chain(&["a"]), op, &[]);
            assert_eq!(
                decision.verdict,
                Verdict::Deny,
                "{op:?} should default-deny"
            );
            assert_eq!(decision.decided_by, None);
        }
    }

    #[test]
    fn ever_requires_lease_is_false_for_read_create_upload_and_trash() {
        for op in [
            DriveOperation::Read,
            DriveOperation::Create,
            DriveOperation::Upload,
            DriveOperation::Trash,
        ] {
            assert!(
                !op.ever_requires_lease(),
                "{op:?} should never take a lease"
            );
        }
    }

    #[test]
    fn ever_requires_lease_is_true_for_every_content_mutating_operation() {
        for op in [
            DriveOperation::Edit,
            DriveOperation::SheetsWrite,
            DriveOperation::SheetsStructure,
            DriveOperation::SheetsDelete,
            DriveOperation::SheetsProtection,
            DriveOperation::DocsWrite,
            DriveOperation::DocsDelete,
            DriveOperation::DocsStructure,
            DriveOperation::DocsTableDelete,
        ] {
            assert!(op.ever_requires_lease(), "{op:?} should be lease-eligible");
        }
    }

    #[test]
    fn trash_permission_serde_round_trip_does_not_widen_other_grants() {
        let rule: FolderPermissionRule = serde_json::from_value(serde_json::json!({
            "folder_id": "a", "allow": ["trash"]
        }))
        .unwrap();
        assert!(rule.allow.contains(&DriveOperation::Trash));
        assert_eq!(DriveOperation::Trash.to_string(), "trash");
        let serialized = serde_json::to_value(&rule).unwrap();
        assert_eq!(serialized["allow"], serde_json::json!(["trash"]));
        for op in [
            DriveOperation::Create,
            DriveOperation::Edit,
            DriveOperation::SheetsDelete,
        ] {
            let rule = FolderPermissionRule::folder("a").allowing([op]);
            assert_eq!(
                resolve(&chain(&["a"]), DriveOperation::Trash, &[rule]).verdict,
                Verdict::Deny
            );
        }
    }

    #[test]
    fn recursive_rule_matches_deep_descendant() {
        let rules = [rule("root", true, &[DriveOperation::Create], &[])];
        let decision = resolve(
            &chain(&["child", "grandchild", "root"]),
            DriveOperation::Create,
            &rules,
        );
        assert_eq!(decision.verdict, Verdict::Allow);
        assert_eq!(folder_id_of(&decision), "root");
    }

    #[test]
    fn non_recursive_rule_matches_own_folder_only() {
        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
        assert_eq!(decision.verdict, Verdict::Allow);
    }

    #[test]
    fn non_recursive_rule_does_not_match_child() {
        let rules = [rule("parent", false, &[DriveOperation::Create], &[])];
        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
        // Falls through to the default policy since the non-recursive rule
        // never matches at depth > 0.
        assert_eq!(decision.verdict, Verdict::Deny);
        assert_eq!(decision.decided_by, None);
    }

    #[test]
    fn closest_ancestor_wins_deny_over_broader_allow() {
        let rules = [
            rule("child", true, &[], &[DriveOperation::Create]),
            rule("parent", true, &[DriveOperation::Create], &[]),
        ];
        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
        assert_eq!(decision.verdict, Verdict::Deny);
        assert_eq!(folder_id_of(&decision), "child");
    }

    #[test]
    fn closest_ancestor_wins_allow_over_broader_deny() {
        // The inverse case — proves "closest wins" isn't secretly "deny
        // always wins": a closer *allow* beats a farther *deny*.
        let rules = [
            rule("child", true, &[DriveOperation::Create], &[]),
            rule("parent", true, &[], &[DriveOperation::Create]),
        ];
        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
        assert_eq!(decision.verdict, Verdict::Allow);
        assert_eq!(folder_id_of(&decision), "child");
    }

    #[test]
    fn deny_beats_allow_at_equal_depth() {
        let rules = [
            rule("target", false, &[DriveOperation::Create], &[]),
            rule("target", false, &[], &[DriveOperation::Create]),
        ];
        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
        assert_eq!(decision.verdict, Verdict::Deny);
    }

    #[test]
    fn rule_on_unrelated_folder_does_not_apply() {
        let rules = [rule("unrelated", true, &[DriveOperation::Create], &[])];
        let decision = resolve(
            &chain(&["target", "parent"]),
            DriveOperation::Create,
            &rules,
        );
        assert_eq!(decision.verdict, Verdict::Deny);
        assert_eq!(decision.decided_by, None);
    }

    #[test]
    fn empty_chain_orphan_file_uses_default_policy_only() {
        let rules = [rule("some-folder", true, &[DriveOperation::Create], &[])];
        let decision = resolve(&[], DriveOperation::Create, &rules);
        assert_eq!(decision.verdict, Verdict::Deny);
        assert_eq!(decision.decided_by, None);
    }

    #[test]
    fn display_matches_the_serde_kebab_case_wire_form() {
        // Display is hand-written, so it can drift from the derive. Every
        // variant is asserted against a round-trip through serde rather
        // than a second hand-written literal, which would drift with it.
        for op in [
            DriveOperation::Read,
            DriveOperation::Create,
            DriveOperation::Upload,
            DriveOperation::Edit,
            DriveOperation::SheetsWrite,
            DriveOperation::SheetsStructure,
            DriveOperation::SheetsDelete,
            DriveOperation::SheetsProtection,
            DriveOperation::DocsWrite,
            DriveOperation::Trash,
            DriveOperation::DocsDelete,
            DriveOperation::DocsStructure,
            DriveOperation::DocsTableDelete,
        ] {
            let wire = serde_json::to_string(&op).unwrap();
            assert_eq!(
                format!("\"{op}\""),
                wire,
                "Display drifted from serde for {op:?}"
            );
        }
        // The pre-existing spellings are pinned literally: switching
        // `rename_all` to kebab-case must not have moved any of them.
        assert_eq!(DriveOperation::Read.to_string(), "read");
        assert_eq!(DriveOperation::Create.to_string(), "create");
        assert_eq!(DriveOperation::Upload.to_string(), "upload");
        assert_eq!(DriveOperation::Edit.to_string(), "edit");
        assert_eq!(DriveOperation::SheetsWrite.to_string(), "sheets-write");
        assert_eq!(
            DriveOperation::SheetsStructure.to_string(),
            "sheets-structure"
        );
        assert_eq!(DriveOperation::SheetsDelete.to_string(), "sheets-delete");
        assert_eq!(
            DriveOperation::SheetsProtection.to_string(),
            "sheets-protection"
        );
        assert_eq!(DriveOperation::DocsWrite.to_string(), "docs-write");
    }

    #[test]
    fn sheets_write_defaults_to_deny_like_every_other_write() {
        let decision = resolve(&chain(&["f"]), DriveOperation::SheetsWrite, &[]);
        assert_eq!(decision.verdict, Verdict::Deny);
        assert!(decision.decided_by.is_none());
    }

    #[test]
    fn an_edit_rule_does_not_grant_sheets_write() {
        // The whole reason for a separate variant: an existing
        // `allow: ["edit"]` rule must not silently gain cell-write power.
        let rules = [rule("target", true, &[DriveOperation::Edit], &[])];
        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
        let sheets = resolve(&chain(&["target"]), DriveOperation::SheetsWrite, &rules);
        assert_eq!(edit.verdict, Verdict::Allow);
        assert_eq!(sheets.verdict, Verdict::Deny);
    }

    #[test]
    fn sheets_structure_defaults_to_deny_like_every_other_write() {
        let decision = resolve(&chain(&["f"]), DriveOperation::SheetsStructure, &[]);
        assert_eq!(decision.verdict, Verdict::Deny);
        assert!(decision.decided_by.is_none());
    }

    #[test]
    fn slides_write_is_a_distinct_default_deny_leased_operation() {
        assert_eq!(DriveOperation::SlidesWrite.to_string(), "slides-write");
        assert!(DriveOperation::SlidesWrite.ever_requires_lease());
        assert_eq!(
            serde_json::to_string(&DriveOperation::SlidesWrite).unwrap(),
            "\"slides-write\""
        );
        assert_eq!(
            resolve(&chain(&["target"]), DriveOperation::SlidesWrite, &[]).verdict,
            Verdict::Deny
        );
        for granted in [
            DriveOperation::Edit,
            DriveOperation::DocsWrite,
            DriveOperation::SheetsWrite,
        ] {
            let rules = [rule("target", true, &[granted], &[])];
            assert_eq!(
                resolve(&chain(&["target"]), DriveOperation::SlidesWrite, &rules).verdict,
                Verdict::Deny
            );
        }
        let rules = [rule("target", true, &[DriveOperation::SlidesWrite], &[])];
        assert_eq!(
            resolve(&chain(&["target"]), DriveOperation::SlidesWrite, &rules).verdict,
            Verdict::Allow
        );
        for other in [
            DriveOperation::Edit,
            DriveOperation::DocsWrite,
            DriveOperation::SheetsWrite,
        ] {
            assert_eq!(
                resolve(&chain(&["target"]), other, &rules).verdict,
                Verdict::Deny
            );
        }
    }

    #[test]
    fn docs_write_defaults_to_deny_like_every_other_write() {
        let decision = resolve(&chain(&["f"]), DriveOperation::DocsWrite, &[]);
        assert_eq!(decision.verdict, Verdict::Deny);
        assert!(decision.decided_by.is_none());
    }

    #[test]
    fn a_sheets_write_rule_does_not_grant_sheets_structure() {
        // The whole reason for a second Sheets variant (issue #1613): an
        // existing `allow: ["sheets-write"]` rule was written when structural
        // edits were impossible, so it must not silently gain the power to
        // add, rename or reshape sheets.
        let rules = [rule("target", true, &[DriveOperation::SheetsWrite], &[])];
        let write = resolve(&chain(&["target"]), DriveOperation::SheetsWrite, &rules);
        let structure = resolve(&chain(&["target"]), DriveOperation::SheetsStructure, &rules);
        assert_eq!(write.verdict, Verdict::Allow);
        assert_eq!(structure.verdict, Verdict::Deny);
    }

    #[test]
    fn an_edit_rule_does_not_grant_sheets_structure() {
        let rules = [rule("target", true, &[DriveOperation::Edit], &[])];
        let structure = resolve(&chain(&["target"]), DriveOperation::SheetsStructure, &rules);
        assert_eq!(structure.verdict, Verdict::Deny);
    }

    /// ADR-0076 §2 runs ADR-0073 §3's argument in **three** directions, so
    /// all are pinned: an `edit` grant predates Docs being reachable at
    /// all, and both Sheets grants are lexically about cells.
    #[test]
    fn neither_an_edit_nor_a_sheets_write_nor_a_sheets_structure_rule_grants_docs_write() {
        for granted in [
            DriveOperation::Edit,
            DriveOperation::SheetsWrite,
            DriveOperation::SheetsStructure,
        ] {
            let rules = [rule("target", true, &[granted], &[])];
            let held = resolve(&chain(&["target"]), granted, &rules);
            let docs = resolve(&chain(&["target"]), DriveOperation::DocsWrite, &rules);
            assert_eq!(held.verdict, Verdict::Allow, "{granted:?}");
            assert_eq!(
                docs.verdict,
                Verdict::Deny,
                "an allow:[{granted}] rule must not leak into docs-write"
            );
        }
    }

    /// The converse, so the separation is not accidentally one-way: a
    /// `docs-write` grant must not confer cell writes, structural sheet
    /// edits, or raw content edits.
    #[test]
    fn a_docs_write_rule_grants_nothing_else() {
        let rules = [rule("target", true, &[DriveOperation::DocsWrite], &[])];
        assert_eq!(
            resolve(&chain(&["target"]), DriveOperation::DocsWrite, &rules).verdict,
            Verdict::Allow
        );
        for other in [
            DriveOperation::Edit,
            DriveOperation::SheetsWrite,
            DriveOperation::SheetsStructure,
            DriveOperation::Create,
            DriveOperation::Upload,
            DriveOperation::Trash,
        ] {
            assert_eq!(
                resolve(&chain(&["target"]), other, &rules).verdict,
                Verdict::Deny,
                "an allow:[docs-write] rule must not leak into {other}"
            );
        }
    }

    #[test]
    fn operations_on_one_rule_are_independent() {
        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
        assert_eq!(create.verdict, Verdict::Allow);
        assert_eq!(
            upload.verdict,
            Verdict::Deny,
            "an allow:[create] rule must not leak into upload"
        );
    }

    #[test]
    fn deny_list_and_allow_list_on_same_rule_apply_to_different_ops_independently() {
        let rules = [rule(
            "target",
            false,
            &[DriveOperation::Create],
            &[DriveOperation::Edit],
        )];
        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
        assert_eq!(create.verdict, Verdict::Allow);
        assert_eq!(edit.verdict, Verdict::Deny);
        assert_eq!(
            upload.verdict,
            Verdict::Deny,
            "no rule named upload; falls to default policy"
        );
    }

    // ── combine_across_parents ────────────────────────────────────────

    fn decision(verdict: Verdict) -> Decision {
        Decision {
            verdict,
            decided_by: None,
        }
    }

    #[test]
    fn combine_across_parents_single_decision_returns_it_unchanged() {
        let combined = combine_across_parents(decision(Verdict::Allow), []);
        assert_eq!(combined.verdict, Verdict::Allow);
    }

    #[test]
    fn combine_across_parents_deny_beats_allow_deny_first() {
        let combined = combine_across_parents(decision(Verdict::Deny), [decision(Verdict::Allow)]);
        assert_eq!(combined.verdict, Verdict::Deny);
    }

    #[test]
    fn combine_across_parents_deny_beats_allow_allow_first() {
        let combined = combine_across_parents(decision(Verdict::Allow), [decision(Verdict::Deny)]);
        assert_eq!(combined.verdict, Verdict::Deny);
    }

    // ── decided_by_log_fields ────────────────────────────────────────

    #[test]
    fn decided_by_log_fields_none_yields_all_none() {
        assert_eq!(decided_by_log_fields(None), DecidedByLogFields::default());
    }

    #[test]
    fn decided_by_log_fields_some_extracts_folder_id_and_depth() {
        let rule = DecidingRule::Folder {
            folder_id: "folder-1".to_string(),
            depth: 2,
        };
        assert_eq!(
            decided_by_log_fields(Some(&rule)),
            DecidedByLogFields {
                folder_id: Some("folder-1".to_string()),
                file_id: None,
                depth: Some(2),
            }
        );
    }

    #[test]
    fn decided_by_log_fields_never_puts_a_file_id_in_the_folder_field() {
        // `omni-dev log --query decided_by_folder_id:X` must never start
        // matching file ids — that would silently reinterpret an existing
        // audit key.
        let rule = DecidingRule::File {
            file_id: "file-1".to_string(),
        };
        assert_eq!(
            decided_by_log_fields(Some(&rule)),
            DecidedByLogFields {
                folder_id: None,
                file_id: Some("file-1".to_string()),
                depth: None,
            }
        );
    }

    #[test]
    fn decided_rule_requires_lease_defaults_true_with_no_decided_by() {
        assert!(decided_rule_requires_lease(None, DriveOperation::Edit, &[]));
    }

    #[test]
    fn decided_rule_requires_lease_true_when_the_folder_rule_says_so() {
        let rules = [FolderPermissionRule::folder("f1").allowing([DriveOperation::Edit])];
        let decided_by = DecidingRule::Folder {
            folder_id: "f1".to_string(),
            depth: 0,
        };
        assert!(decided_rule_requires_lease(
            Some(&decided_by),
            DriveOperation::Edit,
            &rules
        ));
    }

    #[test]
    fn decided_rule_requires_lease_false_when_the_folder_rule_opts_out() {
        let rules = [FolderPermissionRule::folder("f1")
            .allowing([DriveOperation::Edit])
            .requiring_lease(false)];
        let decided_by = DecidingRule::Folder {
            folder_id: "f1".to_string(),
            depth: 0,
        };
        assert!(!decided_rule_requires_lease(
            Some(&decided_by),
            DriveOperation::Edit,
            &rules
        ));
    }

    #[test]
    fn decided_rule_requires_lease_true_when_the_file_rule_says_so() {
        let rules = [FolderPermissionRule::file("file-1").allowing([DriveOperation::Edit])];
        let decided_by = DecidingRule::File {
            file_id: "file-1".to_string(),
        };
        assert!(decided_rule_requires_lease(
            Some(&decided_by),
            DriveOperation::Edit,
            &rules
        ));
    }

    #[test]
    fn decided_rule_requires_lease_any_matching_rule_opting_in_wins() {
        // Two rules on the same folder, both governing `Edit`: one opting
        // out and one not — the safe direction (any rule requiring a
        // lease is enough) wins.
        let rules = [
            FolderPermissionRule::folder("f1")
                .allowing([DriveOperation::Edit])
                .requiring_lease(false),
            FolderPermissionRule::folder("f1").allowing([DriveOperation::Edit]),
        ];
        let decided_by = DecidingRule::Folder {
            folder_id: "f1".to_string(),
            depth: 0,
        };
        assert!(decided_rule_requires_lease(
            Some(&decided_by),
            DriveOperation::Edit,
            &rules
        ));
    }

    #[test]
    fn decided_rule_requires_lease_ignores_a_non_recursive_rule_on_an_ancestor() {
        // The depth-0 rule opted out; an unrelated non-recursive rule
        // naming the same folder id at a *different* depth must not be
        // consulted (mirrors `resolve`'s own recursive-match rule).
        let rules = [FolderPermissionRule::folder("f1")
            .allowing([DriveOperation::Edit])
            .requiring_lease(false)];
        let decided_by = DecidingRule::Folder {
            folder_id: "f1".to_string(),
            depth: 1,
        };
        // depth=1 with a non-recursive rule: the rule does not match at
        // this depth, so no matching rule is found and the fail-safe
        // default (true) applies.
        assert!(decided_rule_requires_lease(
            Some(&decided_by),
            DriveOperation::Edit,
            &rules
        ));
    }

    #[test]
    fn decided_rule_requires_lease_ignores_a_rule_for_a_different_operation() {
        // Two rules on the same folder/depth, governing different
        // operations: the `Edit` rule opts out, and an unrelated
        // `SheetsWrite` rule (which does not opt out) must not contribute
        // its own `require_lease` to the `Edit` lookup — a rule that
        // doesn't govern the operation being checked isn't "matching".
        let rules = [
            FolderPermissionRule::folder("f1")
                .allowing([DriveOperation::Edit])
                .requiring_lease(false),
            FolderPermissionRule::folder("f1").allowing([DriveOperation::SheetsWrite]),
        ];
        let decided_by = DecidingRule::Folder {
            folder_id: "f1".to_string(),
            depth: 0,
        };
        assert!(!decided_rule_requires_lease(
            Some(&decided_by),
            DriveOperation::Edit,
            &rules
        ));
    }

    #[test]
    fn combine_across_parents_all_allow_returns_allow() {
        let combined = combine_across_parents(
            decision(Verdict::Allow),
            [decision(Verdict::Allow), decision(Verdict::Allow)],
        );
        assert_eq!(combined.verdict, Verdict::Allow);
    }

    // ── file rules (issue #1612) ──────────────────────────────────────

    #[test]
    fn a_file_rule_grants_a_target_with_no_chain_at_all() {
        // The whole point of the feature: a Sheet shared by link or email
        // has no visible parent, so the chain is empty and no folder rule
        // could ever apply.
        let rules = [file_rule("shared", &[DriveOperation::SheetsWrite], &[])];
        let decision = resolve_for_file("shared", &[], DriveOperation::SheetsWrite, &rules);
        assert_eq!(decision.verdict, Verdict::Allow);
        assert_eq!(
            decision.decided_by,
            Some(DecidingRule::File {
                file_id: "shared".to_string()
            })
        );
    }

    #[test]
    fn file_deny_beats_a_recursive_folder_allow() {
        let rules = [
            rule("root", true, &[DriveOperation::Edit], &[]),
            file_rule("target", &[], &[DriveOperation::Edit]),
        ];
        let decision = resolve_for_file(
            "target",
            &chain(&["parent", "root"]),
            DriveOperation::Edit,
            &rules,
        );
        assert_eq!(decision.verdict, Verdict::Deny);
        assert_eq!(decision.decided_by.unwrap().kind_label(), "file");
    }

    #[test]
    fn file_allow_beats_a_folder_deny_on_the_immediate_parent() {
        // The inverse direction — proves depth −1 is "closest wins", not a
        // one-way "a file rule can only restrict" rule.
        let rules = [
            rule("parent", false, &[], &[DriveOperation::SheetsWrite]),
            file_rule("target", &[DriveOperation::SheetsWrite], &[]),
        ];
        let decision = resolve_for_file(
            "target",
            &chain(&["parent"]),
            DriveOperation::SheetsWrite,
            &rules,
        );
        assert_eq!(decision.verdict, Verdict::Allow);
    }

    #[test]
    fn deny_beats_allow_among_file_rules_for_the_same_id() {
        let rules = [
            file_rule("target", &[DriveOperation::Edit], &[]),
            file_rule("target", &[], &[DriveOperation::Edit]),
        ];
        let decision = resolve_for_file("target", &[], DriveOperation::Edit, &rules);
        assert_eq!(decision.verdict, Verdict::Deny);
    }

    #[test]
    fn resolve_file_rule_returns_none_rather_than_the_default_policy() {
        // Must be distinguishable from "decided deny", or the caller would
        // skip the ancestor walk that could still have granted it.
        let rules = [file_rule("target", &[DriveOperation::Edit], &[])];
        assert!(resolve_file_rule("target", DriveOperation::SheetsWrite, &rules).is_none());
        assert!(resolve_file_rule("other", DriveOperation::Edit, &rules).is_none());
    }

    #[test]
    fn a_file_rule_is_invisible_to_the_ancestor_walk() {
        // A `file_id` rule whose id happens to equal a folder in the chain
        // must not match it: the two id spaces are not interchangeable.
        let rules = [file_rule("target", &[DriveOperation::Create], &[])];
        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
        assert_eq!(decision.verdict, Verdict::Deny);
        assert_eq!(decision.decided_by, None);
    }

    #[test]
    fn a_folder_rule_is_invisible_to_the_file_lookup() {
        let rules = [rule("target", true, &[DriveOperation::Create], &[])];
        assert!(resolve_file_rule("target", DriveOperation::Create, &rules).is_none());
    }

    #[test]
    fn resolve_for_file_falls_through_to_the_chain_when_no_file_rule_matches() {
        let rules = [rule("parent", true, &[DriveOperation::Edit], &[])];
        let decision =
            resolve_for_file("target", &chain(&["parent"]), DriveOperation::Edit, &rules);
        assert_eq!(decision.verdict, Verdict::Allow);
        assert_eq!(folder_id_of(&decision), "parent");
    }

    // ── rule schema validation ────────────────────────────────────────

    fn parse_rule(json: &str) -> Result<FolderPermissionRule, serde_json::Error> {
        serde_json::from_str(json)
    }

    #[test]
    fn a_folder_rule_round_trips_byte_identically_to_the_pre_1612_shape() {
        let parsed =
            parse_rule(r#"{"folder_id":"f1","recursive":true,"allow":["create"]}"#).unwrap();
        assert_eq!(parsed.folder_id.as_deref(), Some("f1"));
        assert_eq!(parsed.file_id, None);
        assert!(parsed.recursive);
        let json: serde_json::Value = serde_json::to_value(&parsed).unwrap();
        assert_eq!(json["folder_id"], "f1");
        assert_eq!(json["recursive"], true);
        assert!(
            json.get("file_id").is_none(),
            "an absent file_id must not be serialized"
        );
    }

    #[test]
    fn a_pre_lease_rule_with_no_require_lease_key_defaults_to_requiring_one() {
        // Pins `default_require_lease`'s return value against a
        // pre-existing `settings.json` written before ADR-0080 (issue
        // #1664): every operator's already-saved rule has no
        // `require_lease` key at all, and the backward-compatibility
        // guarantee is that it is still gated by the lease, not silently
        // exempted. Nothing else in this test file asserts this — a
        // refactor that broke `default_require_lease`'s body (e.g.
        // swapping `#[serde(default = "default_require_lease")]` for a
        // bare `#[serde(default)]`) would otherwise compile and pass every
        // other test while disabling Touch ID/backup enforcement crate-wide
        // for every such rule.
        let parsed =
            parse_rule(r#"{"folder_id":"f1","recursive":true,"allow":["create"]}"#).unwrap();
        assert!(parsed.require_lease);
    }

    #[test]
    fn a_file_rule_parses_and_serializes_without_a_folder_id() {
        let parsed = parse_rule(r#"{"file_id":"x1","allow":["sheets-write"]}"#).unwrap();
        assert_eq!(parsed.file_id.as_deref(), Some("x1"));
        assert_eq!(parsed.folder_id, None);
        let json: serde_json::Value = serde_json::to_value(&parsed).unwrap();
        assert_eq!(json["file_id"], "x1");
        assert!(json.get("folder_id").is_none());
    }

    #[test]
    fn a_rule_naming_neither_target_is_a_load_error() {
        let err = parse_rule(r#"{"allow":["create"]}"#)
            .unwrap_err()
            .to_string();
        assert!(err.contains("folder_id"), "{err}");
        assert!(err.contains("file_id"), "{err}");
    }

    #[test]
    fn a_rule_naming_both_targets_is_a_load_error() {
        let err = parse_rule(r#"{"folder_id":"f","file_id":"x"}"#)
            .unwrap_err()
            .to_string();
        assert!(err.contains("not both"), "{err}");
    }

    #[test]
    fn recursive_true_on_a_file_rule_is_a_load_error() {
        // Silently ignoring it would let an operator believe they had
        // granted a subtree when they had granted one file.
        let err = parse_rule(r#"{"file_id":"x","recursive":true}"#)
            .unwrap_err()
            .to_string();
        assert!(err.contains("recursive"), "{err}");
    }

    #[test]
    fn recursive_false_on_a_file_rule_is_accepted() {
        // Redundant but not a misunderstanding, so it must not error.
        let parsed = parse_rule(r#"{"file_id":"x","recursive":false}"#).unwrap();
        assert!(!parsed.recursive);
    }

    #[test]
    fn deciding_rule_accessors_render_both_kinds() {
        let folder = DecidingRule::Folder {
            folder_id: "f1".to_string(),
            depth: 2,
        };
        assert_eq!(folder.id(), "f1");
        assert_eq!(folder.kind_label(), "folder");
        assert_eq!(folder.depth_suffix(), " (depth 2)");

        let file = DecidingRule::File {
            file_id: "x1".to_string(),
        };
        assert_eq!(file.id(), "x1");
        assert_eq!(file.kind_label(), "file");
        assert_eq!(file.depth_suffix(), "");
    }
    #[test]
    fn docs_format_is_default_denied_lease_eligible_and_isolated() {
        let format: DriveOperation = serde_json::from_str("\"docs-format\"").unwrap();
        assert_eq!(format, DriveOperation::DocsFormat);
        assert_eq!(format.to_string(), "docs-format");
        assert_eq!(format.default_policy(), Verdict::Deny);
        assert!(format.ever_requires_lease());
        for other in [
            DriveOperation::DocsWrite,
            DriveOperation::DocsDelete,
            DriveOperation::Edit,
            DriveOperation::SheetsWrite,
            DriveOperation::SlidesWrite,
        ] {
            assert_eq!(
                resolve(
                    &chain(&["target"]),
                    format,
                    &[rule("target", true, &[other], &[])]
                )
                .verdict,
                Verdict::Deny
            );
            assert_eq!(
                resolve(
                    &chain(&["target"]),
                    other,
                    &[rule("target", true, &[format], &[])]
                )
                .verdict,
                Verdict::Deny
            );
        }
        assert_eq!(
            resolve(
                &chain(&["target"]),
                format,
                &[rule("target", true, &[format], &[])]
            )
            .verdict,
            Verdict::Allow
        );
    }
}