aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md
blob: ddafaead8c50b4c298a015156eb264296ad669ad (plain)
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
# Rule Apply and Archive-on-Arrival Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let the rules dialog apply a saved rule to existing mail (confirmed, undoable), and let a rule opt in to removing `inbox` on arrival so the `post-new` hook stops skipping it.

**Architecture:** Spec: `docs/superpowers/specs/2026-10-31-rule-apply-and-archive-design.md`. Part B adds an optional `allow_archive` rule field to both readers (`src/tagrules.cpp`, `assets/hooks/mailrules.py`) and teaches `post-new` to honour it for `inbox` only. Part A adds `SearchTerm::changes()` (the "would change" query), a worker slot `applyTagsToQuery` that resolves ids and calls the existing `applyTags()`, an Apply button in `TagRulesDialog` that saves first and emits `applyRequested(TagRule)`, and `MainWindow::applyRuleToExistingMail()` that counts, confirms, and writes the rule one tag at a time, each as a `MessageTagCommand` filled from its confirmation, grouped in one undo macro.

**Tech Stack:** C++17, Qt 6.11 (Widgets, Test), libnotmuch, Python 3 stdlib (hook), CMake/Ninja.

**Conventions for every task:**
- Build: `cmake --build build`. Run a C++ test binary ONLY as `QT_QPA_PLATFORM=offscreen ./build/tests/test_<name> [function]`. Never launch `./build/src/qtmaildir`.
- Python hook tests run from `assets/hooks/`: `./test_mailrules.py && ./test_post_new.py`.
- `tests/test_mainwindow` has 4 failures on clean master (`everyBuiltinFilterButtonCarriesAnIconAndItsText`, `everyActionCarriesAnIcon`, `noTwoActionsShareAnIcon`, `undoMovesTheMessageBack`). They are environmental and pre-existing; any OTHER failure is yours.
- Commits are GPG-signed: `git commit -S`. End every message with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Never stage `HANDOFF.md`.
- No em dashes anywhere (code comments, docs, commit messages). No real names, addresses or subjects in fixtures: use `example.org` / `example.net`.
- Every user-facing string goes through `tr()`. Notmuch query syntax is never translated.

---

### Task 1: `SearchTerm::changes()`, the "would change" query

**Files:**
- Modify: `src/searchterm.h` (after `exclude()`, before the closing `}  // namespace SearchTerm`)
- Modify: `src/searchterm.cpp`
- Test: `tests/test_searchterm.cpp`

- [ ] **Step 1: Write the failing tests**

Add to the `private slots:` list in `tests/test_searchterm.cpp`:

```cpp
    void changesNarrowsToMessagesTheTagsWouldMove();
    void changesKeepsADisjunctionInsideItsScope();
    void changesWithNothingToDoMatchesNothing();
```

Add the bodies before `QTEST_MAIN`:

```cpp
void TestSearchTerm::changesNarrowsToMessagesTheTagsWouldMove()
{
    // Item 212. A message already carrying every added tag and none of the
    // removed ones would not change, so it must not be counted or written.
    QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"),
                                 { QStringLiteral("promo") },
                                 { QStringLiteral("inbox") }),
             QStringLiteral("(from:a@example.org) AND "
                            "(not tag:promo or tag:inbox)"));
    QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"),
                                 { QStringLiteral("x"), QStringLiteral("y") },
                                 {}),
             QStringLiteral("(from:a@example.org) AND "
                            "(not tag:x or not tag:y)"));
    QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"), {},
                                 { QStringLiteral("inbox") }),
             QStringLiteral("(from:a@example.org) AND (tag:inbox)"));
}

void TestSearchTerm::changesKeepsADisjunctionInsideItsScope()
{
    // `a or b AND c` binds as `a or (b AND c)`, which would write to every
    // message matching `a` whatever its tags. extend() parenthesises both.
    QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org or "
                                                "from:b@example.org"),
                                 { QStringLiteral("promo") }, {}),
             QStringLiteral("(from:a@example.org or from:b@example.org) AND "
                            "(not tag:promo)"));
}

void TestSearchTerm::changesWithNothingToDoMatchesNothing()
{
    // notmuch reads an EMPTY query as "match everything". A caller writing
    // through this must never be handed one.
    QCOMPARE(SearchTerm::changes(QString(), { QStringLiteral("x") }, {}),
             Config::matchNothingQuery());
    QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"), {}, {}),
             Config::matchNothingQuery());
}
```

Add `#include "config.h"` beside `#include "searchterm.h"` in the test if it is not already there.

- [ ] **Step 2: Run to verify it fails**

Run: `cmake --build build 2>&1 | grep -m3 error`
Expected: compile error, `'changes' is not a member of 'SearchTerm'`.

- [ ] **Step 3: Implement**

In `src/searchterm.h`, before the namespace's closing brace:

```cpp
/// The messages among `query` that `+add -remove` would actually change:
/// `(query) AND (not tag:a1 or ... or tag:r1 ...)`.
///
/// Used to count and to write a rule applied to existing mail (item 212), so
/// the number in the confirmation is the number of messages touched, not the
/// number matched. Built on extend(), so a disjunction in the rule cannot
/// escape the scope.
///
/// **Never empty.** An empty query or an empty change yields
/// Config::matchNothingQuery(), because notmuch reads an empty query as
/// "match everything" and this string is written to.
QString changes(const QString &query, const QStringList &add,
                const QStringList &remove);
```

In `src/searchterm.cpp`, add `#include "config.h"` below `#include "searchterm.h"`, and at the end of the namespace:

```cpp
QString changes(const QString &query, const QStringList &add,
                const QStringList &remove)
{
    QStringList terms;
    for (const QString &name : add) {
        const QString term = tag(name);
        if (!term.isEmpty())
            terms.append(QStringLiteral("not ") + term);
    }
    for (const QString &name : remove) {
        const QString term = tag(name);
        if (!term.isEmpty())
            terms.append(term);
    }
    if (query.trimmed().isEmpty() || terms.isEmpty())
        return Config::matchNothingQuery();
    return extend(query, terms.join(QStringLiteral(" or ")));
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_searchterm`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add src/searchterm.h src/searchterm.cpp tests/test_searchterm.cpp
git commit -S -m "feat: build the query for what a tag change would move

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 2: `allow_archive` in both rule readers

Read "Changing the rule format" at the bottom of `AGENTS.md` first. Optional field, so **no version bump**.

**Files:**
- Modify: `src/tagrules.h` (`struct TagRule`, and a new static in `class TagRules`)
- Modify: `src/tagrules.cpp` (`isKnownKey`, `load`, `save`, new `hookWouldSkip`)
- Modify: `assets/hooks/mailrules.py` (`KNOWN_KEYS`, `Rule`, `_parse_rule`, `_rule_to_dict`)
- Test: `tests/test_tagrules.cpp`, `assets/hooks/test_mailrules.py`

- [ ] **Step 1: Write the failing C++ tests**

Add to `private slots:` in `tests/test_tagrules.cpp`:

```cpp
    void allowArchiveRoundTripsAndIsOmittedWhenFalse();
    void allowArchiveIsDroppedFromARuleThatKeepsInbox();
    void hookWouldSkipMirrorsThePostNewRefusal();
```

Bodies, before `QTEST_MAIN`:

