Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Christian Brauner <brauner@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
	 "Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH RFC v2 11/25] review: test per-revision message tracking
Date: Wed, 12 Aug 2026 23:46:52 +0200	[thread overview]
Message-ID: <20260812-work-b4-multiver-rows-v2-11-305d53cd723a@kernel.org> (raw)
In-Reply-To: <20260812-work-b4-multiver-rows-v2-0-305d53cd723a@kernel.org>

Cover the v11 migration (column adds, catalog backfill from live and
archived series rows, idempotence), the catalog-owned count reads, the
per-revision poller (first fetch, a quiet poll that records the check
without moving the counts, new-mail bump, error paths, skip statuses,
tracked-row guarantee, least-recently-checked rotation and the cap,
rethreaded first fetch and per-patch incremental, thread-blob caching and
the stitched series blob a re-store must not discard), and the
revision-aware writes in refresh_message_count() and
sync_seen_from_unseen_count().

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_review_tracking.py | 2902 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 2901 insertions(+), 1 deletion(-)

diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 8611cd3d..c1f20649 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -7,7 +7,7 @@ import pathlib
 import re
 import sqlite3
 from email.message import EmailMessage
-from typing import Any, Dict
+from typing import Any, Dict, Optional
 from unittest import mock
 
 import pytest
@@ -1918,6 +1918,30 @@ class TestFollowupBlob:
         result = review_tracking.get_thread_mbox(gitdir, 'deadbeef' * 5)
         assert result is None
 
+    def test_store_revision_thread_blob_reports_a_missing_catalog_row(
+        self, gitdir: str, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A no-op UPDATE must not read as a stored blob.
+
+        The tracked revision is not guaranteed a catalog row, so a caller
+        handed a synthesized entry took the silent no-op for a cache write
+        and refetched from lore on every call.
+        """
+        conn = review_tracking.init_db('blob-no-row')
+        msgs = [_make_test_msg('only@example.com')]
+
+        assert (
+            review_tracking.store_revision_thread_blob(conn, gitdir, 'cid', 1, msgs)
+            is None
+        )
+
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@example.com')
+        sha = review_tracking.store_revision_thread_blob(conn, gitdir, 'cid', 1, msgs)
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert sha
+        assert revs[0]['thread_blob'] == sha
+
 
 class TestPatchState:
     """Tests for _get_patch_state() and _set_patch_state()."""
@@ -3357,6 +3381,66 @@ class TestAbsorbSeriesAsRevision:
         conn.close()
         assert srow[0] == 0
 
+    def test_absorbing_the_last_series_row_rehomes_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The stray's other catalogued versions move to the target.
+
+        A stray tracked online carries auto-discovered sibling versions,
+        and a rethreaded one's per-patch message-ids exist nowhere else.
+        Deleting them with the stray's last series row made [l] on a
+        multi-version stray a silent data-loss action.
+        """
+        conn = review_tracking.init_db('mrl-absorb-rehome-test')
+        review_tracking.add_revision(conn, 'series-A', 1, 'a-v1@example.com')
+        _seed_stray_series(conn, 'series-B', 2, 'fp-stray-b')
+        # The stray's catalog knows more than its series row: a
+        # rethreaded v1 colliding with the target's, and a plain v3.
+        review_tracking.add_revision(
+            conn,
+            'series-B',
+            1,
+            'b-v1@example.com',
+            source='discovered',
+            is_rethreaded=True,
+        )
+        _insert_patches(
+            conn, 'series-B', 1, ['b-v1-p1@example.com', 'b-v1-p2@example.com']
+        )
+        review_tracking.add_revision(
+            conn, 'series-B', 3, 'b-v3@example.com', source='discovered'
+        )
+
+        absorbed = review_tracking.absorb_series_as_revision(
+            conn, 'series-A', 'series-B', 2
+        )
+        assert absorbed is True
+
+        revs_a = {
+            r['revision']: r for r in review_tracking.get_revisions(conn, 'series-A')
+        }
+        assert sorted(revs_a) == [1, 2, 3]
+        # The colliding v1 keeps the target's own row, but rescues the
+        # stray's patch list the target lacked -- and the rethread flag
+        # behind which that list is read.
+        assert revs_a[1]['message_id'] == 'a-v1@example.com'
+        assert revs_a[1]['is_rethreaded']
+        patches = review_tracking.get_series_patches(conn, 'series-A', 1)
+        assert [p['message_id'] for p in patches] == [
+            'b-v1-p1@example.com',
+            'b-v1-p2@example.com',
+        ]
+        # The non-colliding v3 is re-homed whole, provenance included.
+        assert revs_a[3]['message_id'] == 'b-v3@example.com'
+        assert revs_a[3]['source'] == 'discovered'
+        # Nothing left under the stray.
+        assert review_tracking.get_revisions(conn, 'series-B') == []
+        leftovers = conn.execute(
+            "SELECT COUNT(*) FROM series_patches WHERE change_id = 'series-B'"
+        ).fetchone()
+        conn.close()
+        assert leftovers[0] == 0
+
     def test_absorb_missing_stray_is_noop(
         self, tmp_path: pytest.TempPathFactory
     ) -> None:
@@ -4382,6 +4466,36 @@ class TestKnownRevisionsCatalog:
             'p2@example.com',
         ]
 
+    def test_the_posting_date_survives_the_round_trip(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """add_revision() defaults found_at to now, which is wrong on replay.
+
+        The catalog is rebuilt from the review branch on a second machine,
+        or after the database is deleted.  Dropping found_at on the way
+        through re-dates every version to the moment of the rebuild, so the
+        version rows and 'posted <date>' all read as today and v1 sorts
+        after vN -- the case found_at was added to prevent.
+        """
+        conn = review_tracking.init_db('rt-found-at')
+        review_tracking.add_revision(
+            conn, 'cid', 1, 'v1@example.com', found_at='2025-11-02T09:15:00+00:00'
+        )
+        review_tracking.add_revision(
+            conn, 'cid', 2, 'v2@example.com', found_at='2026-01-20T18:40:00+00:00'
+        )
+        known = review_tracking.build_known_revisions(conn, 'cid')
+        conn.close()
+
+        conn2 = review_tracking.init_db('rt-found-at2')
+        review_tracking.record_known_revisions(conn2, 'cid', known)
+        revs = review_tracking.get_revisions(conn2, 'cid')
+        conn2.close()
+
+        by_rev = {r['revision']: r['found_at'] for r in revs}
+        assert by_rev[1] == '2025-11-02T09:15:00+00:00'
+        assert by_rev[2] == '2026-01-20T18:40:00+00:00'
+
     def test_record_is_sticky_and_no_downgrade(
         self, tmp_path: pytest.TempPathFactory
     ) -> None:
@@ -5163,3 +5277,2789 @@ class TestUpgradeKeepsTheRethreadFlag:
         )
         conn.close()
         assert rows == {2: 1, 3: 0}
