aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-12 10:53:30 +0200
committerDanilo M. <danix@danix.xyz>2026-08-12 10:53:30 +0200
commitb55ea2694a8cbb2a1ed3ae55dc1747daad8e6e3e (patch)
tree64f6eeb27786f74e2884afe51361a70a3c724c48 /docs/superpowers/specs
parent1faf94eb35e8270a659f215d260db73bcaa3f8d7 (diff)
downloadqtmaildir-b55ea2694a8cbb2a1ed3ae55dc1747daad8e6e3e.tar.gz
qtmaildir-b55ea2694a8cbb2a1ed3ae55dc1747daad8e6e3e.zip
docs(backlog): specify item 44 as a shared tagging-rule store
Item 44 sat as "open, unspecified" because nothing in this application applies rules at sync time, and the item could not be planned until it was known whether such rules existed anywhere. They do: the notmuch post-new hook holds hand-written `notmuch tag` lines scoped to tag:new, carrying their reasoning in shell comments. The design moves them to a tool-neutral JSON store that both qtmaildir and mailctl read, with unknown fields preserved across a write by either tool so neither owns the format. A rule carries no scope, so the same rule serves the hook, a dry run and a future backfill. Also in this pass: - Item 61's cause is established, not open. It is the user's cron sync holding the mbsync lock: 0 failures in 30 runs with no lock held, 30 in 30 with one held. The fix is item 38's existing seam applied across the suite. The document still said "not established" and proposed a load hypothesis that had already failed to reproduce. - Item 74 records the first-start latency measured this session. The delay is the notmuch index paging in from disk, 5714 ms cold against 154 ms warm for the same 4444-thread query, and is not addressable here. What it did expose is a real defect: the status bar holds "Searching..." for the whole walk while rows are already arriving.
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-08-12-tagging-rules-design.md341
1 files changed, 341 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-12-tagging-rules-design.md b/docs/superpowers/specs/2026-08-12-tagging-rules-design.md
new file mode 100644
index 0000000..1d32f02
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-tagging-rules-design.md
@@ -0,0 +1,341 @@
+# Shared tagging rules: design
+
+Backlog item 44, "No way to manage the filters applied at sync time".
+
+**Status:** design approved 2026-08-12, not implemented.
+
+## The problem this solves
+
+The user asked for a way to "manage filters to be applied when syncing (view
+existing, edit, delete, create new, copy as new, dry-run)". The backlog recorded
+the item as unspecified for a week because nothing in qtmaildir applies rules at
+sync time, and the item could not be planned until it was known whether such
+rules existed anywhere.
+
+They do. They live in the notmuch `post-new` hook inside the user's Maildir, as
+a sequence of hand-written `notmuch tag` commands, each scoped to `tag:new` so
+it applies to newly indexed mail only. The hook is tag-only by design: it never
+removes `inbox` or `unread`, so nothing is archived or marked read unattended.
+
+Those rules are invisible from every tool. They are also where a substantial
+amount of hard-won reasoning lives, in comments recording which senders were
+deliberately excluded from a rule and why. That reasoning is readable only by
+opening the hook in an editor, and it is exactly the context a person needs when
+deciding whether a rule is still correct.
+
+## What is being built
+
+A rule store both tools read, in a format neither owns.
+
+```
+ ~/.config/mailrules/rules.json
+ |
+ +---------------+---------------+--------------+
+ | | | |
+ post-new qtmaildir qtmaildir mailctl rules
+ (mailctl) TagRules dialog list/show/
+ | read+write dry-run dry-run
+ | | | |
+ scope: atomic via read-only
+ tag:new QSaveFile NotmuchWorker
+ | (generation-
+ notmuch tag stamped)
+ per rule
+ |
+ -new consumer
+ (hardcoded,
+ only if all
+ rules ran)
+```
+
+The constraint that shaped this: qtmaildir is the main consumer of the mail
+system, but mailctl must keep working through it. A format with one
+implementation is not neutral, it is qtmaildir's format that mailctl is invited
+to parse. Both readers are therefore written together, and the format carries an
+explicit provision for fields one tool does not understand.
+
+## The file
+
+`$XDG_CONFIG_HOME/mailrules/rules.json`, falling back to
+`~/.config/mailrules/rules.json`. Neither tool hardcodes a home directory, and
+the location is under neither project's own config directory.
+
+```json
+{
+ "version": 1,
+ "rules": [
+ {
+ "id": "account-work",
+ "stage": 10,
+ "enabled": true,
+ "add": ["account-work"],
+ "remove": [],
+ "query": "path:\"work-account/**\"",
+ "note": "Account tags use the maildir spelling, not the account keys another tool shows. Must run before topic rules."
+ },
+ {
+ "id": "notify-forge",
+ "stage": 50,
+ "enabled": true,
+ "add": ["notify/forge"],
+ "remove": [],
+ "query": "from:notifications@example.com",
+ "note": "All repositories, not one project."
+ }
+ ]
+}
+```
+
+JSON, and not the INI the item originally named. Both tools parse JSON with no
+new dependency: `QJsonDocument` in Qt, `json` in the Python standard library,
+and mailctl is deliberately stdlib-only. INI was rejected on a concrete hazard
+rather than taste: tag names in use contain `/` (`notify/forge`,
+`mailing-list/*`, `shopping/*`), and `QSettings` treats `/` in a section name as
+a group separator. `CLAUDE.md` already records that trap costing this project
+once, over the `[general]` section.
+
+The cost of JSON is that the file cannot carry loose comments. This is
+acceptable because every comment in the current hook is attached to a specific
+rule, and `note` gives it a home that a UI can display. The reasoning stops
+being visible only to someone reading shell.
+
+### Fields
+
+| Field | Meaning |
+|---|---|
+| `version` | Format version, currently `1`. A reader finding a higher number refuses the file rather than guessing at semantics. |
+| `id` | Stable unique handle, `[a-z0-9-]`. What a UI selects and a diff tracks. |
+| `stage` | Integer, ascending. Ties broken by array order. |
+| `enabled` | A rule switched off without losing its note. |
+| `add` / `remove` | Tags, as separate arrays. |
+| `query` | notmuch query. Carries no scope and no `tag:new`. |
+| `note` | Why this rule is shaped the way it is. |
+
+**`id` is never the tag name.** Tags contain `/`, and a tag can be renamed while
+the rule stays the same rule.
+
+**`add` and `remove` are arrays, not `+tag`/`-tag` strings.** Every current rule
+only adds, but the format should not need a version bump the first time a rule
+wants a removal, and parsing sigils off strings invites the class of bug where a
+tag legitimately beginning with a sigil character is mangled.
+
+**Unknown keys are preserved across a write by either tool.** A reader stashes
+every field it does not recognise and a writer merges them back. Without this,
+one qtmaildir save silently strips whatever a newer mailctl wrote, and the file
+is neutral in name only.
+
+## Scope belongs to the runner, not the rule
+
+A rule is a `(add, remove, query, stage)` tuple that knows nothing about which
+mail it applies to. Whoever runs it supplies the scope.
+
+| Runner | Scope | Effect |
+|---|---|---|
+| `post-new` | `tag:new` | Tags newly indexed mail. |
+| Dry run | none | Counts against the whole corpus. |
+| Backfill (future) | none | Applies to the whole corpus. |
+| A future timer or hook | its own | Not designed here. |
+
+This is why stored queries omit `tag:new`. A query that carried its own scope
+would have to be stripped back down for a dry run to be useful, and a
+hand-written rule that forgot the scope would silently retag the entire archive.
+
+It also settles where the `tag:new` consumer lives. `notmuch tag -new -- tag:new`
+is not a tagging rule, it is the `post-new` runner's own cleanup, and it is
+hardcoded there rather than stored as data. It cannot be deleted, disabled or
+reordered by either tool because it is not a rule. The failure mode if it went
+missing justifies putting it out of reach: `tag:new` would accumulate, and every
+rule would begin matching the whole backlog instead of new arrivals.
+
+Rules genuinely untied to `tag:new` already exist as manual work today:
+retroactively applying a newly written rule, and re-running a corrected rule
+over history after a mistake. Both are the same rule at a different scope, which
+is what this split makes expressible.
+
+## The runner
+
+Today's hook is a sequence of hand-written `notmuch tag` lines. It becomes a
+loop over `rules.json`.
+
+**Python, not shell.** The hook must parse JSON. Shell would mean either a new
+`jq` dependency on a path that runs every ten minutes, or hand-parsing JSON in
+`sh`. Python 3 is already required by mailctl and needs no imports beyond the
+standard library here.
+
+**It lives in the mailctl repository, installed to `~/bin/`.** The hook is a
+mail-organization concern and mailctl is the mail-organization tool. Note the
+asymmetry with `mailsync.sh`, which lives in qtmaildir precisely because
+qtmaildir runs it as a subprocess and depends on its behaviour; here the
+relationship is reversed, since qtmaildir never invokes the hook and `notmuch
+new` does.
+
+```
+mailctl repo
+ mailrules.py load/save/validate/stage-order, unknown-key preservation
+ post-new reads rules.json, applies with tag:new scope, then -new
+ mailctl.py mailctl rules list|show|dry-run
+
+qtmaildir repo
+ src/tagrules.* the same format, read/write/validate in C++
+ (dialog) view, edit, create, delete, copy-as-new, dry-run
+```
+
+### Algorithm, and the properties that must survive any edit
+
+1. Load `rules.json`. **On any error, missing file, bad JSON or failed
+ validation, log to stderr and exit non-zero WITHOUT running the `-new`
+ consumer.**
+2. Sort enabled rules by `stage`, then by array position.
+3. For each rule, run
+ `notmuch tag <+add...> <-remove...> -- 'tag:new and (<query>)'`.
+4. **Only after every rule succeeded**, run `notmuch tag -new -- tag:new`.
+
+**Step 1 is the critical safety property.** If the consumer runs while the rules
+did not, `tag:new` is cleared from mail that was never tagged, and that mail can
+never be tagged by these rules again. The failure is silent, permanent, and
+invisible until someone notices a gap months later. A rules file that fails to
+load must leave `tag:new` in place so the next successful run catches up.
+
+**The query is parenthesised in step 3, and this is not cosmetic.** Several
+current rules are a disjunction of senders. Without the parentheses,
+`tag:new and a or b` binds as `(tag:new and a) or b`, and the rule matches every
+message in the corpus that satisfies `b` rather than only new arrivals.
+
+**Step 4 makes the run idempotent.** A failure partway through leaves `tag:new`
+set, so re-running is safe and completes the work. This is also what makes the
+migration verifiable: old and new hooks can be run against the same `tag:new`
+set and compared.
+
+## qtmaildir
+
+**`TagRules` (`src/tagrules.h`, `src/tagrules.cpp`)**, a plain value type with
+no widget dependency, following `Config` and `KeyMap`: it parses, validates,
+collects warnings rather than throwing, and is unit-testable without a UI.
+`QJsonDocument` to parse and serialize, `QSaveFile` to write.
+
+```cpp
+struct TagRule {
+ QString id, query, note;
+ QStringList add, remove;
+ int stage = 50;
+ bool enabled = true;
+ QJsonObject unknown; // fields this version does not know, preserved on save
+};
+```
+
+The `unknown` member is the neutrality guarantee made concrete.
+
+**Validation degrades, it does not refuse.** Following `Config`'s existing
+pattern: a duplicate id, an empty query, or a rule with neither `add` nor
+`remove` drops that rule and records a warning the dialog shows. A file that
+will not parse leaves the list empty with one warning. qtmaildir must never fail
+to open because of this file.
+
+**Dry-run runs on the worker.** `NotmuchWorker` owns the only
+`notmuch_database_t*` and notmuch permits one open handle per process, so
+counting cannot happen on the UI thread even if it were fast. It is not fast:
+item 74 measured a 4444-thread query at 5.7 seconds against a cold page cache,
+and a dry run counts every rule. The counting slot is generation-stamped like
+every other query so a superseded result is discarded.
+
+Each rule shows two numbers:
+
+- **Corpus count**, what the rule matches across all mail. This is the number
+ that answers "is this rule still doing anything" and "would this rule bury
+ real correspondence", which is what the comments in the current hook spend
+ most of their words reasoning about.
+- **Pending count**, what it would tag on the next sync. Usually zero between
+ syncs.
+
+**The dialog** lists rules in stage order, each showing its tags, query, note,
+enabled state and counts. Edit, create, delete and copy-as-new operate on the
+list; Save writes atomically. Rules are a property of the mail system rather
+than of an account, so the account dropdown does not scope them.
+
+## mailctl
+
+Read-only in this version:
+
+```
+mailctl rules list # id, stage, tags, enabled, note
+mailctl rules show <id> # one rule, full query and note
+mailctl rules dry-run [<id>] # corpus counts, all rules or one
+```
+
+**No `rules edit`, `add` or `delete`.** mailctl's safety model is that reads are
+free and mutations are gated, and a rule edit is a mutation whose blast radius is
+every future sync. What that gate should be is a real design question and it is
+not needed yet: qtmaildir has the editor, and the file has a text editor. Writing
+is fully designed and implemented in `mailrules.py`, because the hook's library
+and qtmaildir must agree on atomic-write and unknown-key semantics, but no CLI
+surface exposes it.
+
+`dry-run` reuses the existing `run_notmuch` and counting paths. **Its numbers
+must equal qtmaildir's for the same rule**, and checking that once by hand after
+migration is the cheap cross-check that the two parsers agree.
+
+## Concurrency
+
+Atomic writes, no locking. Every writer does write-to-temp then `rename()`,
+which is atomic within a filesystem, so a reader always sees a complete file,
+old or new. A truncated read by the hook is therefore impossible, which is the
+failure that would actually hurt.
+
+Last writer wins on a genuine collision, silently. Accepted: this is a
+single-user setup, agents run only when asked, and simultaneous edits from two
+tools are rare enough to be corrected by eye. Locking was rejected because a
+stale lock would block the hook, which is the one thing that must not be
+blocked.
+
+## Migration
+
+The existing rules are converted once, preserving each comment as the rule's
+`note`.
+
+**The live hook is not touched until the user has reviewed the conversion.** The
+new hook is staged alongside as `post-new.new` and swapped by the user.
+
+**Equivalence is proved, not assumed.** For each rule, `notmuch count` the old
+query and the new `tag:new and (query)` construction against the full corpus,
+unscoped, and compare rule by rule. Identical counts are the evidence that the
+conversion changed nothing.
+
+The conversion table names real senders and is therefore built and kept locally,
+outside version control, per the project rule against personal details in
+committed documentation. Every example in this document uses placeholders.
+
+## Testing
+
+**qtmaildir, `tests/test_tagrules.cpp`:** parse; round-trip preserving unknown
+fields; each validation warning; stage ordering including ties resolved by array
+position; and JSON escaping of queries containing quotes, which is not
+hypothetical since path-scoped queries are written `path:"account/**"`.
+
+**mailctl:** plain-assert tests for `mailrules.py` matching the existing
+`test_mailctl.py` style, covering the same load, save and ordering semantics.
+
+**Cross-tool:** the dry-run counts from both tools compared once by hand after
+migration.
+
+The risk here is in the file format and the runner's failure modes, not in
+painting. `CLAUDE.md`'s warnings about rendering probes apply to the dialog if
+it grows custom painting, which this design does not call for.
+
+## Out of scope for v1
+
+**Backfill: applying a rule to existing mail.** The counts are shown; the button
+is not built. This is wanted and is the natural next step, since "apply this new
+rule to the backlog" is currently a hand-run `notmuch tag`.
+
+Two things must be settled before it lands. A rule that is safe against arrivals
+is not automatically safe against the whole corpus: a rule removing `inbox`, run
+unscoped, would archive years of mail in one action. And `CLAUDE.md` currently
+states that this project has no destructive-action confirmation and uses undo
+instead, which does not cover a bulk tag write over thousands of messages. **The
+user has stated that rule is due for revision**; backfill is the change that
+forces it.
+
+**Rule edits from mailctl**, pending the gating decision above.
+
+**`post-insert` and timer-driven rules.** The scope split makes them
+expressible, and nothing is designed for them here.