```cpp
void TestTagRules::allowArchiveRoundTripsAndIsOmittedWhenFalse()
{
    // Item 213. Optional, so a file with no archiving rule must save
    // unchanged: no "allow_archive": false appears from nowhere.
    const QString path = writeRules(R"({
      "version": 1,
      "rules": [
        {"id": "promo", "add": ["promo"], "remove": ["inbox"],
         "query": "from:a@example.org", "allow_archive": true},
        {"id": "plain", "add": ["x"], "query": "from:b@example.org"}
      ]
    })");

    TagRules rules;
    rules.load(path);
    QCOMPARE(rules.rules().size(), 2);
    QVERIFY(rules.rules().at(0).allowArchive);
    QVERIFY(!rules.rules().at(1).allowArchive);
    // Known now, so it is not ALSO carried as an unknown field.
    QVERIFY(!rules.rules().at(0).unknown.contains(QStringLiteral("allow_archive")));

    QVERIFY(rules.save(path));
    QFile file(path);
    QVERIFY(file.open(QIODevice::ReadOnly));
    const QJsonArray saved = QJsonDocument::fromJson(file.readAll())
                                 .object().value(QStringLiteral("rules")).toArray();
    QCOMPARE(saved.at(0).toObject().value(QStringLiteral("allow_archive")),
             QJsonValue(true));
    QVERIFY(!saved.at(1).toObject().contains(QStringLiteral("allow_archive")));
}

void TestTagRules::allowArchiveIsDroppedFromARuleThatKeepsInbox()
{
    // A flag that does nothing is cleared by the WRITER, never rejected by a
    // reader: the hook must not drop a whole rule over a harmless flag.
    TagRule rule;
    rule.id = QStringLiteral("x");
    rule.query = QStringLiteral("from:a@example.org");
    rule.add = { QStringLiteral("x") };
    rule.allowArchive = true;

    TagRules rules;
    rules.setRules({ rule });
    QVERIFY(TagRules::validate(rules.rules()).isEmpty());
    const QString path = m_dir.filePath(QStringLiteral("cleared.json"));
    QVERIFY(rules.save(path));

    TagRules reloaded;
    reloaded.load(path);
    QVERIFY(!reloaded.rules().at(0).allowArchive);
}

void TestTagRules::hookWouldSkipMirrorsThePostNewRefusal()
{
    // Mirrors PROTECTED_REMOVALS in assets/hooks/post-new, which the Python
    // suite tests against the hook itself.
    TagRule rule;
    rule.remove = { QStringLiteral("inbox") };
    QVERIFY(TagRules::hookWouldSkip(rule));
    rule.allowArchive = true;
    QVERIFY(!TagRules::hookWouldSkip(rule));

    rule.remove = { QStringLiteral("unread") };
    QVERIFY(TagRules::hookWouldSkip(rule));   // never opted out

    rule.remove = { QStringLiteral("promo") };
    rule.allowArchive = false;
    QVERIFY(!TagRules::hookWouldSkip(rule));
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `cmake --build build 2>&1 | grep -m3 error`
Expected: `'struct TagRule' has no member named 'allowArchive'`.

- [ ] **Step 3: Implement the C++ reader and writer**

In `src/tagrules.h`, in `struct TagRule`, after `bool enabled = true;`:

```cpp
    /// Lets the post-new hook remove `inbox` from arriving mail for this rule
    /// (item 213). Meaningless, and cleared on save, unless `remove` holds
    /// `inbox`. `unread` stays refused regardless.
    bool allowArchive = false;
```

In `class TagRules`, beside `validate()`:

```cpp
    /// Whether the post-new hook will skip this rule whole. Mirrors
    /// PROTECTED_REMOVALS in assets/hooks/post-new: `unread` always, `inbox`
    /// unless the rule opts in with allowArchive.
    static bool hookWouldSkip(const TagRule &rule);
```

In `src/tagrules.cpp`:

1. `isKnownKey`: add `QStringLiteral("allow_archive"),` to the list.
2. In `load()`, after `rule.note = ...`:

```cpp
        rule.allowArchive =
            object.value(QStringLiteral("allow_archive")).toBool(false);
```

3. In `save()`, after `object.insert(QStringLiteral("note"), rule.note);`:

```cpp
        // Written only where it does something, so a file without an
        // archiving rule saves byte-identical (item 213).
        if (rule.allowArchive && rule.remove.contains(QStringLiteral("inbox")))
            object.insert(QStringLiteral("allow_archive"), true);
```

4. New function, after `validate()`:

```cpp
bool TagRules::hookWouldSkip(const TagRule &rule)
{
    if (rule.remove.contains(QStringLiteral("unread")))
        return true;
    return rule.remove.contains(QStringLiteral("inbox")) && !rule.allowArchive;
}
```

- [ ] **Step 4: Run C++ tests**

Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules`
Expected: all PASS.

- [ ] **Step 5: Write the failing Python tests**

Append to `assets/hooks/test_mailrules.py`, above `def run_all():`:

```python
def test_allow_archive_round_trips_and_is_omitted_when_false():
    """Item 213. Known, so not carried as unknown; written only when true."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [
                {"id": "promo", "add": ["promo"], "remove": ["inbox"],
                 "query": "from:a@example.com", "allow_archive": True},
                {"id": "plain", "add": ["x"], "query": "from:b@example.com"},
            ],
        })
        store = mailrules.load(path)
        assert store.rules[0].allow_archive is True
        assert store.rules[1].allow_archive is False
        assert "allow_archive" not in store.rules[0].unknown

        mailrules.save(store, path)
        raw = json.loads(path.read_text())
        assert raw["rules"][0]["allow_archive"] is True
        assert "allow_archive" not in raw["rules"][1]


def test_allow_archive_on_a_rule_keeping_inbox_is_accepted_and_ignored():
    """Never a reason to drop a rule: the hook runs unattended."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [{"id": "x", "add": ["x"], "query": "from:a@example.com",
                       "allow_archive": True}],
        })
        store = mailrules.load(path)
        assert len(store.rules) == 1
        assert store.warnings == []
```

- [ ] **Step 6: Run to verify it fails**

Run: `cd assets/hooks && ./test_mailrules.py`
Expected: `AttributeError: 'Rule' object has no attribute 'allow_archive'`.

- [ ] **Step 7: Implement the Python reader and writer**

In `assets/hooks/mailrules.py`:

1. `KNOWN_KEYS = {"id", "stage", "enabled", "add", "remove", "query", "note", "allow_archive"}`
2. In `class Rule`, after `note: str = ""`: `allow_archive: bool = False`
3. In `_parse_rule`'s `return Rule(...)`, after the `note=` line:

```python
        allow_archive=obj.get("allow_archive") is True,
```

4. In `_rule_to_dict`, before `out.update(rule.unknown)`:

```python
    # Written only where it does something, matching src/tagrules.cpp, so a
    # file without an archiving rule round-trips unchanged (item 213).
    if rule.allow_archive and "inbox" in rule.remove:
        out["allow_archive"] = True
```

- [ ] **Step 8: Run both suites**

Run: `cd assets/hooks && ./test_mailrules.py && ./test_post_new.py; cd ../.. && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules | tail -1`
Expected: `all passed` twice, and `0 failed`.

- [ ] **Step 9: Commit**

```bash
git add src/tagrules.h src/tagrules.cpp tests/test_tagrules.cpp assets/hooks/mailrules.py assets/hooks/test_mailrules.py
git commit -S -m "feat: an optional allow_archive field on a tagging rule

Both readers learn it together, as the rule format requires. No version
bump: the field is optional, and each writer emits it only on a rule that
removes inbox, so a file without one saves unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 3: the hook honours `allow_archive`

**Files:**
- Modify: `assets/hooks/post-new` (`protected_removals`, the skip log line, the header comments that describe the policy)
- Test: `assets/hooks/test_post_new.py`

- [ ] **Step 1: Write the failing tests**

Add above `def setup_accounts(` in `assets/hooks/test_post_new.py`:

```python
def test_an_opted_in_rule_archives_arriving_mail():
    """Item 213: the promo rule removed inbox and was skipped on every sync,
    so arrivals were never even tagged."""
    with tempfile.TemporaryDirectory() as tmp:
        env = setup_database(tmp)
        write_rules(env, [
            {"id": "promo", "stage": 50, "add": ["promo"],
             "remove": ["inbox"], "allow_archive": True,
             "query": "from:promo@example.net"},
        ])
        result = subprocess.run([str(HOOK)], env=env, capture_output=True,
                                text=True)
        assert result.returncode == 0, result.stderr
        assert count(env, "tag:promo") == 1
        assert count(env, "tag:inbox and from:promo@example.net") == 0
        # unread is untouched by an archive
        assert count(env, "tag:unread and from:promo@example.net") == 1
        assert count(env, "tag:inbox") == 2
        assert count(env, "tag:new") == 0


def test_without_the_flag_the_rule_is_skipped_and_the_log_says_how_to_allow_it():
    with tempfile.TemporaryDirectory() as tmp:
        env = setup_database(tmp)
        write_rules(env, [
            {"id": "promo", "stage": 50, "add": ["promo"],
             "remove": ["inbox"], "query": "from:promo@example.net"},
        ])
        result = subprocess.run([str(HOOK)], env=env, capture_output=True,
                                text=True)
        assert result.returncode == 0, result.stderr
        assert count(env, "tag:promo") == 0
        assert "Archive on arrival" in result.stderr, result.stderr


def test_the_flag_never_unlocks_unread():
    with tempfile.TemporaryDirectory() as tmp:
        env = setup_database(tmp)
        write_rules(env, [
            {"id": "greedy", "stage": 50, "add": ["seen"],
             "remove": ["inbox", "unread"], "allow_archive": True,
             "query": "from:promo@example.net"},
        ])
        result = subprocess.run([str(HOOK)], env=env, capture_output=True,
                                text=True)
        assert result.returncode == 0, result.stderr
        assert count(env, "tag:seen") == 0
        assert count(env, "tag:unread") == 3
        assert count(env, "tag:inbox") == 3
```

- [ ] **Step 2: Run to verify they fail**

Run: `cd assets/hooks && ./test_post_new.py`
Expected: `AssertionError` in `test_an_opted_in_rule_archives_arriving_mail` (`tag:promo` is 0).

- [ ] **Step 3: Implement**

In `assets/hooks/post-new`, replace `protected_removals`:

```python
def protected_removals(rule):
    """The protected tags this rule would remove, if any.

    `inbox` is released by the rule's own `allow_archive` opt-in (item 213),
    which a human set in the rules dialog. `unread` never is: removing it
    rewrites Maildir filenames and reaches the server.
    """
    refused = set(PROTECTED_REMOVALS)
    if rule.allow_archive:
        refused.discard("inbox")
    return sorted(refused.intersection(rule.remove))
```

Replace the skip log call in `main()`:

```python
        if refused:
            hint = (' (set "Archive on arrival" on the rule to allow it)'
                    if refused == ["inbox"] else "")
            log(f"rule '{rule.id}' would remove {', '.join(refused)}; "
                f"skipped, this hook does not remove those unattended{hint}")
            continue
```

Update the comment block above `PROTECTED_REMOVALS`: replace the paragraph starting `DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE RELAXED.` (through `... not whether the guard is annoying.`) with:

```python
# RELAXED ONCE, FOR INBOX ONLY (item 213). A rule may opt in with
# `allow_archive`, set by a human in the rules dialog, and then this hook
# removes `inbox` for it. That checkbox is the confirmation the old comment
# here asked for before relaxing anything. `unread` has no opt-in.
```

Update the module docstring line 23-24 so it reads: `Rules may add any tag and remove most, but this hook REFUSES to remove \`unread\`, and \`inbox\` unless the rule sets allow_archive, and skips any rule that asks: see PROTECTED_REMOVALS`.

- [ ] **Step 4: Run both suites**

Run: `cd assets/hooks && ./test_post_new.py && ./test_mailrules.py`
Expected: `all passed` twice. The existing `test_a_protected_removal_is_skipped_whole_and_the_run_continues` must still pass (its rule removes `unread`).

- [ ] **Step 5: Commit**

```bash
git add assets/hooks/post-new assets/hooks/test_post_new.py
git commit -S -m "fix: let an opted-in rule archive arriving mail

The hook refused to remove inbox by skipping the whole rule, so the promo
rule (+promo -inbox) had never tagged a single arrival. A rule carrying
allow_archive now runs, and inbox is removed for it. unread is still
refused, and a rule without the flag is still skipped, with a log line
saying how to allow it.

Item 213.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 4: worker slot `applyTagsToQuery`

**Files:**
- Modify: `src/notmuchworker.h` (public slots, beside `applyTagsToThreads`)
- Modify: `src/notmuchworker.cpp` (after `applyTagsToThreads`)
- Test: `tests/test_notmuchworker.cpp`

- [ ] **Step 1: Write the failing test**

Add to `private slots:` in `tests/test_notmuchworker.cpp`:

```cpp
    void applyTagsToQueryWritesOnlyWhatWouldChange();
```

Body, beside `applyTagsToThreadsTagsEveryMessage`:

```cpp
void TestNotmuchWorker::applyTagsToQueryWritesOnlyWhatWouldChange()
{
    // Item 212. Two messages in OPPOSITE states (item 87's lesson): a1
    // already carries the tag, a2 does not. Only a2 may be reported, or the
    // undo would strip the tag from a message that had it before.
    const QString threadId = threadIdOf(QStringLiteral("Release notes"));
    QVERIFY(!threadId.isEmpty());

    NotmuchWorker setup(m_fixture.configPath());
    setup.applyTags(TagChange{ { QStringLiteral("a1@example.org") },
                               { QStringLiteral("backfill") }, {},
                               QStringLiteral("seed") });

    NotmuchWorker worker(m_fixture.configPath());
    QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
    worker.applyTagsToQuery(QStringLiteral("thread:%1").arg(threadId),
                            { QStringLiteral("backfill") }, {},
                            QStringLiteral("Apply rule"));

    QCOMPARE(applied.size(), 1);
    const TagChange emitted = applied.first().at(0).value<TagChange>();
    QCOMPARE(emitted.messageIds, QStringList{ QStringLiteral("a2@example.org") });
    QCOMPARE(emitted.description, QStringLiteral("Apply rule"));
    QVERIFY(tagsOf(QStringLiteral("a2@example.org"))
                .contains(QStringLiteral("backfill")));

    NotmuchWorker restore(m_fixture.configPath());
    restore.applyTags(TagChange{ { QStringLiteral("a1@example.org"),
                                   QStringLiteral("a2@example.org") },
                                 {}, { QStringLiteral("backfill") },
                                 QStringLiteral("restore") });
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `cmake --build build 2>&1 | grep -m3 error`
Expected: `'class NotmuchWorker' has no member named 'applyTagsToQuery'`.

- [ ] **Step 3: Implement**

`src/notmuchworker.h`, in the same public-slots block as `applyTagsToThreads`:

```cpp
    /// Applies `+add -remove` to every message the change would move within
    /// `query` (item 212, a rule applied to existing mail). The query is
    /// narrowed with SearchTerm::changes() so already-correct messages are
    /// never written, then the ids go through applyTags(), which reports the
    /// effective set for undo. Emits nothing when nothing would change.
    void applyTagsToQuery(const QString &query, const QStringList &add,
                          const QStringList &remove,
                          const QString &description);
```

`src/notmuchworker.cpp`: add `#include "searchterm.h"` with the other project includes if absent (remember gmime/notmuch headers stay before Qt headers; project headers already follow that order in this file, so put it beside the other `#include "..."` lines). Then, after `applyTagsToThreads`:

```cpp
void NotmuchWorker::applyTagsToQuery(const QString &query,
                                     const QStringList &add,
                                     const QStringList &remove,
                                     const QString &description)
{
    if (!openReadOnly())
        return;

    QStringList messageIds;
    if (!collectMessageIds(m_db, SearchTerm::changes(query, add, remove),
                           &messageIds)) {
        emit errorOccurred(QStringLiteral("Cannot resolve the rule's query"));
        return;
    }

    // Empty is not an error: the rule has nothing left to do. applyTags()
    // returns at once on an empty list, and stays silent, as it must
    // (CLAUDE.md, item 176).
    applyTags(TagChange{ messageIds, add, remove, description });
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_notmuchworker`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add src/notmuchworker.h src/notmuchworker.cpp tests/test_notmuchworker.cpp
git commit -S -m "feat: apply a tag change to every message a query would move

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 5: undo bookkeeping stays linear on a large write

**Files:**
- Modify: `src/mainwindow.h` (`TagCommand::addChangedMessageIds`, `forgetChangedMessageIds`)

No test of its own: a performance property, and Task 7's test exercises the code path.

- [ ] **Step 1: Make `addChangedMessageIds` linear**

It runs `QStringList::contains` per id, which is quadratic on a 10k-message backfill (and on item 211's 10k archive). Replace the method and the member in `class TagCommand`:

```cpp
    void addChangedMessageIds(const QStringList &ids)
    {
        // A set beside the list, so a 10k-message write is not quadratic
        // here (items 211, 212). The list keeps the order for the undo.
        for (const QString &id : ids) {
            if (!m_seen.contains(id)) {
                m_seen.insert(id);
                m_changedMessageIds.append(id);
            }
        }
    }
```

```cpp
    void forgetChangedMessageIds()
    {
        m_changedMessageIds.clear();
        m_seen.clear();
    }

private:
    QStringList m_changedMessageIds;
    QSet<QString> m_seen;
```

(`QSet` is already included by `mainwindow.h`.)

No redo change: the application has no Redo action (it is an open wish in
the user's notes), so `MessageTagCommand::redo()` after the first push is
unreachable. When Redo is built, a command constructed without ids (Task 7)
must replay `changedMessageIds()`; Task 8 records that in AGENTS.md.

- [ ] **Step 2: Build and run the main suite**

Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow 2>&1 | grep -E "^FAIL|^Totals"`
Expected: only the 4 known failures.

- [ ] **Step 3: Commit**

```bash
git add src/mainwindow.h
git commit -S -m "fix: keep undo bookkeeping linear on large writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 6: the rules dialog, Apply button, archive checkbox, skip warning

**Files:**
- Modify: `src/tagrulesdialog.h`
- Modify: `src/tagrulesdialog.cpp`
- Test: `tests/test_tagrules.cpp` (the dialog tests live here)

- [ ] **Step 1: Write the failing tests**

Add to `private slots:`:

```cpp
    void applySavesFirstAndEmitsTheSavedRule();
    void applyEmitsNothingWhenTheSaveFails();
    void theArchiveBoxMapsToAllowArchive();
    void aRuleTheHookWillSkipIsNamedInTheWarning();
```

Bodies, before `QTEST_MAIN`. Each points `XDG_CONFIG_HOME` at its own temp dir, as the existing dialog tests do:

```cpp
static QString writeDialogRules(const QTemporaryDir &home, const QByteArray &json)
{
    QDir().mkpath(home.filePath(QStringLiteral("mailrules")));
    const QString path = home.filePath(QStringLiteral("mailrules/rules.json"));
    QFile out(path);
    if (out.open(QIODevice::WriteOnly))
        out.write(json);
    return path;
}

void TestTagRules::applySavesFirstAndEmitsTheSavedRule()
{
    // Item 212, decided with the user: only what the hook would run is
    // applied, so Apply writes the file before asking for anything.
    QTemporaryDir home;
    QVERIFY(home.isValid());
    qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
    const QString path = writeDialogRules(home, R"({"version": 1, "rules": [
        {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})");

    TagRulesDialog dialog;
    QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested);
    dialog.selectRuleForTest(0);
    dialog.setTagsForTest(QStringLiteral("promo, sale"));   // unsaved edit
    dialog.applyForTest();

    QCOMPARE(spy.size(), 1);
    const TagRule emitted = spy.first().at(0).value<TagRule>();
    QCOMPARE(emitted.add, (QStringList{ QStringLiteral("promo"),
                                        QStringLiteral("sale") }));

    TagRules onDisk;
    onDisk.load(path);
    QCOMPARE(onDisk.rules().at(0).add, emitted.add);
}

void TestTagRules::applyEmitsNothingWhenTheSaveFails()
{
    QTemporaryDir home;
    QVERIFY(home.isValid());
    qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
    writeDialogRules(home, R"({"version": 1, "rules": [
        {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})");

    TagRulesDialog dialog;
    QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested);
    dialog.selectRuleForTest(0);
    dialog.setTagsForTest(QString());        // adds and removes nothing
    dialog.applyForTest();

    QVERIFY(spy.isEmpty());
    QVERIFY(!dialog.warningTextForTest().isEmpty());
}

void TestTagRules::theArchiveBoxMapsToAllowArchive()
{
    QTemporaryDir home;
    QVERIFY(home.isValid());
    qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
    const QString path = writeDialogRules(home, R"({"version": 1, "rules": [
        {"id": "promo", "add": ["promo"], "remove": ["inbox"],
         "query": "from:a@example.org"}]})");

    {
        TagRulesDialog dialog;
        dialog.selectRuleForTest(0);
        dialog.setArchiveForTest(true);
        dialog.saveForTest();
    }
    TagRules onDisk;
    onDisk.load(path);
    QVERIFY(onDisk.rules().at(0).allowArchive);
}

void TestTagRules::aRuleTheHookWillSkipIsNamedInTheWarning()
{
    // CLAUDE.md: a rule the hook will refuse must be flagged in the UI.
    QTemporaryDir home;
    QVERIFY(home.isValid());
    qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
    writeDialogRules(home, R"({"version": 1, "rules": [
        {"id": "promo", "add": ["promo"], "remove": ["inbox"],
         "query": "from:a@example.org"},
        {"id": "fine", "add": ["x"], "query": "from:b@example.org"}]})");

    TagRulesDialog dialog;
    QVERIFY2(dialog.warningTextForTest().contains(QStringLiteral("promo")),
             qPrintable(dialog.warningTextForTest()));
    QVERIFY(!dialog.warningTextForTest().contains(QStringLiteral("fine")));

    dialog.selectRuleForTest(0);
    dialog.setArchiveForTest(true);
    QVERIFY2(!dialog.warningTextForTest().contains(QStringLiteral("promo")),
             qPrintable(dialog.warningTextForTest()));
}
```

`TagRule` must be a metatype for `QSignalSpy::value<TagRule>()`: add `Q_DECLARE_METATYPE(TagRule)` at the bottom of `src/tagrules.h`, outside the class, and `#include <QMetaType>` there.

- [ ] **Step 2: Run to verify it fails**

Run: `cmake --build build 2>&1 | grep -m3 error`
Expected: `'applyRequested' is not a member of 'TagRulesDialog'`.

- [ ] **Step 3: Header changes**

In `src/tagrulesdialog.h`:

- Test seams, beside `saveForTest()`:

```cpp
    void applyForTest() { onApply(); }
    void setArchiveForTest(bool on);
```

- Signal, after `previewRequested`:

```cpp
    /// Asks the owner to apply one SAVED rule to existing mail (item 212).
    /// Emitted only after the dialog has written rules.json, so the rule
    /// applied is exactly the rule the hook will run.
    void applyRequested(const TagRule &rule);
```

- Private slots: add `void onApply();`.
- Private: add `bool saveRules();` and `QString hookSkipWarning() const;`.
- Members: `QCheckBox *m_archive = nullptr;` and `QPushButton *m_applyButton = nullptr;`.

- [ ] **Step 4: Split saving from closing**

In `src/tagrulesdialog.cpp`, replace `onSave()` with:

```cpp
bool TagRulesDialog::saveRules()
{
    applyEditsToCurrentRule();

    const QStringList problems = TagRules::validate(m_working);
    if (!problems.isEmpty()) {
        setWarning(tr("%n rule(s) cannot be saved as they are: %1", "",
                      problems.size())
                       .arg(problems.join(QStringLiteral("; "))));
        return false;
    }

    m_rules.setRules(m_working);
    if (!m_rules.save()) {
        QMessageBox::warning(this, tr("Tagging rules"),
                             tr("Could not write %1.")
                                 .arg(TagRules::defaultPath()));
        return false;
    }
    return true;
}

void TagRulesDialog::onSave()
{
    if (saveRules())
        accept();
}

void TagRulesDialog::onApply()
{
    // Saved first, decided with the user (item 212): the rule applied is the
    // rule the hook runs, and a failed save stops here exactly as OK does.
    if (!saveRules())
        return;
    const int index = currentIndex();
    if (index < 0 || index >= m_working.size())
        return;
    emit applyRequested(m_working.at(index));
}
```

Keep any comment that sat inside the old `onSave()` between `applyEditsToCurrentRule();` and `validate`: move it into `saveRules()` at the same place.

- [ ] **Step 5: The Apply button**

In the constructor, after the `m_previewButton` block (`buttons->addWidget(m_previewButton);`):

```cpp
    m_applyButton = new QPushButton(tr("&Apply to existing mail"), this);
    m_applyButton->setToolTip(
        tr("Save, then apply this rule to mail already in the index. Asks "
           "first, naming how many messages would change. Undoable from the "
           "main window."));
    buttons->addWidget(m_applyButton);
```

With the other connects:

```cpp
    connect(m_applyButton, &QPushButton::clicked, this, &TagRulesDialog::onApply);
```

Enable it only for a rule worth applying. At the end of `onSelectionChanged()` and at the end of `applyEditsToCurrentRule()`:

```cpp
    if (index >= 0 && index < m_working.size()) {
        const TagRule &current = m_working.at(index);
        m_applyButton->setEnabled(current.enabled && !current.query.isEmpty()
                                  && !(current.add.isEmpty()
                                       && current.remove.isEmpty()));
    }
```

(Both functions already compute `index` from `currentIndex()`; reuse that variable.)

- [ ] **Step 6: The archive checkbox**

In the constructor, right after `m_remove` is created and added to the form (find `form->addRow(tr("Remove tags"), m_remove);`):

```cpp
    m_archive = new QCheckBox(
        tr("Archive matching mail on arrival (removes inbox)"), this);
    m_archive->setToolTip(
        tr("Without this, the sync hook skips a rule that removes inbox, "
           "since it runs unattended."));
    form->addRow(QString(), m_archive);
    connect(m_remove, &QLineEdit::textChanged, this, [this](const QString &text) {
        m_archive->setVisible(splitTags(text).contains(QStringLiteral("inbox")));
    });
    connect(m_archive, &QCheckBox::toggled,
            this, &TagRulesDialog::applyEditsToCurrentRule);
```

In `onSelectionChanged()`, beside `m_remove->setText(...)`:

```cpp
    m_archive->setChecked(rule.allowArchive);
    m_archive->setVisible(rule.remove.contains(QStringLiteral("inbox")));
```

In `applyEditsToCurrentRule()`, after `rule.remove = splitTags(m_remove->text());` (find the line that assigns `rule.remove`):

```cpp
    // Cleared in the editor when inbox leaves the list, so a flag that does
    // nothing never reaches the file (item 213).
    rule.allowArchive = m_archive->isChecked()
                        && rule.remove.contains(QStringLiteral("inbox"));
```

Seam:

```cpp
void TagRulesDialog::setArchiveForTest(bool on)
{
    m_archive->setChecked(on);   // toggled -> applyEditsToCurrentRule
}
```

`splitTags` is the file's existing helper used by `applyEditsToCurrentRule`; if it is a member or a static in an anonymous namespace, the lambda can call it the same way.

- [ ] **Step 7: The skip warning**

Add:

```cpp
QString TagRulesDialog::hookSkipWarning() const
{
    QStringList skipped;
    for (const TagRule &rule : m_working) {
        if (rule.enabled && TagRules::hookWouldSkip(rule))
            skipped.append(rule.id);
    }
    if (skipped.isEmpty())
        return {};
    return tr("The sync hook skips %n rule(s), which remove inbox or unread "
              "on mail nobody has looked at: %1. Tick \"Archive matching "
              "mail on arrival\" to let a rule remove inbox; unread is never "
              "removed on arrival.", "", skipped.size())
        .arg(skipped.join(QStringLiteral(", ")));
}
```

Replace `showWarnings()` so both sources share the one banner:

```cpp
void TagRulesDialog::showWarnings()
{
    QStringList parts;
    const QStringList warnings = m_rules.warnings();
    if (!warnings.isEmpty()) {
        parts.append(tr("%n rule(s) in the file need attention: %1. Save to "
                        "write them back.", "", warnings.size())
                         .arg(warnings.join(QStringLiteral("; "))));
    }
    const QString skips = hookSkipWarning();
    if (!skips.isEmpty())
        parts.append(skips);
    setWarning(parts.join(QStringLiteral(" ")));
}
```

Keep the comment that sat between the early return and the old `setWarning(tr("%n rule(s) in the file need attention...` call, above the `if (!warnings.isEmpty())`.

At the END of `applyEditsToCurrentRule()`, re-evaluate only when the skip text actually changed, so a dismissed banner is not resurrected by every keystroke:

```cpp
    const QString skips = hookSkipWarning();
    if (skips != m_lastSkipWarning) {
        m_lastSkipWarning = skips;
        showWarnings();
    }
```

Add the member `QString m_lastSkipWarning;` and set `m_lastSkipWarning = hookSkipWarning();` at the end of `reloadList()` (where `showWarnings()` is first called on load).

- [ ] **Step 8: Run**

Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules 2>&1 | grep -E "^FAIL|^Totals"`
Expected: `0 failed`. If an existing warning test compares the banner text exactly and now sees a skip sentence appended, check its fixture: a rule removing `inbox` without the flag legitimately gets the new sentence. Do not weaken the new assertions.

- [ ] **Step 9: Commit**

```bash
git add src/tagrulesdialog.h src/tagrulesdialog.cpp src/tagrules.h tests/test_tagrules.cpp
git commit -S -m "feat: apply a rule from the rules dialog, and opt a rule into archiving

Apply saves first, so only what the hook would run is applied, then asks
the owner to apply the saved rule. A checkbox maps to allow_archive, and
the banner names every rule the sync hook will skip.

Items 212 and 213.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 7: `MainWindow` counts, confirms, writes, and undoes

**Files:**
- Modify: `src/mainwindow.h`
- Modify: `src/mainwindow.cpp` (`showTagRulesDialog`, `onRuleCountsReady`, `announceAction`, new `applyRuleToExistingMail`)
- Test: `tests/test_mainwindow.cpp`

**Two deviations from the spec, deliberate.** First, the rule is written one tag at a time inside one undo macro, because the worker's effective set is per message, not per tag (see `ruleOperations()` below): a single `+promo -inbox` write would make the undo strip `promo` from messages that had it before. Second, the spec said a backfill during a sync is held and replayed like a list edit. `HeldEdit` carries message or thread ids, and a backfill has neither until the worker resolves the query. Instead, Apply during a sync is refused with a status message and does nothing; the user presses it again after the sync. Record this in the spec in Task 8.

- [ ] **Step 1: Write the failing test**

Add to `private slots:` beside `aWriteSaysItIsApplyingUntilTheWorkerHasRunIt`:

```cpp
    void applyingARuleConfirmsWithTheCountAndUndoesExactly();
```

Body, beside that test:

```cpp
void TestMainWindow::applyingARuleConfirmsWithTheCountAndUndoesExactly()
{
    // Items 212 and 176. Two matching messages in OPPOSITE states: `had`
    // already carries promo, `lacked` does not. The confirmation must count
    // one, and undo must leave `had` tagged.
    WorkerBackedWindow backed;
    const QString date = QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200");
    QVERIFY(backed.fixture().addMessage(
        QStringLiteral("inbox"), QStringLiteral("had@example.org"),
        QStringLiteral("One"), QStringLiteral("deals@example.net"), date,
        QStringLiteral("Body.")));
    QVERIFY(backed.fixture().addMessage(
        QStringLiteral("inbox"), QStringLiteral("lacked@example.org"),
        QStringLiteral("Two"), QStringLiteral("deals@example.net"), date,
        QStringLiteral("Body.")));
    QVERIFY2(backed.build(), qPrintable(backed.error()));

    const auto notmuch = [&backed](const QStringList &args) {
        QProcess p;
        QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
        env.insert(QStringLiteral("NOTMUCH_CONFIG"),
                   backed.fixture().configPath());
        p.setProcessEnvironment(env);
        p.start(QStringLiteral("notmuch"), args);
        p.waitForFinished();
        return QString::fromUtf8(p.readAllStandardOutput()).trimmed();
    };
    notmuch({ QStringLiteral("tag"), QStringLiteral("+promo"), QStringLiteral("--"),
              QStringLiteral("id:had@example.org") });

    MainWindow window(backed.config());
    auto *undo = window.findChild<QAction *>(QStringLiteral("undo"));
    QVERIFY(undo);

    TagRule rule;
    rule.id = QStringLiteral("promo");
    rule.query = QStringLiteral("from:deals@example.net");
    rule.add = { QStringLiteral("promo") };
    rule.remove = { QStringLiteral("inbox") };

    // Answer the confirmation when it appears, recording what it said.
    QString asked;
    QTimer poll;
    poll.setInterval(20);
    QObject::connect(&poll, &QTimer::timeout, [&]() {
        auto *box = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
        if (!box)
            return;
        asked = box->text();
        poll.stop();
        box->button(QMessageBox::Yes)->click();
    });
    poll.start();

    window.applyRuleToExistingMail(rule);
    QTRY_VERIFY_WITH_TIMEOUT(!asked.isEmpty(), 15000);
    // The whole rule's count: both messages still carry inbox, so both
    // would move, even though `had` already has promo.
    QVERIFY2(asked.contains(QStringLiteral("2")), qPrintable(asked));

    QTRY_COMPARE_WITH_TIMEOUT(
        notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
        QStringLiteral("0"), 15000);

    undo->trigger();
    QTRY_COMPARE_WITH_TIMEOUT(
        notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
        QStringLiteral("2"), 15000);
    // `had` carried promo before the backfill, so undo must not take it off.
    QCOMPARE(notmuch({ QStringLiteral("count"),
                       QStringLiteral("tag:promo and id:had@example.org") }),
             QStringLiteral("1"));
    QCOMPARE(notmuch({ QStringLiteral("count"),
                       QStringLiteral("tag:promo and id:lacked@example.org") }),
             QStringLiteral("0"));
}
```

The undo action is named `undo` (`src/keymap.cpp`). Add `#include <QMessageBox>` and `#include <QProcess>` to the test file if absent, and `#include "tagrules.h"`.

- [ ] **Step 2: Run to verify it fails**

Run: `cmake --build build 2>&1 | grep -m3 error`
Expected: `'class MainWindow' has no member named 'applyRuleToExistingMail'`.

- [ ] **Step 3: `announceAction` learns an optional completion callback**

In `src/mainwindow.h`, change the declaration to:

```cpp
    void announceAction(const QString &text,
                        const std::function<void()> &onDone = {});
```

In `src/mainwindow.cpp`, change the definition's signature to match, and in the worker round trip capture and run it (the bare-window branch runs it at once):

```cpp
        if (!m_worker) {
            showTransientStatus(text);
            if (onDone)
                onDone();
            return;
        }
```

```cpp
        QMetaObject::invokeMethod(m_worker, [this, text, ticket, onDone]() {
            QMetaObject::invokeMethod(this, [this, text, ticket, onDone]() {
                if (onDone)
                    onDone();
                // A newer announcement, or an error, owns the label now.
                if (ticket == m_announceTicket)
                    showTransientStatus(tr("%1, done").arg(text));
            }, Qt::QueuedConnection);
        }, Qt::QueuedConnection);
```

`onDone` runs whatever the ticket says: it is the caller's follow-up (a refresh), not a label. The sync-held branch at the bottom of `announceAction` does not run it; nothing calls it with a callback while a sync holds the lock (Step 4 refuses first).

- [ ] **Step 4: `applyRuleToExistingMail` and the count reply**

`src/mainwindow.h`, public slots (it is driven by the dialog and by the test):

```cpp
    /// Item 212. Counts what `rule` would change across the whole index,
    /// confirms with that count, then writes through the worker with exact
    /// undo. The rule has already been saved by the dialog.
    void applyRuleToExistingMail(const TagRule &rule);
```

Private members, beside `m_ruleCountGeneration`:

```cpp
    /// The rule awaiting its change count, and the generation that count
    /// will carry. Shares m_ruleCountGeneration's sequence, so a pending
    /// Apply and the dialog's Count matches can never be confused.
    TagRule m_pendingApplyRule;
    quint64 m_pendingApplyGeneration = 0;
```

Add `#include "tagrules.h"` to `mainwindow.h` if absent.

`src/mainwindow.cpp`, in `showTagRulesDialog`, beside the other dialog connects:

```cpp
    connect(dialog, &TagRulesDialog::applyRequested,
            this, &MainWindow::applyRuleToExistingMail);
```

New function, after `onRuleCountsReady`:

```cpp
void MainWindow::applyRuleToExistingMail(const TagRule &rule)
{
    if (!m_worker)
        return;

    // Refused rather than held: HeldEdit replays ids, and a backfill has none
    // until the worker resolves the query. A deliberate deviation from the
    // spec, recorded there.
    if (aSyncHoldsTheWriteLock()) {
        showTransientStatus(
            tr("A sync is running; apply the rule again when it finishes."));
        return;
    }

    // One count for the whole rule (what the confirmation names), then one per
    // single-tag operation, in the order ruleOperations() lists them. The
    // per-operation counts decide which writes are sent at all; see
    // confirmRuleApply() for why the rule is split.
    QStringList queries{ SearchTerm::changes(rule.query, rule.add, rule.remove) };
    for (const auto &op : ruleOperations(rule))
        queries.append(SearchTerm::changes(rule.query, op.first, op.second));

    m_pendingApplyRule = rule;
    m_pendingApplyGeneration = ++m_ruleCountGeneration;
    QMetaObject::invokeMethod(
        m_worker, "requestMessageCounts", Qt::QueuedConnection,
        Q_ARG(QStringList, queries),
        Q_ARG(quint64, m_pendingApplyGeneration));
}
```

And a file-local helper at the top of `mainwindow.cpp`'s anonymous namespace (create one beside the other file-local helpers if needed):

```cpp
/// A rule as single-tag operations: `+a`, `+b`, `-c`, each as (add, remove).
/// Item 212: the worker's effective set is per MESSAGE, not per tag, so one
/// `+promo -inbox` write reports a message that already had promo (it lost
/// inbox) and its undo would strip promo from it. One operation per tag makes
/// each effective set, and therefore each undo, exact.
QList<QPair<QStringList, QStringList>> ruleOperations(const TagRule &rule)
{
    QList<QPair<QStringList, QStringList>> ops;
    for (const QString &tag : rule.add)
        ops.append({ { tag }, {} });
    for (const QString &tag : rule.remove)
        ops.append({ {}, { tag } });
    return ops;
}
```

Replace the body of `onRuleCountsReady` so it routes the Apply count first (keep its existing comment above the dialog branch):

```cpp
void MainWindow::onRuleCountsReady(const QVector<int> &counts,
                                   quint64 generation)
{
    if (m_pendingApplyGeneration != 0
        && generation == m_pendingApplyGeneration) {
        m_pendingApplyGeneration = 0;
        confirmRuleApply(counts);
        return;
    }

    if (generation != m_ruleCountGeneration || !m_tagRulesDialog)
        return;

    m_tagRulesDialog->setCounts(counts);
}
```

Private helper, declared in the header beside `applyRuleToExistingMail` (private section): `void confirmRuleApply(const QVector<int> &counts);`

```cpp
void MainWindow::confirmRuleApply(const QVector<int> &counts)
{
    const TagRule rule = m_pendingApplyRule;
    const int count = counts.value(0, 0);
    if (count <= 0) {
        showTransientStatus(
            tr("Rule \"%1\" has nothing to change.").arg(rule.id));
        return;
    }

    // The one confirmation for a reversible write, and why (CLAUDE.md): the
    // scope is the whole index, so the user cannot see what it will touch.
    QStringList what;
    if (!rule.add.isEmpty())
        what.append(tr("Adds: %1.").arg(rule.add.join(QStringLiteral(", "))));
    if (!rule.remove.isEmpty())
        what.append(tr("Removes: %1.").arg(rule.remove.join(QStringLiteral(", "))));

    QWidget *parent = m_tagRulesDialog ? static_cast<QWidget *>(m_tagRulesDialog)
                                       : this;
    QMessageBox box(parent);
    box.setIcon(QMessageBox::Question);
    box.setWindowTitle(tr("Apply rule"));
    box.setText(tr("Apply rule \"%1\" to %n message(s)?", "", count).arg(rule.id));
    box.setInformativeText(what.join(QStringLiteral(" "))
                           + QStringLiteral("\n")
                           + tr("You can undo this from the main window."));
    box.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel);
    box.setDefaultButton(QMessageBox::Cancel);
    if (box.exec() != QMessageBox::Yes)
        return;

    const QString description = tr("Apply rule \"%1\"").arg(rule.id);

    // One write per single-tag operation, grouped so one Ctrl+Z reverts the
    // whole rule (see ruleOperations() for why it is split). An operation
    // whose count was 0 is NOT sent: applyTags() stays silent on an empty
    // write, and an armed command that never hears back would steal the next
    // write's confirmation. Commands are built with no ids; the worker
    // resolves them and the confirmation fills each one (item 176).
    const auto ops = ruleOperations(rule);
    m_undoStack.beginMacro(description);
    bool first = true;
    for (int i = 0; i < ops.size(); ++i) {
        if (counts.value(i + 1, 0) <= 0)
            continue;
        auto *command = new MessageTagCommand(this, {}, ops.at(i).first,
                                              ops.at(i).second, description);
        awaitTagConfirmation(command, first);
        first = false;
        QMetaObject::invokeMethod(
            m_worker, "applyTagsToQuery", Qt::QueuedConnection,
            Q_ARG(QString, rule.query), Q_ARG(QStringList, ops.at(i).first),
            Q_ARG(QStringList, ops.at(i).second), Q_ARG(QString, description));
        m_undoStack.push(command);
    }
    m_undoStack.endMacro();

    // The rows a backfill touches are mostly not in the model, so the list is
    // re-queried once the write has run rather than updated optimistically.
    announceAction(tr("%1: %n message(s)", "", count).arg(description),
                   [this]() { refreshCurrentQuery(); });
}
```

Add `#include "searchterm.h"` to `mainwindow.cpp` if absent.

**Verified while planning:** `awaitTagConfirmation(command, first)` keeps a FIFO; `first = true` clears anything an earlier gesture stranded, so the loop passes `true` for the first operation sent and `false` after, which is the "both halves of one gesture" usage `tagSelected()` already has. `QUndoStack::push()` calls `redo()` on each child inside a macro too, which `m_firstRedo` turns into the no-op it expects.

**Residual gap, accepted:** an operation counted > 0 can still move nothing if another writer (a sync, the hook) changed those messages between the count and the write. Its command then never hears back and steals a later confirmation. The count is taken a moment before, so this is a race rather than a path. Do not invent new plumbing for it; Task 8 records it in AGENTS.md.

- [ ] **Step 5: Run**

Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow applyingARuleConfirmsWithTheCountAndUndoesExactly 2>&1 | grep -E "^(PASS|FAIL)|Totals"`
Expected: PASS.

Then the whole binary: `QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow 2>&1 | grep -E "^FAIL|^Totals"` shows only the 4 known failures.

- [ ] **Step 6: Mutation check**

Temporarily make `ruleOperations()` return ONE operation, `{ rule.add, rule.remove }`, so the rule is written in a single call. Rebuild and run the test: the assertion that `had` keeps `promo` after undo must FAIL. That is the defect the split exists for. Restore, rebuild, confirm PASS.

- [ ] **Step 7: Commit**

```bash
git add src/mainwindow.h src/mainwindow.cpp tests/test_mainwindow.cpp
git commit -S -m "feat: apply a saved rule to existing mail, confirmed and undoable

The dialog's Apply reaches MainWindow, which counts the messages the rule
would actually move, confirms with that count, then writes through the
worker. The undo entry is filled from the confirmation, so it reverts
exactly what moved. Refused while a sync holds the write lock.

Item 212.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 8: documents, translations, the hand round trip

**Files:**
- Modify: `AGENTS.md`
- Modify: `docs/superpowers/specs/2026-10-31-rule-apply-and-archive-design.md`
- Modify: `CHANGELOG.md`
- Modify: `translations/qtmaildir_it_IT.ts`
- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md` and `-closed.md`

- [ ] **Step 1: AGENTS.md, the confirmation rule**

In the paragraph beginning `**There is exactly ONE exception, and its shape is the rule's own logic rather than a hole in it.**`, change `exactly ONE exception` to `two exceptions` and append after the sentence ending `...unless the action is likewise irreversible.`:

```markdown
The second is **a write scoped to the whole index rather than to rows on
screen**: applying a tagging rule to existing mail (item 212). It IS undoable,
through the same exact-undo path as every tag write, and it confirms anyway,
naming the count, because the user cannot see what it will touch. The two
exceptions have different reasons and must keep them: `empty_trash` confirms
because it cannot be undone, Apply because it cannot be seen.
```

In "Changing the rule format", under the paragraph `**Two hook properties are safety-critical...`, replace `The hook refuses to remove \`unread\` or \`inbox\`` with `The hook refuses to remove \`unread\`, and \`inbox\` unless the rule sets \`allow_archive\` (item 213),`. Delete the closing paragraph `Backfill, applying a rule to existing mail, is deliberately unbuilt. See ...` and replace it with:

```markdown
Backfill exists since item 212 as the dialog's **Apply to existing mail**, and
it runs in qtmaildir, attended and confirmed, never in the hook. **The rule is
written one tag at a time, grouped in one undo macro, and that is
load-bearing**: `applyTags()` reports which MESSAGES moved, not which tags, so
one `+promo -inbox` write reports a message that already had `promo` (it lost
`inbox`), and its undo strips `promo` from it. One write per tag makes every
effective set exact. Each command is built with no ids and filled from its confirmation; the
application has no Redo yet, and when it gets one, such a command must redo
`changedMessageIds()`, not its empty constructor list. An operation counted at 0
is not sent, since a silent write would leave its armed command stealing the
next confirmation; a write racing another writer between count and send can
still do that, and is accepted rather than plumbed.
```

- [ ] **Step 2: The spec**

In the spec's Part A, item 4, replace the bullet `- the write lock: if a sync holds it, the edit is held and replayed the same way a list edit is.` with:

```markdown
  - the write lock: Apply during a sync is REFUSED with a status message.
    Changed during planning: `HeldEdit` replays message or thread ids, and a
    backfill has neither until the worker resolves its query.
```

Also in the spec's Part A, item 4, after the line `calls the existing \`applyTags()\`. Everything downstream is inherited`, add a paragraph:

```markdown
  **Written one tag at a time** (changed during planning). `applyTags()`
  reports which messages moved, not which tags, so one `+promo -inbox` write
  would list a message that already had `promo`, and undo would strip it.
  Each tag is its own write and its own undo command, grouped in one undo
  macro, and only the operations whose own count is above zero are sent.
```

- [ ] **Step 3: CHANGELOG**

Under `## [Unreleased]`, in `### Added`:

```markdown
- **Apply to existing mail** in the tagging rules dialog. It saves the
  rules, counts the messages the selected rule would actually change, asks
  with that count, and applies it. Undo reverts exactly what moved.
- **Archive matching mail on arrival**, a per-rule checkbox. A rule that
  removes `inbox` used to be skipped whole by the sync hook; with the box
  ticked it runs, and `inbox` is removed. `unread` is never removed on
  arrival. The dialog now names every rule the hook will skip.
```

And under `### Upgrading` (create it below the version summary area of `[Unreleased]`, after `### Fixed`, if absent):

```markdown
- A rule that removes `inbox` was never applied by the sync hook. Open the
  rules dialog: such rules are named in the warning. Tick "Archive matching
  mail on arrival" on each one you want to run, then use "Apply to existing
  mail" to catch up on what arrived meanwhile.
```

- [ ] **Step 4: Translations**

Run: `lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none`
Expected: zero "cannot be called without context" warnings. Fill every `type="unfinished"` entry with Italian. Suggested:

| English | Italian |
|---|---|
| `&Apply to existing mail` | `&Applica alla posta esistente` |
| `Save, then apply this rule ...` (tooltip) | `Salva, poi applica questa regola alla posta già indicizzata. Chiede prima, indicando quanti messaggi cambierebbero. Annullabile dalla finestra principale.` |
| `Archive matching mail on arrival (removes inbox)` | `Archivia all'arrivo la posta corrispondente (rimuove inbox)` |
| `Without this, the sync hook skips ...` | `Senza questa opzione, l'hook di sincronizzazione salta le regole che rimuovono inbox, perché agisce senza supervisione.` |
| `The sync hook skips %n rule(s) ...` | numerus forms: `L'hook di sincronizzazione salta %n regola, che rimuove inbox o unread su posta che nessuno ha visto: %1. Spunta "Archivia all'arrivo la posta corrispondente" per permettere a una regola di rimuovere inbox; unread non viene mai rimosso all'arrivo.` and the plural `... salta %n regole, che rimuovono ...` |
| `A sync is running; apply the rule again when it finishes.` | `Sincronizzazione in corso; applica di nuovo la regola al termine.` |
| `Rule "%1" has nothing to change.` | `La regola "%1" non ha nulla da cambiare.` |
| `Adds: %1.` / `Removes: %1.` | `Aggiunge: %1.` / `Rimuove: %1.` |
| `Apply rule` | `Applica regola` |
| `Apply rule "%1" to %n message(s)?` | `Applicare la regola "%1" a %n messaggio?` / `... a %n messaggi?` |
| `You can undo this from the main window.` | `Puoi annullare dalla finestra principale.` |
| `Apply rule "%1"` | `Applica regola "%1"` |

Then `cmake --build build 2>&1 | grep -i unfinished` must show `0 unfinished`, and `ctest --test-dir build -R translations --output-on-failure` must pass.

- [ ] **Step 5: Full suites**

Run:
```bash
ctest --test-dir build -j8 2>&1 | tail -5
cd assets/hooks && ./test_mailrules.py && ./test_post_new.py && cd ../..
```
Expected: only `mainwindow` fails, with only the 4 known failures; both Python suites `all passed`.

- [ ] **Step 6: Hand round trip (rule-format step 4)**

No automated test spans the C++ and the Python. In a scratch dir:

```bash
S=$(mktemp -d)
mkdir -p "$S/Mail/"{new,cur,tmp} "$S/config/mailrules"
printf 'From: promo@example.net\nTo: you@example.org\nSubject: sale\nMessage-ID: <s1@example.net>\nDate: Fri, 14 Aug 2026 10:00:00 +0200\n\nbody\n' > "$S/Mail/new/1"
printf '[database]\npath=%s/Mail\n[new]\ntags=new;unread;inbox\n[user]\nname=T\nprimary_email=you@example.org\n' "$S" > "$S/nm"
export NOTMUCH_CONFIG="$S/nm" XDG_CONFIG_HOME="$S/config"
notmuch new
```

Write a rule file FROM THE C++ SIDE, using a tiny throwaway test or by calling `TagRules::save` from the dialog test harness: the quickest route is to copy the JSON that `allowArchiveRoundTripsAndIsOmittedWhenFalse` saves, adding a `"note": "kept"`. Then:

```bash
./assets/hooks/post-new; echo "exit $?"
notmuch count tag:promo           # expect 1
notmuch count tag:inbox           # expect 0
python3 -c "import json,os;d=json.load(open(os.environ['XDG_CONFIG_HOME']+'/mailrules/rules.json'));r=d['rules'][0];print(r.get('allow_archive'),r.get('note'))"   # expect: True kept
```

Report the measured counts in the commit message.

- [ ] **Step 7: Backlog**

Mark items 212 and 213 `**done** 2026-10-31, unreleased` in the status table with a one-line summary each, and move both `## 212.` and `## 213.` sections from the backlog to `2026-08-03-post-0.1.0-usability-closed.md` on this commit, per `AGENTS.md`.

- [ ] **Step 8: Commit**

```bash
git add AGENTS.md CHANGELOG.md translations/qtmaildir_it_IT.ts docs/
git commit -S -m "docs: record rule apply and archive-on-arrival

The no-confirmation rule gains its second exception, by property: a write
scoped to the whole index confirms with the count, though it can be undone.
Hand round trip: <paste the measured counts>.

Items 212 and 213.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

- [ ] **Step 9: Hand over**

Do not launch the application. Tell the user what to look at: open the rules dialog, see `promo` named in the warning, tick "Archive matching mail on arrival", press "Apply to existing mail", read the count, confirm, watch "applying… / done", and Ctrl+Z.