+
+
+def _make_legacy_v10_db(identifier: str) -> str:
+    """Create a schema-v10 database (revisions without count columns)."""
+    path = review_tracking.get_db_path(identifier)
+    conn = sqlite3.connect(path)
+    conn.executescript(
+        """
+        CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
+        CREATE TABLE series (
+            track_id INTEGER PRIMARY KEY,
+            change_id TEXT NOT NULL,
+            revision INTEGER NOT NULL,
+            subject TEXT,
+            sender_name TEXT,
+            sender_email TEXT,
+            sent_at TEXT,
+            added_at TEXT,
+            message_id TEXT,
+            num_patches INTEGER,
+            pw_series_id INTEGER,
+            status TEXT DEFAULT 'new',
+            fingerprint TEXT,
+            branch_sha TEXT,
+            message_count INT,
+            seen_message_count INT,
+            last_update_check TEXT,
+            last_activity_at TEXT,
+            snoozed_until TEXT,
+            attestation TEXT DEFAULT 'pending',
+            target_branch TEXT,
+            is_rethreaded INTEGER DEFAULT 0,
+            UNIQUE (change_id, revision)
+        );
+        CREATE TABLE revisions (
+            change_id   TEXT NOT NULL,
+            revision    INTEGER NOT NULL,
+            message_id  TEXT NOT NULL,
+            subject     TEXT,
+            link        TEXT,
+            found_at    TEXT,
+            thread_blob TEXT,
+            fingerprint TEXT,
+            source      TEXT DEFAULT 'heuristic',
+            is_rethreaded INTEGER DEFAULT 0,
+            PRIMARY KEY (change_id, revision)
+        );
+        """
+    )
+    conn.execute('INSERT INTO schema_version (version) VALUES (10)')
+    # (i) live tracked series with counts and a matching catalog row,
+    # plus a catalog-only older revision.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, subject, message_id, status,'
+        ' message_count, seen_message_count, last_update_check,'
+        ' last_activity_at, added_at)'
+        " VALUES ('cid-live', 2, 'live v2', 'live-v2@x', 'reviewing',"
+        " 8, 6, '2026-07-01T00:00:00+00:00', '2026-06-30T00:00:00+00:00',"
+        " '2026-06-01T00:00:00+00:00')"
+    )
+    conn.execute(
+        'INSERT INTO revisions (change_id, revision, message_id)'
+        " VALUES ('cid-live', 2, 'live-v2@x')"
+    )
+    conn.execute(
+        'INSERT INTO revisions (change_id, revision, message_id)'
+        " VALUES ('cid-live', 1, 'live-v1@x')"
+    )
+    # (ii) archived series row (upgrade leftover) with old counts.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, message_id, status,'
+        ' message_count, seen_message_count)'
+        " VALUES ('cid-live', 1, 'live-v1@x', 'archived', 4, 1)"
+    )
+    # (iii) tracked series with no catalog row at all.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, message_id, status,'
+        ' message_count, seen_message_count)'
+        " VALUES ('cid-norow', 3, 'norow-v3@x', 'new', 5, 5)"
+    )
+    # (iv) series row without a message-id.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, message_id, status)'
+        " VALUES ('cid-nomsgid', 1, '', 'new')"
+    )
+    conn.commit()
+    conn.close()
+    return path
+
+
+class TestSchemaV11RevisionCounts:
+    """Schema v11: per-revision unread tracking lands on the catalog."""
+
+    def test_schema_version_at_least_11(self) -> None:
+        assert review_tracking.SCHEMA_VERSION >= 11
+
+    def test_new_db_has_count_columns(self, tmp_path: pytest.TempPathFactory) -> None:
+        conn = review_tracking.init_db('v11-cols')
+        cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        conn.close()
+        assert {
+            'message_count',
+            'seen_message_count',
+            'last_update_check',
+            'last_mail_at',
+        } <= cols
+
+    def test_migration_adds_columns_and_bumps_version(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('v11-migrate')
+        conn = review_tracking.get_db('v11-migrate')  # runs migration on open
+        cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        assert 'message_count' in cols
+        assert version == review_tracking.SCHEMA_VERSION
+
+    def test_migration_backfills_catalog_rows(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('v11-backfill')
+        conn = review_tracking.get_db('v11-backfill')
+        norow = review_tracking.get_revisions(conn, 'cid-norow')
+        nomsgid = review_tracking.get_revisions(conn, 'cid-nomsgid')
+        conn.close()
+        # The series row without a catalog entry gets one, carrying counts.
+        assert len(norow) == 1
+        assert norow[0]['message_id'] == 'norow-v3@x'
+        assert norow[0]['message_count'] == 5
+        assert norow[0]['seen_message_count'] == 5
+        # No catalog row is invented without a message-id.
+        assert nomsgid == []
+
+    def test_migration_seeds_existing_catalog_rows(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('v11-seed')
+        conn = review_tracking.get_db('v11-seed')
+        revs = {
+            r['revision']: r for r in review_tracking.get_revisions(conn, 'cid-live')
+        }
+        conn.close()
+        # v2 counts come from the live series row via the stitched read.
+        assert revs[2]['message_count'] == 8
+        assert revs[2]['seen_message_count'] == 6
+        # v1 counts were seeded from the archived series row (the only
+        # historical data) into the catalog columns.
+        assert revs[1]['message_count'] == 4
+        assert revs[1]['seen_message_count'] == 1
+
+    def test_migration_leaves_catalog_activity_unseeded(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """series.last_activity_at is not this version's newest mail.
+
+        It also records maintainer actions, so seeding last_mail_at from it
+        would date a version's thread from the last time someone snoozed
+        the series.  Left NULL until a poll reads a real Date: header --
+        and with one owner there is no second column to fall through to.
+        """
+        _make_legacy_v10_db('v11-activity')
+        conn = review_tracking.get_db('v11-activity')
+        raw = conn.execute(
+            'SELECT last_mail_at FROM revisions'
+            " WHERE change_id = 'cid-live' AND revision = 2"
+        ).fetchone()[0]
+        revs = {
+            r['revision']: r for r in review_tracking.get_revisions(conn, 'cid-live')
+        }
+        conn.close()
+        assert raw is None
+        # ...and the read reports exactly that, rather than borrowing the
+        # series' maintainer-action stamp.
+        assert revs[2]['last_mail_at'] is None
+
+    def test_migration_idempotent(self, tmp_path: pytest.TempPathFactory) -> None:
+        _make_legacy_v10_db('v11-idem')
+        review_tracking.get_db('v11-idem').close()
+        conn = review_tracking.get_db('v11-idem')
+        nrevs = conn.execute('SELECT COUNT(*) FROM revisions').fetchone()[0]
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        assert nrevs == 3
+        assert version == review_tracking.SCHEMA_VERSION
+
+
+class TestStitchedRevisionReads:
+    """Per-revision counts stitch series (live) over catalog columns."""
+
+    def test_counts_come_from_the_catalog_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """One owner, so there is no second copy for a reader to prefer."""
+        conn = review_tracking.init_db('stitch-live')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 7'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 10
+        assert revs[0]['seen_message_count'] == 7
+
+    def test_rethread_flag_is_stitched_too(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """It picks the retrieval path, so both rows must agree on it.
+
+        'e' on the tracked revision's own version row would otherwise
+        reassemble a different thread than 'e' on the series row directly
+        above it.
+        """
+        conn = review_tracking.init_db('stitch-rethread')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+            is_rethreaded=True,
+        )
+        # The catalog row predates the rethread being recognised.
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert bool(revs[0]['is_rethreaded'])
+
+    def test_archived_series_does_not_shadow(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stitch-arch')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=1,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v1@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 0'
+            " WHERE change_id = 'cid'"
+        )
+        conn.execute("UPDATE series SET status = 'archived' WHERE change_id = 'cid'")
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 6, seen_message_count = 6'
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 6
+        assert revs[0]['seen_message_count'] == 6
+
+    def test_null_series_counts_fall_back(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stitch-null')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 9, seen_message_count = 9'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 9
+        assert revs[0]['seen_message_count'] == 9
+
+    def test_grouped_returns_full_columns(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stitch-grouped')
+        review_tracking.add_revision(
+            conn, 'cid', 1, 'v1@x', fingerprint='fp1', is_rethreaded=True
+        )
+        grouped = review_tracking.get_all_revisions_grouped(conn)
+        conn.close()
+        entry = grouped['cid'][0]
+        assert entry['is_rethreaded']
+        assert entry['fingerprint'] == 'fp1'
+        assert entry['source'] == 'heuristic'
+        assert entry['message_count'] is None
+
+    def test_series_list_agrees_with_its_own_version_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The tracker list and the version rows read the same numbers.
+
+        An upgrade clears the series row's counts, so a raw read renders
+        the parent as '-' while the child row for that very revision,
+        sourced from the catalog, shows a count.
+        """
+        conn = review_tracking.init_db('stitch-list')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 12'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        grouped = review_tracking.get_all_revisions_grouped(conn)
+        conn.close()
+
+        series = {
+            s['change_id']: s
+            for s in review_tracking.get_all_tracked_series('stitch-list')
+        }['cid']
+        child = {r['revision']: r for r in grouped['cid']}[3]
+        assert series['revision'] == 3
+        assert series['message_count'] == child['message_count'] == 20
+        assert series['seen_message_count'] == child['seen_message_count'] == 12
+
+
+def _thread_msgs(count: int, base: str = 'm') -> list[EmailMessage]:
+    """Build a minimal thread of EmailMessage objects with Date headers."""
+    msgs = []
+    for i in range(count):
+        msg = EmailMessage()
+        msg['Subject'] = f'Re: thread {i}'
+        msg['From'] = 'Dev <dev@example.com>'
+        msg['Message-Id'] = f'<{base}-{i}@example.com>'
+        msg['Date'] = f'Thu, {i + 1:02d} Jul 2026 08:00:00 +0000'
+        msg.set_payload('body\n')
+        msgs.append(msg)
+    return msgs
+
+
+def _poller_series(
+    change_id: str, revision: int, message_id: str, status: str = 'new'
+) -> Dict[str, Any]:
+    """Series dict shaped like the TUI's loaded rows, for the poller."""
+    return {
+        'change_id': change_id,
+        'revision': revision,
+        'message_id': message_id,
+        'subject': 'test subject',
+        'status': status,
+    }
+
+
+class TestFailedPollsSpendTheBudget:
+    """The cap counts lore round-trips, and a fetch that fails made one.
+
+    Two deliberate rules combine badly otherwise: naming the revisions
+    lifts the two-in-a-row break (dead message-ids are exactly what a
+    backward search turns up), so a version whose ids all 404 walked every
+    one of them however small the cap.
+    """
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        for rev in (1, 2, 3, 4, 5):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+
+    def _run(
+        self, identifier: str, monkeypatch: pytest.MonkeyPatch, online: bool
+    ) -> list[int]:
+        self._seed(identifier)
+        tried: list[int] = []
+
+        def _fail(ident: str, conn: Any, change_id: str, rev: Dict[str, Any]) -> None:
+            tried.append(int(rev['revision']))
+            return None
+
+        monkeypatch.setattr(b4, 'can_network', online)
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fail)
+        review_tracking.update_revision_message_counts(
+            identifier,
+            [_poller_series('cid', 6, 'v6@x')],
+            only_revisions={1, 2, 3, 4, 5},
+            max_revisions_per_series=2,
+        )
+        return tried
+
+    def test_the_cap_bounds_a_run_of_failures(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        assert len(self._run('poll-budget', monkeypatch, online=True)) == 2
+
+    def test_offline_spends_nothing(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """No request was made, so there is no round-trip to charge for.
+
+        Charging them would make an offline sweep look like a series that
+        had used up its budget, and the revisions behind the cap would wait
+        a sweep for nothing.
+        """
+        assert len(self._run('poll-budget-off', monkeypatch, online=False)) == 5
+
+
+class TestUpdateRevisionMessageCounts:
+    """The per-revision poller for non-tracked versions."""
+
+    def test_first_fetch_initializes_counts(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-first')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='test subject',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        # Leave only the manual-link gap the backfill exists to close.
+        conn.execute('DELETE FROM revisions WHERE revision = 2')
+        conn.commit()
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(3),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-first', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-first')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 3
+        assert revs[1]['seen_message_count'] == 3
+        assert revs[1]['last_update_check'] is not None
+        assert revs[1]['last_mail_at'] is not None
+        # The tracked revision's row was backfilled but not polled.
+        assert revs[2]['message_count'] is None
+
+    def test_quiet_poll_leaves_the_counts_alone(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A quiet revision records the check but not a new count.
+
+        The check has to be recorded or the poll rotation never advances
+        past it (see TestPollCapFairness); what must not move is the
+        count/seen pair the unread badge is derived from.
+        """
+        conn = review_tracking.init_db('poll-quiet')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 3,'
+            " last_update_check = '2026-07-01T00:00:00+00:00',"
+            " last_mail_at = '2026-06-30T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        # The refetch finds the same 5 messages.
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(5),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-quiet', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-quiet')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # The badge is untouched: still 2 unseen, and no fresh activity.
+        assert revs[1]['message_count'] == 5
+        assert revs[1]['seen_message_count'] == 3
+        assert revs[1]['last_mail_at'] == '2026-06-30T00:00:00+00:00'
+        # But the rotation moved on.
+        assert revs[1]['last_update_check'] > '2026-07-01T00:00:00+00:00'
+
+    def test_a_shorter_thread_still_becomes_the_cached_thread(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """thread_blob is "the thread as it last looked", so the fetch wins.
+
+        Threads do shrink, and the blob is what the next sweep diffs
+        against to decide which messages are new -- keeping a fuller older
+        snapshot would re-count the difference as fresh mail for ever.  A
+        range-diff is not what this column answers: that reads
+        ``series_blob``, which :func:`set_revision_thread_blob` leaves
+        alone unless the thread itself changed.
+        """
+        conn = review_tracking.init_db('poll-blobshrink')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " thread_blob = 'cafebabe'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(1),
+        )
+        stored: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-blobshrink', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        conn = review_tracking.get_db('poll-blobshrink')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert (revs[1]['message_count'], revs[1]['seen_message_count']) == (1, 1)
+        assert stored == [1]
+
+    def test_a_changed_thread_drops_the_stitched_series_blob(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A stitch is only as good as the thread it was built from.
+
+        The patch that made a version unstitchable may be exactly what
+        just landed, so a new thread retires the series blob built from
+        the old one and the next range-diff stitches again.
+        """
+        conn = review_tracking.init_db('poll-blobstitch')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " thread_blob = 'cafebabe', series_blob = 'deadbeef'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(6),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_write_mbox_blob', lambda topdir, msgs: 'feedface'
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-blobstitch', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        conn = review_tracking.get_db('poll-blobstitch')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['thread_blob'] == 'feedface'
+        assert revs[1]['series_blob'] is None
+
+    def test_an_unchanged_thread_keeps_the_stitched_series_blob(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Re-storing the same thread must not throw the stitch away.
+
+        The blob is content-addressed, so a thread that has not moved
+        hashes to the SHA already on the row -- and a sweep that retired
+        the series blob on every such write would undo the caching it
+        exists to provide.
+        """
+        conn = review_tracking.init_db('poll-blobkeep')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " thread_blob = 'cafebabe', series_blob = 'deadbeef'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        assert (
+            review_tracking.set_revision_thread_blob(
+                review_tracking.get_db('poll-blobkeep'), 'cid', 1, 'cafebabe'
+            )
+            is True
+        )
+        conn = review_tracking.get_db('poll-blobkeep')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['series_blob'] == 'deadbeef'
+
+    def test_a_cancel_between_series_is_reported(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The outer cancel check must not present a clean, complete sweep."""
+        review_tracking.init_db('poll-cancel-outer').close()
+        result = review_tracking.update_revision_message_counts(
+            'poll-cancel-outer',
+            [_poller_series('cid', 2, 'v2@x')],
+            cancel_cb=lambda: True,
+        )
+        assert result['cancelled'] == 1
+        assert result['polled'] == 0
+
+    def test_a_missing_db_returns_the_documented_contract(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The early return carries every key the docstring promises."""
+        result = review_tracking.update_revision_message_counts(
+            'poll-no-such-db', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+
+    def test_first_fetch_is_not_reported_as_new_mail(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Two revisions never counted before, plus one that got mail.
+
+        The sweep summary quotes 'new_mail', so a catalog that has just
+        grown per-revision columns must not read as activity everywhere.
+        """
+        conn = review_tracking.init_db('poll-firstmail')
+        for rev, msgid in ((1, 'v1@x'), (2, 'v2@x'), (3, 'v3@x')):
+            review_tracking.add_revision(conn, 'cid', rev, msgid)
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_update_check = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        conn.close()
+        # v1 has never been counted; v2 was at 5 and has grown to 7.
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(
+                7 if int(rev['revision']) == 2 else 2
+            ),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-firstmail', [_poller_series('cid', 3, 'v3@x')]
+        )
+        assert result == {
+            'updated': 2,
+            'new_mail': 1,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 2,
+            'cancelled': 0,
+        }
+
+    def test_new_mail_bumps_count(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-new')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_update_check = '2026-07-01T00:00:00+00:00',"
+            " last_mail_at = '2026-06-30T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(7),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-new', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 1,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-new')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 7
+        assert revs[1]['seen_message_count'] == 5
+        assert revs[1]['last_mail_at'] > '2026-06-30T00:00:00+00:00'
+        assert revs[1]['last_update_check'] > '2026-07-01T00:00:00+00:00'
+
+    def test_fetch_error_counts_error(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-err')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_update_check = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: None,
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-err', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 1,
+            'fresh_errors': 1,
+            'polled': 0,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-err')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 5
+
+    def test_skip_statuses_not_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-skip')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        calls: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: calls.append(rev['revision']),
+        )
+        for status in ('archived', 'snoozed'):
+            result = review_tracking.update_revision_message_counts(
+                'poll-skip', [_poller_series('cid', 2, 'v2@x', status=status)]
+            )
+            assert result == {
+                'updated': 0,
+                'new_mail': 0,
+                'errors': 0,
+                'fresh_errors': 0,
+                'polled': 0,
+                'cancelled': 0,
+            }
+        assert calls == []
+
+    def test_applied_series_is_still_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A late reply to an old version still counts once a series lands.
+
+        The tracked revision is deliberately polled past 'accepted' so
+        follow-up discussion on an applied series keeps raising a badge;
+        its older versions must not be dropped at the same moment.
+        """
+        conn = review_tracking.init_db('poll-applied')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        calls: list[int] = []
+
+        def _fetch(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            calls.append(int(rev['revision']))
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fetch)
+        for status in ('accepted', 'thanked'):
+            calls.clear()
+            # The first pass stamps the check; age it back out so the
+            # second status is not skipped by the minimum-interval gate.
+            conn = review_tracking.get_db('poll-applied')
+            conn.execute('UPDATE revisions SET last_update_check = NULL')
+            conn.commit()
+            conn.close()
+            review_tracking.update_revision_message_counts(
+                'poll-applied', [_poller_series('cid', 2, 'v2@x', status=status)]
+            )
+            assert calls == [1]
+
+    def test_tracked_revision_not_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-tracked')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.close()
+        calls: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: calls.append(rev['revision']),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-tracked', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+        assert calls == []
+
+    def test_tracked_row_backfilled_when_missing(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Seeded from the series table, the authority the dict mirrors."""
+        conn = review_tracking.init_db('poll-backfill')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='test subject',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        # Simulate the manual-link gap: the series row exists, its
+        # revision's catalog row does not.
+        conn.execute('DELETE FROM revisions')
+        conn.commit()
+        conn.close()
+        review_tracking.update_revision_message_counts(
+            'poll-backfill', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('poll-backfill')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert len(revs) == 1
+        assert revs[0]['revision'] == 2
+        assert revs[0]['message_id'] == 'v2@x'
+        assert revs[0]['message_count'] is None
+
+    def test_a_recent_check_is_skipped(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Below the cap the rotation never engages; the age floor must.
+
+        With every candidate under the cap, an uncapped rotation re-fetches
+        each quiet old version's full thread on every sweep just to learn
+        it is still quiet -- thousands of lore round-trips a day on a
+        30-minute cron for a list of any size.
+        """
+        recent = datetime.datetime.now(datetime.timezone.utc).isoformat()
+        conn = review_tracking.init_db('poll-fresh')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            ' last_update_check = ?',
+            (recent,),
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: pytest.fail(
+                'a freshly checked revision must not be fetched'
+            ),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-fresh', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result['polled'] == 0
+
+    def test_named_revisions_bypass_the_age_floor(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """only_revisions means the caller is asking right now."""
+        recent = datetime.datetime.now(datetime.timezone.utc).isoformat()
+        conn = review_tracking.init_db('poll-named')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            ' last_update_check = ?',
+            (recent,),
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(5),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-named', [_poller_series('cid', 2, 'v2@x')], only_revisions={1}
+        )
+        assert result['polled'] == 1
+
+    def test_newest_first_with_cap(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-cap')
+        for rev in (1, 2, 3, 4):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+        polled: list[int] = []
+
+        def _fake_fetch(
+            identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> list[EmailMessage]:
+            polled.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+        result = review_tracking.update_revision_message_counts(
+            'poll-cap',
+            [_poller_series('cid', 4, 'v4@x')],
+            max_revisions_per_series=1,
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        assert polled == [3]
+
+    def test_rethreaded_first_fetch_reassembles(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-rt-first')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+        conn.close()
+        seen_dicts: list[Dict[str, Any]] = []
+
+        def _fake_retrieve(
+            series: Dict[str, Any], identifier: str
+        ) -> list[EmailMessage]:
+            seen_dicts.append(series)
+            return _thread_msgs(4)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _fake_retrieve)
+        result = review_tracking.update_revision_message_counts(
+            'poll-rt-first', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        assert seen_dicts and seen_dicts[0]['is_rethreaded'] is True
+        assert seen_dicts[0]['revision'] == 1
+        conn = review_tracking.get_db('poll-rt-first')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 4
+
+    def test_rethreaded_recount_reassembles_from_patches(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """An already-counted rethreaded revision recounts the same way.
+
+        Summing per-patch queries counted a reply CC'd into several patch
+        threads once per thread; reassembly dedupes it.
+        """
+        conn = review_tracking.init_db('poll-rt-incr')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+        conn.execute(
+            'UPDATE revisions SET message_count = 6, seen_message_count = 6,'
+            " last_update_check = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        seen_series: list[Dict[str, Any]] = []
+
+        def _reassemble(series: Dict[str, Any], identifier: str) -> list[EmailMessage]:
+            seen_series.append(series)
+            return _thread_msgs(8)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _reassemble)
+        result = review_tracking.update_revision_message_counts(
+            'poll-rt-incr', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 1,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        assert [s['revision'] for s in seen_series] == [1]
+        assert seen_series[0]['is_rethreaded'] is True
+        conn = review_tracking.get_db('poll-rt-incr')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 8
+        # Seen is untouched, so the two new messages raise a badge.
+        assert revs[0]['seen_message_count'] == 6
+
+    def test_first_fetch_stores_blob_with_topdir(
+        self, gitdir: str, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-blob')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-blob', [_poller_series('cid', 2, 'v2@x')], topdir=gitdir
+        )
+        conn = review_tracking.get_db('poll-blob')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        blob_sha = revs[0]['thread_blob']
+        assert blob_sha
+        mbox = review_tracking.get_thread_mbox(gitdir, blob_sha)
+        assert mbox is not None
+        assert b'm-0@example.com' in mbox
+
+
+class TestRevisionAwareSyncHelpers:
+    """refresh_message_count / sync_seen fall back to the catalog."""
+
+    def _seed(self, identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 8, seen_message_count = 8'
+            " WHERE change_id = 'cid'"
+        )
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 6, seen_message_count = 6'
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 3, seen_message_count = 3'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        conn.close()
+
+    def test_sync_seen_writes_the_named_revisions_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """No routing decision left: the revision named is the row written."""
+        self._seed('sync-live')
+        assert review_tracking.sync_seen_from_unseen_count('sync-live', 'cid', 2, 2)
+        conn = review_tracking.get_db('sync-live')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # v2 holds 3 messages, 2 of them unseen.
+        assert revs[2]['seen_message_count'] == 1
+        # ...and the version beside it is untouched.
+        assert revs[1]['seen_message_count'] == 6
+
+    def test_sync_seen_falls_back_to_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        self._seed('sync-fall')
+        assert review_tracking.sync_seen_from_unseen_count('sync-fall', 'cid', 1, 4)
+        conn = review_tracking.get_db('sync-fall')
+        rev_seen = conn.execute(
+            'SELECT seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()[0]
+        conn.close()
+        assert rev_seen == 2
+
+    def test_sync_seen_ignores_the_series_status(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """An archived series row used to shadow, then have to be excluded.
+
+        Read state never sat on it, so its status cannot affect a badge and
+        the write needs no guard against it.
+        """
+        self._seed('sync-arch')
+        conn = review_tracking.get_db('sync-arch')
+        review_tracking.update_series_status(conn, 'cid', 'archived')
+        conn.close()
+        assert review_tracking.sync_seen_from_unseen_count('sync-arch', 'cid', 2, 1)
+        conn = review_tracking.get_db('sync-arch')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['seen_message_count'] == 2
+
+    def test_sync_seen_no_rows_returns_false(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        review_tracking.init_db('sync-none').close()
+        assert not review_tracking.sync_seen_from_unseen_count('sync-none', 'cid', 9, 1)
+
+    def test_refresh_count_falls_back_to_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('refresh-fall')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        assert review_tracking.refresh_message_count('refresh-fall', 'cid', 1, 7)
+        conn = review_tracking.get_db('refresh-fall')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        # First fetch initialises both counts equally (no badge).
+        assert revs[0]['message_count'] == 7
+        assert revs[0]['seen_message_count'] == 7
+
+    def test_refresh_count_unchanged_writes_nothing(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A count that has not moved leaves the DB mtime alone."""
+        self._seed('refresh-skip')
+        assert not review_tracking.refresh_message_count('refresh-skip', 'cid', 2, 3)
+
+    def test_mark_all_messages_seen_clears_the_badge(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """One row to clear, and its own total is what the badge showed."""
+        self._seed('mark-rev')
+        conn = review_tracking.get_db('mark-rev')
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = 1'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['seen_message_count'] == revs[2]['message_count'] == 3
+        # The other version keeps whatever it had.
+        assert revs[1]['seen_message_count'] == 6
+
+    def test_marking_seen_uses_the_rows_own_total(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The count on the row is the count that was displayed.
+
+        This used to need a clamp: the badge came from a stitched read, so
+        a catalog row that ran ahead of the series row held messages the
+        list never showed.  With one copy the two cannot diverge.
+        """
+        self._seed('mark-ahead')
+        conn = review_tracking.get_db('mark-ahead')
+        conn.execute(
+            'UPDATE revisions SET message_count = 14, seen_message_count = 10'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        assert revs[2]['message_count'] == 14
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['seen_message_count'] == 14
+
+
+class TestRevisionCountsSurviveSeriesMoves:
+    """A series row only holds the tracked revision's counts.
+
+    Re-pointing or retiring one must hand that read state to the catalog,
+    which is where every non-tracked revision keeps it.
+    """
+
+    def _seed(self, identifier: str, revision: int = 2) -> sqlite3.Connection:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=revision,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id=f'v{revision}@x',
+            num_patches=3,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 12, seen_message_count = 9,'
+            " last_update_check = '2026-06-05T00:00:00+00:00',"
+            " last_mail_at = '2026-06-04T00:00:00+00:00'"
+            ' WHERE change_id = ? AND revision = ?',
+            ('cid', revision),
+        )
+        conn.commit()
+        return conn
+
+    def test_upgrade_parks_counts_in_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = self._seed('carry-upgrade')
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['message_count'] == 12
+        assert revs[2]['seen_message_count'] == 9
+        assert revs[2]['message_id'] == 'v2@x'
+        assert revs[2]['last_mail_at'] == '2026-06-04T00:00:00+00:00'
+
+    def test_upgrade_defers_to_a_newer_catalog_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = self._seed('carry-nooverwrite')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 20,'
+            " last_update_check = '2026-06-09T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['message_count'] == 20
+
+    def test_an_upgrade_leaves_the_old_revision_alone(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Nothing is copied on an upgrade, so nothing can overwrite.
+
+        The outgoing revision's counts were never on the series row: they
+        are its own, and moving off it does not touch them.
+        """
+        conn = self._seed('carry-overwrite')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 8, seen_message_count = 8,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['message_count'] == 8
+        assert revs[2]['seen_message_count'] == 8
+        assert revs[2]['last_update_check'] == '2026-06-01T00:00:00+00:00'
+
+    def test_an_upgrade_needs_no_staleness_check(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Which copy is newer was only ever a question with two copies."""
+        conn = self._seed('carry-untimed')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 20'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # No timestamp anywhere, and still unambiguous.
+        assert revs[2]['message_count'] == 20
+
+    def test_absorb_carries_stray_counts(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('carry-absorb')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=3,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=3,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-05-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=3,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 15, seen_message_count = 11'
+            " WHERE change_id = 'stray'"
+        )
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(conn, 'target', 'stray', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'target')}
+        conn.close()
+        assert revs[2]['message_count'] == 15
+        assert revs[2]['seen_message_count'] == 11
+
+    def test_absorb_carries_counts_held_only_by_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A stray's counts commonly live in its own catalog row.
+
+        The per-revision read COALESCEs over both tables, so a stray whose
+        series row never carried counts still displays them -- and absorb
+        deletes that catalog row, so reading only the series row drops the
+        state the user was looking at.
+        """
+        conn = review_tracking.init_db('carry-absorb-catalog')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=3,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=3,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-05-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=3,
+        )
+        review_tracking.add_revision(conn, 'stray', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 12, seen_message_count = 5'
+            " WHERE change_id = 'stray' AND revision = 2"
+        )
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(conn, 'target', 'stray', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'target')}
+        conn.close()
+        assert revs[2]['message_count'] == 12
+        assert revs[2]['seen_message_count'] == 5
+
+    def test_absorb_uses_the_revision_the_caller_matched(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A stray tracked across versions must not contribute the wrong one."""
+        conn = review_tracking.init_db('carry-absorb-multi')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='t5@x',
+            num_patches=3,
+        )
+        for rev, count in ((2, 4), (3, 30)):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id='stray',
+                revision=rev,
+                subject='s',
+                sender_name='n',
+                sender_email='e@x',
+                sent_at='2026-06-01T00:00:00+00:00',
+                message_id=f'stray-v{rev}@x',
+                num_patches=1,
+            )
+            conn.execute(
+                'UPDATE revisions SET message_count = ?, seen_message_count = 0'
+                " WHERE change_id = 'stray' AND revision = ?",
+                (count, rev),
+            )
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(
+            conn, 'target', 'stray', 2, stray_revision=2
+        )
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'target')}
+        conn.close()
+        # v3's message-id and counts must not arrive labelled as v2.
+        assert revs[2]['message_id'] == 'stray-v2@x'
+        assert revs[2]['message_count'] == 4
+
+    def test_absorb_refuses_a_revision_the_stray_does_not_track(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A named revision is the only candidate, never a hint.
+
+        The catalog can hold a revision the stray never had a series row
+        for, and falling back to another of its versions would file that
+        posting under the message-id the caller matched.
+        """
+        conn = review_tracking.init_db('carry-absorb-missing')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='t5@x',
+            num_patches=3,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=3,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='stray-v3@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 30, seen_message_count = 0'
+            " WHERE change_id = 'stray' AND revision = 3"
+        )
+        conn.commit()
+
+        assert (
+            review_tracking.absorb_series_as_revision(
+                conn, 'target', 'stray', 2, stray_revision=2
+            )
+            is False
+        )
+        revs = review_tracking.get_revisions(conn, 'target')
+        stray_rows = conn.execute(
+            "SELECT COUNT(*) FROM series WHERE change_id = 'stray'"
+        ).fetchone()[0]
+        conn.close()
+        # The refused absorb recorded nothing, and the stray is untouched --
+        # v3's message-id must not turn up labelled v2.  The target's own
+        # v5 row is there because tracking a series catalogues the revision
+        # it tracks, which is where that revision's read state lives.
+        assert [r['revision'] for r in revs] == [5]
+        assert stray_rows == 1
+
+    def test_archiving_parks_counts_in_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Upgrading a checked-out series archives the outgoing row.
+
+        Per-revision reads skip archived rows, so the counts have to
+        reach the catalog before the status flips.
+        """
+        conn = self._seed('carry-archive', revision=1)
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=1)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-10T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=3,
+        )
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 12
+        assert revs[1]['seen_message_count'] == 9
+
+    def test_archiving_backfills_a_missing_catalog_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A manually linked series can lack a catalog row entirely."""
+        conn = self._seed('carry-archive-nocatalog', revision=1)
+        review_tracking.update_series_status(conn, 'cid', 'archived')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_id'] == 'v1@x'
+        assert revs[1]['message_count'] == 12
+        assert revs[1]['seen_message_count'] == 9
+
+    def test_parking_dates_the_revision_it_retires(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """found_at is when the version was posted, not when it was retired."""
+        conn = self._seed('carry-founddate', revision=2)
+        conn.execute(
+            "UPDATE series SET added_at = '2026-06-02T00:00:00+00:00'"
+            " WHERE change_id = 'cid'"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # Dating it now would sort the older version after the newer one, and
+        # dating it added_at reports when tracking started -- sent_at is the
+        # Date: header the row claims to be showing.
+        assert revs[2]['found_at'] == '2026-06-01T00:00:00+00:00'
+
+    def test_archiving_keeps_the_revisions_watermark(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A NULL watermark sorts to the head of the poll rotation forever.
+
+        Archiving used to park counts, which could spread the incoming
+        revision's cleared watermark onto the one being retired.  It now
+        writes no read state at all.
+        """
+        conn = self._seed('carry-nullmark', revision=2)
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " last_update_check = '2026-06-02T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=2)
+        row = conn.execute(
+            'SELECT last_update_check FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] == '2026-06-02T00:00:00+00:00'
+
+    def test_parking_does_not_overwrite_polled_activity(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """series.last_activity_at also stamps maintainer actions.
+
+        The catalog column only ever holds a real Date: header, so a
+        snooze or a status change must not replace one.
+        """
+        conn = self._seed('carry-activity', revision=2)
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " last_mail_at = '2026-03-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        # A maintainer action bumps the series stamp to something newer.
+        conn.execute(
+            "UPDATE series SET last_activity_at = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid'"
+        )
+        conn.commit()
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=2)
+        row = conn.execute(
+            'SELECT last_mail_at FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] == '2026-03-01T00:00:00+00:00'
+
+
+class TestPollerFetchDiscipline:
+    """The poller must fetch the same way every other count writer does."""
+
+    def test_fetch_is_uncached_and_strict(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A cached mbox can predate the very messages the rt: poll found.
+
+        Counting it would leave message_count unmoved while
+        last_update_check advanced past those messages, losing them.
+        """
+        calls: list[Dict[str, Any]] = []
+
+        def _fake(msgid: str, **kw: Any) -> list[EmailMessage]:
+            calls.append({'msgid': msgid, **kw})
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4, 'get_pi_thread_by_msgid', _fake)
+        msgs = review_tracking._fetch_thread_msgs('v1@x')
+        assert msgs is not None and len(msgs) == 3
+        assert calls == [{'msgid': 'v1@x', 'nocache': True, 'quiet': True}]
+
+    def test_fetch_propagates_cancellation(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        def _cancelled(msgid: str, **kw: Any) -> list[EmailMessage]:
+            raise liblore.OperationCancelledError('Request cancelled')
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4, 'get_pi_thread_by_msgid', _cancelled)
+        with pytest.raises(liblore.OperationCancelledError):
+            review_tracking._fetch_thread_msgs('v1@x')
+
+    def test_offline_skips_rethreaded_fetch(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Offline must short-circuit before the per-patch requests."""
+        conn = review_tracking.init_db('poll-offline')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+        conn.close()
+
+        def _boom(series: Dict[str, Any], identifier: str) -> list[EmailMessage]:
+            raise AssertionError('must not fetch while offline')
+
+        monkeypatch.setattr(b4, 'can_network', False)
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _boom)
+        result = review_tracking.update_revision_message_counts(
+            'poll-offline', [_poller_series('cid', 2, 'v2@x')]
+        )
+        # Offline is not a failure: no request was issued, so nothing is
+        # reported unreachable.  Counting it would have _cron_update() mail
+        # 'Could not poll N non-tracked revision(s)' after every sweep run
+        # from a machine that happened to be off the network.
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+
+    def test_cancel_cb_stops_the_sweep(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-cancel')
+        for rev in (1, 2, 3):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+        polled: list[int] = []
+
+        def _fake_fetch(
+            identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> list[EmailMessage]:
+            polled.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+        result = review_tracking.update_revision_message_counts(
+            'poll-cancel',
+            [_poller_series('cid', 4, 'v4@x')],
+            cancel_cb=lambda: len(polled) >= 1,
+        )
+        assert polled == [3]
+        assert result['updated'] == 1
+
+    def test_connection_closed_when_a_revision_raises(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """update_all_tracking swallows and continues, so a leak compounds."""
+        conn = review_tracking.init_db('poll-leak')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        real_get_db = review_tracking.get_db
+
+        class _ProxyConn:
+            """sqlite3.Connection.close is read-only, so wrap instead."""
+
+            def __init__(self, real: sqlite3.Connection) -> None:
+                self._real = real
+                self.closed = False
+
+            def __getattr__(self, name: str) -> Any:
+                return getattr(self._real, name)
+
+            def close(self) -> None:
+                self.closed = True
+                self._real.close()
+
+        proxies: list[_ProxyConn] = []
+
+        def _tracking_get_db(identifier: str) -> Any:
+            proxy = _ProxyConn(real_get_db(identifier))
+            proxies.append(proxy)
+            return proxy
+
+        def _boom(*a: Any, **kw: Any) -> None:
+            raise sqlite3.OperationalError('database is locked')
+
+        monkeypatch.setattr(review_tracking, 'get_db', _tracking_get_db)
+        monkeypatch.setattr(review_tracking, '_update_one_revision_count', _boom)
+        with pytest.raises(sqlite3.OperationalError):
+            review_tracking.update_revision_message_counts(
+                'poll-leak', [_poller_series('cid', 2, 'v2@x')]
+            )
+        assert proxies and all(p.closed for p in proxies)
+
+    def test_incremental_stamps_per_revision(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A sweep-wide timestamp double-counts mail arriving during it."""
+        conn = review_tracking.init_db('poll-stamp')
+        for rev in (1, 2):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+        stamps: list[str] = []
+
+        def _fake_fetch(
+            identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> list[EmailMessage]:
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+        review_tracking.update_revision_message_counts(
+            'poll-stamp', [_poller_series('cid', 3, 'v3@x')]
+        )
+        conn = review_tracking.get_db('poll-stamp')
+        stamps = [
+            r['last_update_check']
+            for r in review_tracking.get_revisions(conn, 'cid')
+            # v3 is the tracked revision, so the poller skips it
+            if r['revision'] != 3
+        ]
+        conn.close()
+        assert len(stamps) == 2
+        assert all(s for s in stamps)
+        assert len(set(stamps)) == 2
+
+
+class TestRevisionSwitchClearsStaleState:
+    """update_series_revision() must not leave the old revision behind."""
+
+    def _seed(self, identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2 0/2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@e.com',
+            num_patches=2,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 10,'
+            " last_update_check = '2026-05-01T00:00:00+00:00',"
+            " last_mail_at = '2026-04-28T00:00:00+00:00'"
+            " WHERE change_id = 'cid'"
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@e.com')
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@e.com')
+        conn.execute(
+            'UPDATE revisions SET message_count = 7, seen_message_count = 7,'
+            " last_update_check = '2026-06-01T00:00:00+00:00',"
+            " last_mail_at = '2026-05-30T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        conn.close()
+
+    def test_watermark_does_not_survive_the_switch(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The incoming revision must not inherit the outgoing thread's watermark."""
+        self._seed('switch-watermark')
+        conn = review_tracking.get_db('switch-watermark')
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # v3's count comes from the catalog, so its watermark must too --
+        # not from the series row, where it still described v2.
+        assert revs[3]['message_count'] == 7
+        assert revs[3]['last_update_check'] == '2026-06-01T00:00:00+00:00'
+
+    def test_activity_is_the_threads_not_the_switch(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A version row reports thread activity, not when the upgrade happened."""
+        self._seed('switch-activity')
+        conn = review_tracking.get_db('switch-activity')
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[3]['last_mail_at'] == '2026-05-30T00:00:00+00:00'
+        assert revs[2]['last_mail_at'] == '2026-04-28T00:00:00+00:00'
+
+    def test_rethread_flag_is_repointed(self, tmp_path: pytest.TempPathFactory) -> None:
+        """The flag describes the tracked revision, so it moves with it."""
+        self._seed('switch-rethread')
+        conn = review_tracking.get_db('switch-rethread')
+        conn.execute("UPDATE series SET is_rethreaded = 1 WHERE change_id = 'cid'")
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        row = conn.execute(
+            "SELECT is_rethreaded FROM series WHERE change_id = 'cid'"
+        ).fetchone()
+        assert row[0] == 0
+        # ...and parking must not stamp the stale flag onto the catalog,
+        # where add_revision() promotes but never clears it.
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=3)
+        crow = conn.execute(
+            'SELECT is_rethreaded FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 3"
+        ).fetchone()
+        conn.close()
+        assert crow[0] == 0
+
+    def test_incoming_counts_are_simply_read(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A switch cannot mark the incoming version's unread mail read.
+
+        There is no first-sighting to mistake it for: the row the series
+        moves onto already holds whatever the poller learned about it, and
+        the next count write reads that rather than a blank series row.
+        """
+        self._seed('switch-adopt')
+        conn = review_tracking.get_db('switch-adopt')
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = 4'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        # The sweep refetches the tracked thread and finds the same 7.
+        changed = review_tracking.update_message_count_from_msgs(
+            conn, 'cid', 3, _thread_msgs(7)
+        )
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert (revs[3]['message_count'], revs[3]['seen_message_count']) == (7, 4)
+        # Nothing moved, and nothing had to be moved for it to be right.
+        assert changed is False
+
+    def test_refresh_count_reads_the_same_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Opening the thread viewer must not clear the badge either."""
+        self._seed('switch-adopt-refresh')
+        conn = review_tracking.get_db('switch-adopt-refresh')
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = 4'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        conn.close()
+        # 7 is what the row already holds, so there is nothing to write.
+        assert not review_tracking.refresh_message_count(
+            'switch-adopt-refresh', 'cid', 3, 7
+        )
+        conn = review_tracking.get_db('switch-adopt-refresh')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert (revs[3]['message_count'], revs[3]['seen_message_count']) == (7, 4)
+
+
+class TestPollerLeavesOtherLiveRowsAlone:
+    """A second live series row's revision is not the poller's to write."""
+
+    @staticmethod
+    def _two_live_rows(identifier: str) -> None:
+        """One change_id, two non-archived series rows, as rescan leaves them."""
+        conn = review_tracking.init_db(identifier)
+        for rev, msgid in ((2, 'v2@x'), (1, 'v1@x')):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id='cid',
+                revision=rev,
+                subject=f'[PATCH v{rev}] thing',
+                sender_name='S',
+                sender_email='s@e.com',
+                sent_at='2026-01-01T00:00:00+00:00',
+                message_id=msgid,
+                num_patches=1,
+            )
+            review_tracking.add_revision(conn, 'cid', rev, msgid)
+        conn.commit()
+        conn.close()
+
+    def test_another_rows_tracked_revision_is_not_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """update_all_tracking() walks the rows one at a time.
+
+        Excluding only the revision of the dict this call was handed leaves
+        the *other* row's actively tracked revision fair game -- and the
+        poller writes the catalog from a first fetch (seen = count), so that
+        row's unread delta is gone before anything can park it.
+        """
+        self._two_live_rows('poll-otherrow')
+        conn = review_tracking.get_db('poll-otherrow')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 17,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        polled: list[int] = []
+
+        def _fetch(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            polled.append(int(rev['revision']))
+            return _thread_msgs(20)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fetch)
+        review_tracking.update_revision_message_counts(
+            'poll-otherrow', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert polled == []
+
+    def test_the_other_rows_unread_survives(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The end-to-end damage this once caused: three unread marked read.
+
+        A change_id with two live series rows had one row's unread state
+        overwritten by the other.  Nothing copies read state between rows
+        any more, so the poller simply has to leave a revision another live
+        row tracks to that row.
+        """
+        self._two_live_rows('poll-otherpark')
+        conn = review_tracking.get_db('poll-otherpark')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 17,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(20),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-otherpark', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('poll-otherpark')
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=1)
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (20, 17)
+
+
+class TestPollerRoutesOnTheCatalogRow:
+    """The poller reads the row it writes, not the stitched view."""
+
+    def test_counted_but_unwatermarked_keeps_its_badge(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The v11 migration seeds counts with a NULL watermark; don't clobber."""
+        conn = review_tracking.init_db('poll-nowm')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 1,'
+            " last_mail_at = '2026-02-02T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        # No Date: headers, so the fetch cannot improve on last_activity_at.
+        undated = _thread_msgs(6)
+        for msg in undated:
+            del msg['Date']
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: undated,
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-nowm', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('poll-nowm')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count, last_mail_at'
+            " FROM revisions WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        # 3 unread before, 5 after -- not "all read".
+        assert (row['message_count'], row['seen_message_count']) == (6, 1)
+        assert row['last_mail_at'] == '2026-02-02T00:00:00+00:00'
+
+    def test_single_patch_rethread_uses_the_recorded_thread(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """One recorded patch is not enough to reassemble from."""
+        conn = review_tracking.init_db('poll-rt1')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        conn.execute(
+            'INSERT INTO series_patches (change_id, revision, position, message_id)'
+            " VALUES ('cid', 1, 1, 'p1@x')"
+        )
+        conn.commit()
+        conn.close()
+        queried: list[str] = []
+
+        def _thread(msgid: str) -> list[EmailMessage]:
+            queried.append(msgid)
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(review_tracking, '_fetch_thread_msgs', _thread)
+        monkeypatch.setattr(
+            b4.review,
+            'retrieve_series_messages',
+            lambda series, identifier: pytest.fail('must not reassemble'),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-rt1', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert queried == ['v1@x']
+
+
+class TestPollCapFairness:
+    """The cap must not permanently hide the oldest versions."""
+
+    def _seed(self, identifier: str, revs: int) -> None:
+        conn = review_tracking.init_db(identifier)
+        for rev in range(1, revs + 1):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+
+    def test_never_counted_revisions_are_polled_first(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A newest-first cap left v1/v2 of a v6 series at '-' forever."""
+        self._seed('cap-fair', 5)
+        conn = review_tracking.get_db('cap-fair')
+        # v3..v5 already counted; v1 and v2 never were.
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'"
+            ' WHERE change_id = ? AND revision >= 3',
+            ('cid',),
+        )
+        conn.commit()
+        conn.close()
+        fetched: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            fetched.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        review_tracking.update_revision_message_counts(
+            'cap-fair',
+            [_poller_series('cid', 6, 'v6@x')],
+            max_revisions_per_series=2,
+        )
+        assert sorted(fetched) == [1, 2]
+
+    def test_a_failed_fetch_does_not_spend_the_budget(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """One unreachable revision must not starve the ones behind it."""
+        self._seed('cap-fail', 3)
+        attempted: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            revision = int(rev['revision'])
+            attempted.append(revision)
+            # v3 is unreachable, the rest are fine.
+            return None if revision == 3 else _thread_msgs(2)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        result = review_tracking.update_revision_message_counts(
+            'cap-fail',
+            [_poller_series('cid', 4, 'v4@x')],
+            max_revisions_per_series=2,
+        )
+        assert result['errors'] == 1
+        assert result['updated'] == 2
+        assert attempted == [3, 2, 1]
+
+    def test_two_failures_in_a_row_stop_the_series(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Offline means every revision fails; don't try the whole catalog."""
+        self._seed('cap-offline', 6)
+        attempted: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            attempted.append(int(rev['revision']))
+            return None
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        review_tracking.update_revision_message_counts(
+            'cap-offline',
+            [_poller_series('cid', 7, 'v7@x')],
+            max_revisions_per_series=4,
+        )
+        assert len(attempted) == 2
+
+    def test_a_dead_revision_does_not_starve_the_rotation(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Two permanently unreachable revisions used to stop every sweep.
+
+        They stayed uncounted, so they sorted to the front for ever, and
+        two failures in a row abandoned the series before anything else
+        was reached -- including the recent versions late replies land on.
+        """
+        monkeypatch.setattr(b4, 'can_network', True)
+        self._seed('cap-dead', 5)
+        conn = review_tracking.get_db('cap-dead')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'"
+            ' WHERE change_id = ? AND revision >= 4',
+            ('cid',),
+        )
+        conn.commit()
+        conn.close()
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            # v1 and v2 are gone from the archive for good.
+            return None if int(rev['revision']) in (1, 2) else _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        for _ in range(3):
+            review_tracking.update_revision_message_counts(
+                'cap-dead',
+                [_poller_series('cid', 6, 'v6@x')],
+                max_revisions_per_series=2,
+            )
+        conn = review_tracking.get_db('cap-dead')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # The reachable versions were reached despite the two dead ones.
+        assert revs[4]['message_count'] == 3
+        assert revs[5]['message_count'] == 3
+
+    def test_named_revisions_are_not_abandoned_after_two_failures(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """discover_older_revisions passes only_revisions with no cap.
+
+        It does that precisely so every version it just recorded gets
+        counted; giving up after two dead message-ids -- which is exactly
+        what a backward lore search turns up -- reintroduces the starvation
+        the missing cap was avoiding.
+        """
+        self._seed('cap-named', 4)
+        fetched: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            revision = int(rev['revision'])
+            fetched.append(revision)
+            return None if revision in (3, 4) else _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        review_tracking.update_revision_message_counts(
+            'cap-named',
+            [_poller_series('cid', 5, 'v5@x')],
+            only_revisions={1, 2, 3, 4},
+        )
+        conn = review_tracking.get_db('cap-named')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert fetched == [4, 3, 2, 1]
+        assert revs[1]['message_count'] == 3
+        assert revs[2]['message_count'] == 3
+
+    def test_counted_revisions_come_back_around(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A newest-first cap watched only the newest few, for ever."""
+        self._seed('cap-rotate', 6)
+        conn = review_tracking.get_db('cap-rotate')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'",
+        )
+        conn.commit()
+        conn.close()
+        attempted: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            attempted.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        for _ in range(3):
+            review_tracking.update_revision_message_counts(
+                'cap-rotate',
+                [_poller_series('cid', 7, 'v7@x')],
+                max_revisions_per_series=2,
+            )
+        # Six sweeps' worth of budget covered all six versions, not the
+        # same two over and over.
+        assert sorted(attempted) == [1, 2, 3, 4, 5, 6]
+
+
+class TestSeenSyncFallsBackToCatalog:
+    def test_uncounted_series_row_defers_to_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A NULL series count is not "nothing to do".
+
+        The displayed badge came from the catalog, so that is where the
+        sync has to land.
+        """
+        conn = review_tracking.init_db('seen-fallback')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 8, seen_message_count = 8'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        conn.close()
+        assert review_tracking.sync_seen_from_unseen_count('seen-fallback', 'cid', 3, 3)
+        conn = review_tracking.get_db('seen-fallback')
+        row = conn.execute(
+            'SELECT seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 3"
+        ).fetchone()
+        conn.close()
+        assert row[0] == 5
+
+
+class TestRethreadFlagStitching:
+    """`series.is_rethreaded` is never NULL, so it cannot be COALESCEd over."""
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=2,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 2, ['p1@x', 'p2@x'])
+        conn.commit()
+        conn.close()
+
+    def test_catalog_rethread_survives_a_zeroed_series_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The column is INTEGER DEFAULT 0, so a COALESCE always picks it.
+
+        A series row that lost the flag (the upgrade path used to write the
+        default) would then permanently mask the catalog's 1.
+        """
+        self._seed('rt-stitch')
+        conn = review_tracking.get_db('rt-stitch')
+        row = conn.execute(
+            "SELECT is_rethreaded FROM series WHERE change_id = 'cid'"
+        ).fetchone()
+        conn.close()
+        # Precondition: the series row really does hold a non-NULL 0.
+        assert row[0] == 0
+
+        conn = review_tracking.get_db('rt-stitch')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['is_rethreaded']
+
+    def test_known_revisions_keeps_the_patch_list(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """build_known_revisions() gates the portable patch list on the flag.
+
+        update_series_tracking() rewrites `known-revisions` on every sweep,
+        so a dropped flag actively erases a rethreaded revision from the
+        branch -- and it cannot be re-derived from lore.
+        """
+        self._seed('rt-known')
+        conn = review_tracking.get_db('rt-known')
+        known = review_tracking.build_known_revisions(conn, 'cid')
+        conn.close()
+        entry = next(e for e in known if e['revision'] == 2)
+        assert entry.get('is-rethreaded') is True
+        assert [p['message-id'] for p in entry['patches']] == ['p1@x', 'p2@x']
+
+
+class TestShrinkIsRecorded:
+    def test_a_stale_high_watermark_does_not_eat_the_badge(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """10/10 stored, real thread 8 -> two replies -> the badge shows.
+
+        Threads do shrink (dedup variation, mail removed from the
+        archive), and a writer that refuses any total at or below the
+        stored one turns 10 into a watermark: the corrected 8 and the
+        subsequent genuine 10 are both refused, the badge never lights,
+        and the one repair path runs only when the maintainer opens the
+        thread the missing badge was meant to point at.
+        """
+        conn = review_tracking.init_db('shrink-badge')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 10,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        size = [8]
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(size[0]),
+        )
+        review_tracking.update_revision_message_counts(
+            'shrink-badge', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('shrink-badge')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        # The shrink is recorded and seen capped with it: nothing unread.
+        assert (row[0], row[1]) == (8, 8)
+        # Age the check stamp out of the minimum-interval gate.
+        conn.execute(
+            "UPDATE revisions SET last_update_check = '2026-01-01T00:00:00+00:00'"
+        )
+        conn.commit()
+        conn.close()
+
+        size[0] = 10
+        review_tracking.update_revision_message_counts(
+            'shrink-badge', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('shrink-badge')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        # The two genuine replies badge instead of vanishing under the
+        # old high watermark.
+        assert (row[0], row[1]) == (10, 8)
+
+    def test_a_genuine_growth_is_still_taken(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Growth stores the count and leaves seen for the badge."""
+        conn = review_tracking.init_db('short-grow')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4'
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(6),
+        )
+        review_tracking.update_revision_message_counts(
+            'short-grow', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('short-grow')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (6, 4)
+
+
+class TestTrackedRevisionActivityKeepsMoving:
+    def test_the_catalog_date_follows_the_tracked_thread(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Per-revision reads prefer the catalog, and the poller skips the
+        tracked revision -- so without a mirror a version's date freezes at
+        whatever poll it last got as an older version."""
+        conn = review_tracking.init_db('act-mirror')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_mail_at = '2026-02-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        conn.close()
+        # get_db(), not the init_db() handle: update_message_count_from_msgs
+        # indexes rows by name and only get_db() sets the Row factory.
+        conn = review_tracking.get_db('act-mirror')
+        review_tracking.update_message_count_from_msgs(conn, 'cid', 3, _thread_msgs(4))
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # _thread_msgs dates run 01..04 Jul 2026, so the newest wins.
+        assert revs[3]['last_mail_at'].startswith('2026-07-04')
+
+    def test_a_maintainer_action_does_not_reach_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Only a real Date: header moves the column."""
+        conn = review_tracking.init_db('act-nomaint')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_mail_at = '2026-02-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_status(conn, 'cid', 'waiting', revision=3)
+        row = conn.execute(
+            'SELECT last_mail_at FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 3"
+        ).fetchone()
+        conn.close()
+        assert row[0] == '2026-02-01T00:00:00+00:00'
+
+
+class TestPrunedThreadBlobIsReCached:
+    def test_a_gc_d_blob_is_replaced_on_a_quiet_poll(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Thread blobs are unreferenced objects; git gc may take one.
+
+        A settled old version's count never moves again, so the quiet path
+        is its only chance -- and it used to treat the dead SHA still in the
+        row as proof the thread was cached.
+        """
+        conn = review_tracking.init_db('blob-gc')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        stored: list[int] = []
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: False
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-gc', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        assert stored == [2]
+
+    def test_a_blob_holding_at_least_as_much_is_left_alone(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('blob-live')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        stored: list[int] = []
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: True
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-live', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        assert stored == []
+
+    def test_the_quiet_path_does_not_read_the_blob_back(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Existence, not content: the thread is the one already stored.
+
+        Every cataloged revision comes through here on every sweep, so
+        reading and re-parsing each one's whole mbox to answer "no change"
+        is a cost the rotation pays for nothing.
+        """
+        conn = review_tracking.init_db('blob-quiet-read')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: True
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'get_thread_mbox',
+            lambda topdir, sha: pytest.fail('quiet poll must not read the blob'),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-quiet-read', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+
+    def test_a_shrink_still_restores_a_pruned_blob(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The short-fetch path has the messages in hand; cache them.
+
+        A revision whose thread ends up permanently shorter than the
+        stored watermark otherwise never got its pruned blob back, and
+        every range-diff against it refetched from lore forever.
+        """
+        conn = review_tracking.init_db('blob-shrink-gc')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 10,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(8),
+        )
+        stored: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-shrink-gc', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        assert stored == [8]
+
+
+class TestSeenWritersStayConsistent:
+    @staticmethod
+    def _seed(identifier: str, series_count: Optional[int], cat_count: int) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        if series_count is not None:
+            conn.execute(
+                'UPDATE revisions SET message_count = ?, seen_message_count = ?'
+                " WHERE change_id = 'cid' AND revision = 2",
+                (series_count, series_count - 3),
+            )
+        conn.execute(
+            'UPDATE revisions SET message_count = ?, seen_message_count = ?'
+            " WHERE change_id = 'cid' AND revision = 2",
+            (cat_count, cat_count),
+        )
+        conn.commit()
+        conn.close()
+
+    def test_sync_writes_the_single_copy(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """There is no second copy to go stale behind this one."""
+        self._seed('seen-mirror', series_count=10, cat_count=10)
+        assert review_tracking.sync_seen_from_unseen_count('seen-mirror', 'cid', 2, 2)
+        conn = review_tracking.get_db('seen-mirror')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (10, 8)
+
+    def test_mark_seen_skips_a_row_with_no_count(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A seen count against a NULL total is a badge with no basis.
+
+        There is no longer a second row for it to shadow, but recording it
+        would still leave seen > total the moment a count did arrive.
+        """
+        conn = review_tracking.init_db('seen-nullcount')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.commit()
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] is None
+        assert row[1] is None
+
+
+class TestMigrationDeclinesWhatItCannotCarry:
+    """A series table too degenerate to backfill from keeps its columns.
+
+    The read-state move drops the `series` copies only inside the backfill
+    guard: dropping a copy that was never carried across would just lose
+    it.  The branch_sha move sits outside that guard because it needs
+    nothing from `series` but the two columns every version of it has had.
+    """
+
+    @staticmethod
+    def _degenerate_v1_db(identifier: str) -> None:
+        import sqlite3 as _sqlite3
+
+        raw = _sqlite3.connect(review_tracking.get_db_path(identifier))
+        raw.executescript("""
+            CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
+            CREATE TABLE series (
+                track_id INTEGER PRIMARY KEY,
+                change_id TEXT NOT NULL,
+                revision INTEGER NOT NULL,
+                status TEXT DEFAULT 'new',
+                UNIQUE (change_id, revision)
+            );
+        """)
+        raw.execute('INSERT INTO schema_version (version) VALUES (1)')
+        raw.commit()
+        raw.close()
+
+    def test_read_state_survives_a_backfill_it_cannot_run(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        self._degenerate_v1_db('mig-degenerate')
+        conn = review_tracking.get_db('mig-degenerate')
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        rev_cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        read_state = {'message_count', 'seen_message_count', 'last_update_check'}
+        # The catalog gains them either way ...
+        assert read_state <= rev_cols
+        # ... and `series` keeps its own, because there was nothing to copy:
+        # this table never had the identity columns the backfill selects.
+        assert read_state <= series_cols
+        assert version == review_tracking.SCHEMA_VERSION
+
+    def test_branch_sha_moves_even_so(self, tmp_path: pytest.TempPathFactory) -> None:
+        self._degenerate_v1_db('mig-degenerate-sha')
+        conn = review_tracking.get_db('mig-degenerate-sha')
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        chg_cols = {row[1] for row in conn.execute('PRAGMA table_info(changes)')}
+        conn.close()
+        assert 'branch_sha' not in series_cols
+        assert 'branch_sha' in chg_cols

-- 
2.53.0


  parent reply	other threads:[~2026-08-12 21:47 UTC|newest]

Thread overview: 26+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
2026-08-12 21:46 ` Christian Brauner [this message]
2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 25/25] review-tui: test per-version tracker rows Christian Brauner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260812-work-b4-multiver-rows-v2-11-305d53cd723a@kernel.org \
    --to=brauner@kernel.org \
    --cc=konstantin@linuxfoundation.org \
    --cc=tools@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox