Files
stack/docs/superpowers/plans/2026-09-08-comment-pipeline-seal-and-skip.md

137 KiB
Raw Permalink Blame History

Comment Pipeline Seal-and-Skip Implementation Plan

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

Goal: A default run of fetch, backfill, extract, or index does zero work for sealed dockets and decides per item whether there is work before touching the network, the filesystem, or a PDF.

Architecture: A dockets table in bib.sqlite carries each reg.gov docket's close date, pull watermark, and seal. Every stage consults it first. Per item, a cheap fingerprint (file stats + updated_at) is compared before any load; the sha256 content hash stays as the second line. --force bypasses everything for one run.

Tech Stack: Python 3.13, uv run, pytest (uv run --no-sync pytest), SQLite via bib.store.Store, Postgres/pgvector via SQLAlchemy in llm/, typer CLIs under src/cli/, PyMuPDF (fitz) in tests that need PDFs.

Spec: docs/superpowers/specs/2026-09-08-comment-pipeline-seal-and-skip-design.md

Global Constraints

  • Run tests with uv run --no-sync pytest <path> -q -p no:cacheprovider. The pre-commit hook runs ruff + the cli test suite; keep it green.
  • Commit messages: conventional prefix (feat(bib):, fix(llm):, chore(comments):), (refs #615) where relevant. No Co-Authored-By trailer (house rule).
  • Before every commit run git status --short and stage only your own files (other sessions share this worktree).
  • --force semantics are uniform: bypass seal, fingerprint, hash, and docket-completion for that run; never unseal.
  • Nothing under .state/ is a source of truth. Docket state lives in bib.sqlite; index state lives in the llm Postgres DB.
  • Config knob: [comments] seal_quiet_days = 30 in stack.toml; read via conf.cfg with a default when the section is absent ("comments" in cfg).
  • One deviation from the spec, decided here: extract staleness uses mtime comparison (combined.md mtime vs newest source file mtime), not a sources fingerprint in frontmatter. A frontmatter check requires reading the file, which violates the "zero combined.md reads" criterion. Spec rollout step 6 (frontmatter stamping) is therefore dropped.

File Structure

File Responsibility
src/bib/dockets.py (new) Docket dataclass, should_seal, fingerprint_files, quiet_days() — pure, no I/O except stat
src/bib/schema.sql dockets table, idx_items_url, attachments unique index (guarded)
src/bib/store.py docket CRUD, upsert_status (no-op upsert), idempotent attach_file
src/bib/regulations_gov.py iter_comments(since=, on_error=), walk_docket, sealed-skip in both backfills, discover_docket
src/cli/bib.py fetch commands rewired to walk_docket; sealed skip before any API call
src/cli/comments.py dockets, seal, unseal commands; extract with sealed skip, --reattach, lazy body lookup
src/rex/comments/walker.py mtime-based currency, no reads for skipped dirs, skip_dockets, reattach
src/rex/comments/combine.py source_paths, is_current
src/llm/migrate.py fingerprint column, index_docket_state table
src/llm/source.py DocRef, iter_comment_refs, iter_rule_refs, iter_corpus_refs, lazy ZoteroPdfIndex
src/llm/index.py index_refs fingerprint-first loop; index_docs kept as a thin wrapper
src/cli/llm.py wires refs + sealed/complete sets
dev/scripts/dedupe_attachments.py (new) one-time duplicate cleanup
dev/scripts/refarm_cms_2026_2377.sh plain chain

Task 1: bib/dockets.py — Docket, should_seal, fingerprint_files, quiet_days

Files:

  • Create: src/bib/dockets.py
  • Test: tests/bib/test_dockets.py

Interfaces:

  • Produces: Docket (frozen dataclass), should_seal(docket, today, quiet_days) -> bool, fingerprint_files(paths) -> str, quiet_days() -> int.

  • Step 1: Write the failing tests

# tests/bib/test_dockets.py
"""bib.dockets — pure docket state helpers."""

from __future__ import annotations

import os
from datetime import date
from pathlib import Path

from bib.dockets import Docket, fingerprint_files, quiet_days, should_seal


def _d(**kw) -> Docket:
    base = dict(
        id="CMS-2026-2377",
        rule_cms_id="CMS-1848-P",
        fr_document_id="CMS-2026-2377-0001",
        fr_object_id="0900006482921ba1",
        comment_end_date="2026-09-14",
        pull_watermark="",
        last_pull_at="",
        last_pull_new=None,
        sealed_at="",
        seal_reason="",
        counts_json="{}",
    )
    base.update(kw)
    return Docket(**base)


class TestShouldSeal:
    def test_seals_after_quiet_period_with_no_new(self):
        d = _d(last_pull_at="2026-10-15T03:00:00Z", last_pull_new=0)
        assert should_seal(d, date(2026, 10, 15), 30) is True

    def test_not_before_quiet_period(self):
        d = _d(last_pull_at="2026-10-13T03:00:00Z", last_pull_new=0)
        assert should_seal(d, date(2026, 10, 13), 30) is False

    def test_not_when_last_pull_found_new(self):
        d = _d(last_pull_at="2026-10-20T03:00:00Z", last_pull_new=3)
        assert should_seal(d, date(2026, 10, 20), 30) is False

    def test_not_when_last_pull_predates_quiet_period(self):
        # Pull happened before end+quiet even though today is well past it.
        d = _d(last_pull_at="2026-09-20T03:00:00Z", last_pull_new=0)
        assert should_seal(d, date(2026, 12, 1), 30) is False

    def test_not_without_close_date(self):
        d = _d(comment_end_date="", last_pull_at="2027-01-01T00:00:00Z", last_pull_new=0)
        assert should_seal(d, date(2027, 1, 1), 30) is False

    def test_not_when_already_sealed(self):
        d = _d(sealed_at="2026-10-15T00:00:00Z", last_pull_at="2026-10-15T03:00:00Z", last_pull_new=0)
        assert should_seal(d, date(2026, 10, 15), 30) is False

    def test_not_when_never_pulled(self):
        d = _d(last_pull_at="", last_pull_new=None)
        assert should_seal(d, date(2027, 1, 1), 30) is False


class TestFingerprintFiles:
    def test_order_independent(self, tmp_path: Path):
        a = tmp_path / "a.pdf"
        b = tmp_path / "b.pdf"
        a.write_bytes(b"aa")
        b.write_bytes(b"bbb")
        assert fingerprint_files([a, b]) == fingerprint_files([b, a])

    def test_changes_when_size_changes(self, tmp_path: Path):
        a = tmp_path / "a.pdf"
        a.write_bytes(b"aa")
        f1 = fingerprint_files([a])
        a.write_bytes(b"aaaa")
        assert fingerprint_files([a]) != f1

    def test_changes_when_mtime_changes(self, tmp_path: Path):
        a = tmp_path / "a.pdf"
        a.write_bytes(b"aa")
        f1 = fingerprint_files([a])
        os.utime(a, ns=(1_000_000_000_000_000_000, 1_000_000_000_000_000_000))
        assert fingerprint_files([a]) != f1

    def test_missing_file_is_recorded_not_fatal(self, tmp_path: Path):
        assert fingerprint_files([tmp_path / "nope.pdf"]) == fingerprint_files([tmp_path / "nope.pdf"])
        assert fingerprint_files([tmp_path / "nope.pdf"]) != fingerprint_files([])

    def test_empty_is_stable(self):
        assert fingerprint_files([]) == fingerprint_files([])
        assert len(fingerprint_files([])) == 64


class TestQuietDays:
    def test_default_when_section_missing(self, monkeypatch):
        from conf import _Cfg
        import bib.dockets as mod

        monkeypatch.setattr(mod, "_cfg", lambda: _Cfg({}))
        assert quiet_days() == 30

    def test_reads_section(self, monkeypatch):
        from conf import _Cfg
        import bib.dockets as mod

        monkeypatch.setattr(mod, "_cfg", lambda: _Cfg({"comments": {"seal_quiet_days": 7}}))
        assert quiet_days() == 7
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_dockets.py -q -p no:cacheprovider Expected: FAIL with ModuleNotFoundError: No module named 'bib.dockets'

  • Step 3: Implement
# src/bib/dockets.py
"""Docket-level state for the regulations.gov comment pipeline.

A *docket* row (``dockets`` table in bib.sqlite) remembers what every
stage would otherwise re-derive from the network: the reg.gov document
that carries the comments, the comment close date, the pull watermark,
and — once the docket is known complete — a *seal*. Sealed dockets are
skipped by fetch, backfill, extract and index unless ``--force``.

Pure helpers only; the store owns persistence (``Store.docket_*``).
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Iterable

DEFAULT_QUIET_DAYS = 30


@dataclass(frozen=True)
class Docket:
    id: str
    rule_cms_id: str = ""
    fr_document_id: str = ""
    fr_object_id: str = ""
    comment_end_date: str = ""  # YYYY-MM-DD
    pull_watermark: str = ""  # max lastModifiedDate seen on a clean walk
    last_pull_at: str = ""  # ISO-8601 UTC
    last_pull_new: int | None = None
    sealed_at: str = ""
    seal_reason: str = ""
    counts_json: str = "{}"

    @property
    def sealed(self) -> bool:
        return bool(self.sealed_at)


def should_seal(docket: Docket, today: date, quiet_days: int) -> bool:
    """True when the comment period closed ≥ *quiet_days* ago and the
    most recent completed pull, itself after that quiet boundary, found
    nothing new."""
    if docket.sealed or not docket.comment_end_date or not docket.last_pull_at:
        return False
    if docket.last_pull_new is None or docket.last_pull_new > 0:
        return False
    try:
        end = date.fromisoformat(docket.comment_end_date[:10])
        pulled = date.fromisoformat(docket.last_pull_at[:10])
    except ValueError:
        return False
    boundary = end + timedelta(days=quiet_days)
    return today >= boundary and pulled >= boundary


def fingerprint_files(paths: Iterable[Path]) -> str:
    """sha256 over sorted ``(name, size, mtime_ns)`` — cheap change
    detection without reading contents. Missing files contribute their
    name with size -1 so a deletion changes the fingerprint too."""
    rows: list[str] = []
    for p in paths:
        p = Path(p)
        try:
            st = p.stat()
            rows.append(f"{p.name}\x00{st.st_size}\x00{st.st_mtime_ns}")
        except FileNotFoundError:
            rows.append(f"{p.name}\x00-1\x000")
    rows.sort()
    return hashlib.sha256("\n".join(rows).encode()).hexdigest()


def _cfg():
    from conf import cfg

    return cfg


def quiet_days() -> int:
    """``[comments] seal_quiet_days`` from stack.toml, default 30."""
    c = _cfg()
    if "comments" in c and "seal_quiet_days" in c.comments:
        return int(c.comments.seal_quiet_days)
    return DEFAULT_QUIET_DAYS

Then add to stack.toml after the [db] block:

[comments]
seal_quiet_days = 30   # days after a docket's comment close date before an empty pull seals it
  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_dockets.py -q -p no:cacheprovider Expected: 14 passed

  • Step 5: Commit
git add src/bib/dockets.py tests/bib/test_dockets.py stack.toml
git commit -m "feat(bib): Docket model, should_seal, fingerprint_files, seal_quiet_days knob (refs #615)"

Task 2: dockets table + Store docket CRUD + url index

Files:

  • Modify: src/bib/schema.sql (append after fr_links)
  • Modify: src/bib/store.py (new section after Attachments & Notes)
  • Test: tests/bib/test_store_dockets.py

Interfaces:

  • Consumes: bib.dockets.Docket.

  • Produces: Store.docket_get(id) -> Docket | None, Store.docket_for_rule(cms_id) -> Docket | None, Store.docket_upsert(d: Docket) -> None, Store.dockets() -> list[Docket], Store.sealed_dockets() -> dict[str, str] (id → sealed_at), Store.docket_seal(id, *, reason, counts: dict) -> None, Store.docket_unseal(id) -> None.

  • Step 1: Write the failing tests

# tests/bib/test_store_dockets.py
"""Store.docket_* — persistence for bib.dockets.Docket."""

from __future__ import annotations

from bib.dockets import Docket
from bib.store import Store


def _store() -> Store:
    return Store(":memory:", storage_dir="/tmp/nope")


def test_table_exists_after_init():
    s = _store()
    names = {r[0] for r in s._con().execute("SELECT name FROM sqlite_master WHERE type='table'")}
    assert "dockets" in names
    idx = {r[0] for r in s._con().execute("SELECT name FROM sqlite_master WHERE type='index'")}
    assert "idx_items_url" in idx


def test_upsert_then_get_roundtrip():
    s = _store()
    d = Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="obj", comment_end_date="2026-09-14")
    s.docket_upsert(d)
    got = s.docket_get("CMS-2026-2377")
    assert got == d
    assert s.docket_get("CMS-0000-0000") is None


def test_upsert_replaces_fields():
    s = _store()
    s.docket_upsert(Docket(id="D1", pull_watermark="a"))
    s.docket_upsert(Docket(id="D1", pull_watermark="b", last_pull_new=4))
    got = s.docket_get("D1")
    assert got.pull_watermark == "b"
    assert got.last_pull_new == 4


def test_docket_for_rule():
    s = _store()
    s.docket_upsert(Docket(id="D1", rule_cms_id="CMS-1848-P"))
    assert s.docket_for_rule("CMS-1848-P").id == "D1"
    assert s.docket_for_rule("CMS-9999-P") is None


def test_seal_unseal_and_sealed_dockets():
    s = _store()
    s.docket_upsert(Docket(id="D1"))
    s.docket_upsert(Docket(id="D2"))
    s.docket_seal("D1", reason="manual", counts={"comments": 3})
    d1 = s.docket_get("D1")
    assert d1.sealed and d1.seal_reason == "manual"
    assert '"comments": 3' in d1.counts_json
    assert set(s.sealed_dockets()) == {"D1"}
    assert s.sealed_dockets()["D1"] == d1.sealed_at
    s.docket_unseal("D1")
    assert not s.docket_get("D1").sealed
    assert s.sealed_dockets() == {}


def test_dockets_lists_sorted_by_id():
    s = _store()
    s.docket_upsert(Docket(id="CMS-2019-0111"))
    s.docket_upsert(Docket(id="CMS-2017-0092"))
    assert [d.id for d in s.dockets()] == ["CMS-2017-0092", "CMS-2019-0111"]
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_store_dockets.py -q -p no:cacheprovider Expected: FAIL — AttributeError: 'Store' object has no attribute 'docket_upsert' (and the first test fails on the missing table).

  • Step 3: Implement

Append to src/bib/schema.sql:


-- ── Dockets (P47) ────────────────────────────────────────────────
-- One row per reg.gov docket: what every stage would otherwise re-fetch
-- (document/object ids, close date), the pull watermark, and the seal
-- that marks the docket complete. See bib/dockets.py.
CREATE TABLE IF NOT EXISTS dockets (
    id               TEXT PRIMARY KEY,
    rule_cms_id      TEXT NOT NULL DEFAULT '',
    fr_document_id   TEXT NOT NULL DEFAULT '',
    fr_object_id     TEXT NOT NULL DEFAULT '',
    comment_end_date TEXT NOT NULL DEFAULT '',
    pull_watermark   TEXT NOT NULL DEFAULT '',
    last_pull_at     TEXT NOT NULL DEFAULT '',
    last_pull_new    INTEGER,
    sealed_at        TEXT NOT NULL DEFAULT '',
    seal_reason      TEXT NOT NULL DEFAULT '',
    counts_json      TEXT NOT NULL DEFAULT '{}'
);

-- upsert() dedupes by URL on every comment; without this every upsert
-- was a full scan of items.
CREATE INDEX IF NOT EXISTS idx_items_url ON items(url);

Add to src/bib/store.py — import at top: from bib.dockets import Docket and import json (json is not yet imported there; add it). New section before # ── Attachments & Notes:

    # ── Dockets ──────────────────────────────────────────────────

    _DOCKET_COLS = (
        "id", "rule_cms_id", "fr_document_id", "fr_object_id",
        "comment_end_date", "pull_watermark", "last_pull_at",
        "last_pull_new", "sealed_at", "seal_reason", "counts_json",
    )

    def _docket_from_row(self, row: sqlite3.Row | None) -> Docket | None:
        if row is None:
            return None
        d = {c: row[c] for c in self._DOCKET_COLS}
        return Docket(**d)

    def docket_get(self, docket_id: str) -> Docket | None:
        row = self._con().execute(
            "SELECT * FROM dockets WHERE id = ?", (docket_id,)
        ).fetchone()
        return self._docket_from_row(row)

    def docket_for_rule(self, cms_rule_id: str) -> Docket | None:
        row = self._con().execute(
            "SELECT * FROM dockets WHERE rule_cms_id = ? ORDER BY id LIMIT 1",
            (cms_rule_id,),
        ).fetchone()
        return self._docket_from_row(row)

    def docket_upsert(self, docket: Docket) -> None:
        cols = ", ".join(self._DOCKET_COLS)
        marks = ", ".join(f":{c}" for c in self._DOCKET_COLS)
        sets = ", ".join(f"{c} = excluded.{c}" for c in self._DOCKET_COLS if c != "id")
        con = self._con()
        con.execute(
            f"INSERT INTO dockets ({cols}) VALUES ({marks}) "  # noqa: S608
            f"ON CONFLICT(id) DO UPDATE SET {sets}",
            {c: getattr(docket, c) for c in self._DOCKET_COLS},
        )
        con.commit()

    def dockets(self) -> list[Docket]:
        rows = self._con().execute("SELECT * FROM dockets ORDER BY id").fetchall()
        return [self._docket_from_row(r) for r in rows]

    def sealed_dockets(self) -> dict[str, str]:
        """docket id → sealed_at for every sealed docket."""
        rows = self._con().execute(
            "SELECT id, sealed_at FROM dockets WHERE sealed_at <> ''"
        ).fetchall()
        return {r["id"]: r["sealed_at"] for r in rows}

    def docket_seal(self, docket_id: str, *, reason: str, counts: dict[str, int]) -> None:
        from datetime import datetime, timezone

        now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        con = self._con()
        con.execute(
            "UPDATE dockets SET sealed_at = ?, seal_reason = ?, counts_json = ? WHERE id = ?",
            (now, reason, json.dumps(counts, sort_keys=True), docket_id),
        )
        con.commit()

    def docket_unseal(self, docket_id: str) -> None:
        con = self._con()
        con.execute(
            "UPDATE dockets SET sealed_at = '', seal_reason = '' WHERE id = ?",
            (docket_id,),
        )
        con.commit()
  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_store_dockets.py tests/bib/test_store.py -q -p no:cacheprovider Expected: all pass.

  • Step 5: Commit
git add src/bib/schema.sql src/bib/store.py tests/bib/test_store_dockets.py
git commit -m "feat(bib): dockets table + Store.docket_* CRUD; index items.url (refs #615)"

Task 3: No-op upsert — Store.upsert_status

Files:

  • Modify: src/bib/store.py:207-245 (upsert)
  • Test: tests/bib/test_store_upsert_noop.py

Interfaces:

  • Produces: Store.upsert_status(item, *, tags=None, collection="") -> tuple[str, str] where the second element is "created" | "updated" | "unchanged". Store.upsert keeps its signature and returns the key only.

  • Step 1: Write the failing tests

# tests/bib/test_store_upsert_noop.py
"""Store.upsert must not rewrite rows/tags when nothing changed."""

from __future__ import annotations

from bib.item import Source
from bib.store import Store


def _store() -> Store:
    return Store(":memory:", storage_dir="/tmp/nope")


def _item(**kw) -> Source:
    it = Source(title="T", url="https://www.regulations.gov/comment/CMS-2026-2377-1")
    it.abstract = kw.get("abstract", "body")
    for t in kw.get("tags", ["a:1", "b:2"]):
        it.add_tag(t)
    return it


def _snapshot(s: Store, key: str) -> tuple:
    con = s._con()
    row = con.execute("SELECT access_date, updated_at FROM items WHERE key=?", (key,)).fetchone()
    tags = con.execute(
        "SELECT it.rowid FROM item_tags it JOIN items i ON i.id=it.item_id WHERE i.key=? ORDER BY 1",
        (key,),
    ).fetchall()
    return (row["access_date"], row["updated_at"], [t[0] for t in tags])


def test_first_upsert_is_created():
    s = _store()
    key, status = s.upsert_status(_item())
    assert status == "created" and key


def test_identical_upsert_is_unchanged_and_writes_nothing():
    s = _store()
    key, _ = s.upsert_status(_item())
    before = _snapshot(s, key)
    # different Python object, same content
    key2, status = s.upsert_status(_item())
    assert key2 == key and status == "unchanged"
    assert _snapshot(s, key) == before  # no access/updated stamp, no tag row churn


def test_changed_abstract_is_updated():
    s = _store()
    key, _ = s.upsert_status(_item(abstract="v1"))
    _, status = s.upsert_status(_item(abstract="v2"))
    assert status == "updated"
    assert s.get(key).abstract == "v2"


def test_new_tag_is_updated_and_merged():
    s = _store()
    key, _ = s.upsert_status(_item(tags=["a:1"]))
    _, status = s.upsert_status(_item(tags=["c:3"]))
    assert status == "updated"
    assert set(s.get(key).tags) >= {"a:1", "c:3"}


def test_subset_of_existing_tags_is_unchanged():
    """Upsert never removes tags (#624); a subset therefore changes nothing."""
    s = _store()
    key, _ = s.upsert_status(_item(tags=["a:1", "b:2"]))
    _, status = s.upsert_status(_item(tags=["a:1"]))
    assert status == "unchanged"


def test_upsert_keeps_returning_key():
    s = _store()
    key = s.upsert(_item())
    assert s.upsert(_item()) == key
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_store_upsert_noop.py -q -p no:cacheprovider Expected: FAIL — AttributeError: 'Store' object has no attribute 'upsert_status'

  • Step 3: Implement

Replace the body of Store.upsert in src/bib/store.py with:

    _COMPARE_COLS = (
        "item_type", "title", "url", "date_published",
        "abstract", "institution", "extra", "extra_json",
    )

    def upsert(
        self,
        item: Item,
        *,
        tags: list[Any] | None = None,
        collection: str = "",
    ) -> str:
        """Create or update an item, deduplicating by URL."""
        return self.upsert_status(item, tags=tags, collection=collection)[0]

    def upsert_status(
        self,
        item: Item,
        *,
        tags: list[Any] | None = None,
        collection: str = "",
    ) -> tuple[str, str]:
        """Like :meth:`upsert` but also report what happened:
        ``"created"``, ``"updated"`` or ``"unchanged"``.

        ``unchanged`` means every compared column, the merged tag set and
        the merged collection set already match the stored row — nothing
        is written, no ``access_date``/``updated_at`` stamp, no tag row
        churn. A re-farm over a complete docket is therefore a read-only
        pass over ``items``.
        """
        if tags:
            for tag in tags:
                label = tag.label if hasattr(tag, "label") else str(tag)
                item.add_tag(label)
        if collection and collection not in item.collections:
            item.collections.append(collection)

        if not item.url:
            return self.create(item), "created"

        con = self._con()
        existing = con.execute(
            "SELECT key FROM items WHERE url = ?", (item.url,)
        ).fetchone()
        if not existing:
            return self.create(item), "created"

        ekey = existing["key"]
        # Merge with what's already stored — a re-ingest must never
        # clobber tags/collections curated on the row since the last
        # ingest (#624). Deliberate removal goes through remove_tag.
        current = self.get(ekey)
        merged_tags = list(dict.fromkeys([*current.tags, *item.tags]))
        merged_cols = list(dict.fromkeys([*current.collections, *item.collections]))

        new_row = item.to_row()
        cur_row = current.to_row()
        same_cols = all(new_row.get(c, "") == cur_row.get(c, "") for c in self._COMPARE_COLS)
        if same_cols and set(merged_tags) == set(current.tags) and set(merged_cols) == set(current.collections):
            return ekey, "unchanged"

        item.stamp_access()
        row = item.to_row()
        row.pop("key", None)
        row["tags"] = merged_tags
        row["collections"] = merged_cols
        self.update(ekey, **row)
        return ekey, "updated"

Note: Item.to_row() for subclasses packs subclass fields into extra_json; comparing extra_json strings works because both sides serialize through the same to_row. current came from Item.from_row, so current.to_row()["extra_json"] is the canonical serialization of the stored data.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_store_upsert_noop.py tests/bib/test_store.py tests/bib -q -p no:cacheprovider Expected: all pass. If an existing test asserts that access_date changes on an identical re-upsert, update that test: the new contract is "unchanged → no stamp".

  • Step 5: Commit
git add src/bib/store.py tests/bib/test_store_upsert_noop.py
git commit -m "feat(bib): Store.upsert_status — identical re-upsert writes nothing (refs #615)"

Task 3b: Data-preserving upsert — never blank a stored value

Files:

  • Modify: src/bib/store.py (upsert_status)
  • Test: tests/bib/test_store_upsert_noop.py (extend)

Why (incident 2026-09-08): the fetch list walk builds a Source whose abstract is empty (the list endpoint has no body) and re-upserts every comment it sees. With the old upsert that overwrote 17,607 enriched bodies in CMS-2026-2377 and 1,115 in CMS-2017-0092. Task 3's comparison alone would still call that "updated" and write the empty abstract. Upsert must merge, not replace: an empty incoming value carries no information.

Interfaces:

  • Consumes: Store.upsert_status from Task 3.

  • Produces: same signature; for every column in _COMPARE_COLS (except extra_json) an empty incoming string keeps the stored value. extra_json is compared/written as today (it is always a full serialization).

  • Step 1: Write the failing tests (append to tests/bib/test_store_upsert_noop.py)

def test_empty_incoming_abstract_keeps_stored_body():
    """A list-walk row (no body) must not blank an enriched comment."""
    s = _store()
    key, _ = s.upsert_status(_item(abstract="enriched body"))
    _, status = s.upsert_status(_item(abstract=""))
    assert status == "unchanged"
    assert s.get(key).abstract == "enriched body"


def test_empty_incoming_title_keeps_stored_title():
    s = _store()
    it = _item(); it.title = "Org: CMS-2026-2377-1"
    key, _ = s.upsert_status(it)
    it2 = _item(); it2.title = ""
    _, status = s.upsert_status(it2)
    assert status == "unchanged"
    assert s.get(key).title == "Org: CMS-2026-2377-1"


def test_non_empty_incoming_still_updates():
    s = _store()
    key, _ = s.upsert_status(_item(abstract="v1"))
    _, status = s.upsert_status(_item(abstract="v2"))
    assert status == "updated" and s.get(key).abstract == "v2"
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_store_upsert_noop.py -q -p no:cacheprovider Expected: the first two new tests FAIL (status == "updated", abstract/title blanked).

  • Step 3: Implement

In upsert_status, after cur_row = current.to_row() and before the same_cols check:

        # Merge, don't replace: an empty incoming value carries no
        # information (a list-walk row has no body), so the stored value
        # wins. extra_json is always a full serialization and is exempt.
        for c in self._COMPARE_COLS:
            if c != "extra_json" and not new_row.get(c) and cur_row.get(c):
                new_row[c] = cur_row[c]
                setattr(item, c, cur_row[c])

setattr(item, c, …) keeps the later item.to_row() (used to build the written row) consistent with the merged values. Item is a pydantic model; plain attribute assignment is allowed on these fields.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/bib/store.py tests/bib/test_store_upsert_noop.py
git commit -m "fix(bib): upsert never blanks a stored value — empty incoming columns keep the stored ones (refs #615)"

Task 4: Idempotent attach_file

Files:

  • Modify: src/bib/store.py:502-533 (attach_file)
  • Test: tests/bib/test_store_attach_idempotent.py

Interfaces:

  • Produces: Store.attach_file(item_key, path, *, title="") -> str returns the existing attachment key when the item already has an attachment whose filename equals title or path.name.

  • Step 1: Write the failing test

# tests/bib/test_store_attach_idempotent.py
from __future__ import annotations

from pathlib import Path

from bib.item import Source
from bib.store import Store


def test_second_attach_of_same_filename_returns_existing_key(tmp_path: Path):
    s = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
    key = s.create(Source(title="T", url="https://x/1"))
    f = tmp_path / "attachment_1.pdf"
    f.write_bytes(b"%PDF")
    k1 = s.attach_file(key, f, title="attachment_1.pdf")
    k2 = s.attach_file(key, f, title="attachment_1.pdf")
    assert k1 == k2
    n = s._con().execute("SELECT count(*) FROM attachments").fetchone()[0]
    assert n == 1
    # exactly one storage copy
    assert len(list((tmp_path / "storage").iterdir())) == 1


def test_different_filename_creates_second_row(tmp_path: Path):
    s = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
    key = s.create(Source(title="T", url="https://x/1"))
    f1 = tmp_path / "a.pdf"; f1.write_bytes(b"a")
    f2 = tmp_path / "b.pdf"; f2.write_bytes(b"b")
    assert s.attach_file(key, f1) != s.attach_file(key, f2)
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_store_attach_idempotent.py -q -p no:cacheprovider Expected: first test FAILS (k1 != k2, count 2).

  • Step 3: Implement

In attach_file, after the item lookup and before generating a key:

        filename = title or path.name
        dup = con.execute(
            "SELECT key FROM attachments WHERE item_id = ? AND filename = ?",
            (row["id"], filename),
        ).fetchone()
        if dup:
            return dup["key"]

and use filename in the INSERT instead of title or path.name.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_store_attach_idempotent.py tests/bib -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/bib/store.py tests/bib/test_store_attach_idempotent.py
git commit -m "fix(bib): attach_file is idempotent on (item, filename) (refs #615)"

Task 5: Dedupe migration script + guarded unique index

Files:

  • Create: dev/scripts/dedupe_attachments.py
  • Modify: src/bib/store.py (_init_schema)
  • Test: tests/scripts/test_dedupe_attachments.py

Interfaces:

  • Produces: dedupe_attachments.plan(con) -> list[Group], dedupe_attachments.apply(con, groups) -> Report; CLI uv run python dev/scripts/dedupe_attachments.py [--db PATH] [--apply]. Store._init_schema creates idx_attachments_item_filename (UNIQUE on (item_id, filename)) only when no duplicate groups remain.

  • Step 1: Write the failing tests

# tests/scripts/test_dedupe_attachments.py
from __future__ import annotations

import importlib.util
import sqlite3
from pathlib import Path

from bib.item import Source
from bib.store import Store

_SCRIPT = Path(__file__).resolve().parents[2] / "dev" / "scripts" / "dedupe_attachments.py"
spec = importlib.util.spec_from_file_location("dedupe_attachments", _SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)


def _seed(tmp_path: Path) -> tuple[Store, str]:
    s = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
    key = s.create(Source(title="T", url="https://x/1"))
    f = tmp_path / "attachment_1.pdf"
    f.write_bytes(b"%PDF-dup")
    # Simulate the old non-idempotent attach: three rows, three copies.
    con = s._con()
    item_id = con.execute("SELECT id FROM items WHERE key=?", (key,)).fetchone()[0]
    for k in ("AAAAAAAA", "BBBBBBBB", "CCCCCCCC"):
        d = tmp_path / "storage" / k
        d.mkdir(parents=True)
        (d / "attachment_1.pdf").write_bytes(b"%PDF-dup")
        con.execute(
            "INSERT INTO attachments (item_id, key, filename, content_type, storage_path) VALUES (?,?,?,?,?)",
            (item_id, k, "attachment_1.pdf", "application/pdf", str(d / "attachment_1.pdf")),
        )
    con.commit()
    return s, key


def test_plan_finds_group_and_keeps_oldest(tmp_path: Path):
    s, _ = _seed(tmp_path)
    groups = mod.plan(s._con())
    assert len(groups) == 1
    g = groups[0]
    assert g.keep == "AAAAAAAA"
    assert sorted(g.remove) == ["BBBBBBBB", "CCCCCCCC"]


def test_apply_removes_rows_and_files(tmp_path: Path):
    s, _ = _seed(tmp_path)
    rep = mod.apply(s._con(), mod.plan(s._con()))
    assert rep.rows_removed == 2
    assert rep.files_removed == 2
    assert s._con().execute("SELECT count(*) FROM attachments").fetchone()[0] == 1
    assert (tmp_path / "storage" / "AAAAAAAA" / "attachment_1.pdf").is_file()
    assert not (tmp_path / "storage" / "BBBBBBBB").exists()
    assert mod.plan(s._con()) == []


def test_refuses_group_with_differing_sizes(tmp_path: Path):
    s, _ = _seed(tmp_path)
    (tmp_path / "storage" / "CCCCCCCC" / "attachment_1.pdf").write_bytes(b"different-longer")
    groups = mod.plan(s._con())
    assert groups[0].conflict is True
    rep = mod.apply(s._con(), groups)
    assert rep.rows_removed == 0 and rep.skipped_conflicts == 1


def test_unique_index_created_only_when_clean(tmp_path: Path):
    s, _ = _seed(tmp_path)
    s.close()
    s2 = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
    idx = {r[0] for r in s2._con().execute("SELECT name FROM sqlite_master WHERE type='index'")}
    assert "idx_attachments_item_filename" not in idx
    mod.apply(s2._con(), mod.plan(s2._con()))
    s2.close()
    s3 = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
    idx = {r[0] for r in s3._con().execute("SELECT name FROM sqlite_master WHERE type='index'")}
    assert "idx_attachments_item_filename" in idx
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/scripts/test_dedupe_attachments.py -q -p no:cacheprovider Expected: FAIL — script file missing.

  • Step 3: Implement the script
#!/usr/bin/env python3
"""One-time cleanup of duplicate bib attachments (refs #615).

``Store.attach_file`` used to mint a new row + storage copy on every
call, so re-farms produced thousands of ``(item_id, filename)``
duplicates (33,379 groups / 66,917 of 78,017 rows on 2026-09-08). This
keeps the oldest row of each group, deletes the others' rows and their
storage copies, and prints a report. Dry-run by default.

Zotero is not touched: ``bib.sync`` already dedupes child attachments by
filename, so a duplicate that was synced once is a single Zotero child
attachment and stays valid.

Usage::

    uv run python dev/scripts/dedupe_attachments.py            # report only
    uv run python dev/scripts/dedupe_attachments.py --apply    # delete
"""

from __future__ import annotations

import argparse
import os
import sqlite3
import sys
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class Group:
    item_id: int
    filename: str
    keep: str
    remove: list[str] = field(default_factory=list)
    remove_paths: list[str] = field(default_factory=list)
    conflict: bool = False  # sizes differ → do not touch


@dataclass
class Report:
    groups: int = 0
    rows_removed: int = 0
    files_removed: int = 0
    bytes_freed: int = 0
    skipped_conflicts: int = 0
    removed_keys: list[str] = field(default_factory=list)


def plan(con: sqlite3.Connection) -> list[Group]:
    con.row_factory = sqlite3.Row
    rows = con.execute(
        """
        SELECT a.item_id, a.filename, a.key, a.storage_path, a.rowid AS rid
        FROM attachments a
        WHERE (a.item_id, a.filename) IN (
            SELECT item_id, filename FROM attachments
            GROUP BY item_id, filename HAVING count(*) > 1
        )
        ORDER BY a.item_id, a.filename, a.rowid
        """
    ).fetchall()
    groups: dict[tuple[int, str], Group] = {}
    for r in rows:
        gkey = (r["item_id"], r["filename"])
        g = groups.get(gkey)
        if g is None:
            groups[gkey] = Group(item_id=r["item_id"], filename=r["filename"], keep=r["key"])
            continue
        g.remove.append(r["key"])
        g.remove_paths.append(r["storage_path"])
    # conflict check: every copy must have the same size as the kept one
    for g in groups.values():
        keep_path = con.execute(
            "SELECT storage_path FROM attachments WHERE key = ?", (g.keep,)
        ).fetchone()["storage_path"]
        keep_size = _size(keep_path)
        for p in g.remove_paths:
            if _size(p) not in (keep_size, -1):
                g.conflict = True
                break
    return list(groups.values())


def _size(path: str) -> int:
    try:
        return os.stat(path).st_size
    except OSError:
        return -1


def apply(con: sqlite3.Connection, groups: list[Group]) -> Report:
    rep = Report(groups=len(groups))
    con.execute("BEGIN")
    try:
        for g in groups:
            if g.conflict:
                rep.skipped_conflicts += 1
                continue
            for key, path in zip(g.remove, g.remove_paths):
                still_referenced = con.execute(
                    "SELECT count(*) FROM attachments WHERE storage_path = ? AND key <> ?",
                    (path, key),
                ).fetchone()[0]
                con.execute("DELETE FROM attachments WHERE key = ?", (key,))
                rep.rows_removed += 1
                rep.removed_keys.append(key)
                if not still_referenced:
                    p = Path(path)
                    if p.is_file():
                        rep.bytes_freed += p.stat().st_size
                        p.unlink()
                        rep.files_removed += 1
                        try:
                            p.parent.rmdir()  # the per-key dir, if now empty
                        except OSError:
                            pass
        con.execute("COMMIT")
    except Exception:
        con.execute("ROLLBACK")
        raise
    return rep


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--db", default="", help="bib.sqlite path (default: stack.toml db.bib)")
    ap.add_argument("--apply", action="store_true", help="delete duplicates (default: report only)")
    args = ap.parse_args(argv)
    if args.db:
        db = args.db
    else:
        sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
        from conf import path

        db = str(path("db.bib"))
    con = sqlite3.connect(db, isolation_level=None)
    groups = plan(con)
    conflicts = sum(1 for g in groups if g.conflict)
    rows = sum(len(g.remove) for g in groups)
    print(f"{db}: {len(groups)} duplicate groups, {rows} rows to remove, {conflicts} conflicts (size mismatch, skipped)")
    if not args.apply:
        print("dry run — pass --apply to delete")
        return 0
    rep = apply(con, groups)
    print(
        f"removed rows={rep.rows_removed} files={rep.files_removed} "
        f"freed={rep.bytes_freed / 1e6:.1f} MB skipped_conflicts={rep.skipped_conflicts}"
    )
    print("reopen the store once (any `stack bib` command) to create the unique index")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Then in src/bib/store.py _init_schema, after executescript(ddl):

        # The (item_id, filename) unique index can only exist once the
        # dedupe migration has run (dev/scripts/dedupe_attachments.py);
        # until then we leave it off rather than fail to open the store.
        con = self._con()
        dup = con.execute(
            "SELECT 1 FROM attachments GROUP BY item_id, filename HAVING count(*) > 1 LIMIT 1"
        ).fetchone()
        if dup is None:
            con.execute(
                "CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_item_filename "
                "ON attachments(item_id, filename)"
            )
            con.commit()
  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/scripts/test_dedupe_attachments.py tests/bib -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add dev/scripts/dedupe_attachments.py src/bib/store.py tests/scripts/test_dedupe_attachments.py
git commit -m "feat(bib): dedupe_attachments migration + guarded unique index on (item, filename) (refs #615)"

Task 6: iter_comments(since=, on_error=)

Files:

  • Modify: src/bib/regulations_gov.py:198-258 (Client.iter_comments)
  • Test: tests/bib/test_regulations_gov_since.py

Interfaces:

  • Produces: Client.iter_comments(object_id, *, since: str = "", on_error: Callable[[Exception], None] | None = None) -> Iterator[Comment]. since seeds the filter[lastModifiedDate][ge] cursor; on_error is called (then the walk stops, as today) when a page returns an HTTP error.

  • Step 1: Write the failing tests

# tests/bib/test_regulations_gov_since.py
from __future__ import annotations

import httpx

from bib.regulations_gov import Client


def _row(cid: str, lm: str) -> dict:
    return {"id": cid, "attributes": {"lastModifiedDate": lm, "postedDate": lm, "docketId": "D", "commentOnId": "x"}}


def _client(handler) -> Client:
    http = httpx.Client(transport=httpx.MockTransport(handler), headers={"X-Api-Key": "k"})
    return Client(api_key="k", sleep=0, client=http)


def test_since_seeds_the_date_filter():
    seen: list[dict] = []

    def handler(req: httpx.Request) -> httpx.Response:
        seen.append(dict(req.url.params))
        return httpx.Response(200, json={"data": [_row("D-1", "2026-09-08T12:00:00Z")], "meta": {"totalPages": 1}})

    api = _client(handler)
    out = list(api.iter_comments("obj", since="2026-09-01T00:00:00Z"))
    assert [c.id for c in out] == ["D-1"]
    assert seen[0]["filter[lastModifiedDate][ge]"] == "2026-09-01 00:00:00"


def test_no_since_means_no_date_filter():
    seen: list[dict] = []

    def handler(req: httpx.Request) -> httpx.Response:
        seen.append(dict(req.url.params))
        return httpx.Response(200, json={"data": [], "meta": {"totalPages": 1}})

    list(_client(handler).iter_comments("obj"))
    assert "filter[lastModifiedDate][ge]" not in seen[0]


def test_on_error_called_when_page_fails():
    errors: list[Exception] = []

    def handler(req: httpx.Request) -> httpx.Response:
        return httpx.Response(500, json={})

    out = list(_client(handler).iter_comments("obj", on_error=errors.append))
    assert out == []
    assert len(errors) == 1 and isinstance(errors[0], httpx.HTTPStatusError)
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_regulations_gov_since.py -q -p no:cacheprovider Expected: FAIL — TypeError: iter_comments() got an unexpected keyword argument 'since'

  • Step 3: Implement

Change the signature and the two touched lines in iter_comments:

    def iter_comments(
        self,
        object_id: str,
        *,
        since: str = "",
        on_error: "Callable[[Exception], None] | None" = None,
    ) -> Iterator[Comment]:
        """... (keep the existing docstring; add:)

        *since* seeds the ``lastModifiedDate`` cursor so an incremental
        walk starts where the last clean one ended (the boundary row is
        re-yielded; the store's no-op upsert absorbs it). *on_error* is
        invoked with the ``HTTPStatusError`` before the walk stops, so a
        caller can tell "walked to the end" from "gave up".
        """
        cursor: str | None = _reg_date(since) if since else None
        page = 1
        while True:
            ...
            try:
                data = self._get("/comments", **params)
            except httpx.HTTPStatusError as e:
                log.warning(...)  # unchanged
                if on_error is not None:
                    on_error(e)
                break

Add from typing import TYPE_CHECKING, Callable, Iterator at the top (Callable joins the existing import).

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_regulations_gov_since.py tests/bib/test_regulations_gov_exercise.py -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/bib/regulations_gov.py tests/bib/test_regulations_gov_since.py
git commit -m "feat(bib): iter_comments since= watermark + on_error hook (refs #615)"

Task 7: discover_docket + walk_docket (watermark, counts, auto-seal)

Files:

  • Modify: src/bib/regulations_gov.py (new section after upsert_comment)
  • Test: tests/bib/test_walk_docket.py

Interfaces:

  • Consumes: Store.docket_get/docket_upsert/docket_seal, Client.iter_comments(since=, on_error=), Client.find_documents_in_docket, Client.attachments_for, Client.download_attachment, Store.upsert_status, bib.dockets.should_seal.

  • Produces:

    • discover_docket(api, docket_id, *, rule_cms_id="") -> Docket — one find_documents_in_docket call; picks the document with an objectId and the latest commentEndDate.
    • WalkResult dataclass: created, updated, unchanged: int, watermark: str, clean: bool, sealed: bool.
    • walk_docket(store, api, docket, *, attachments=False, limit=0, force=False, quiet_days=30, today=None, scratch_root=Path(".state/comments"), echo=None) -> WalkResult.
  • Step 1: Write the failing tests

# tests/bib/test_walk_docket.py
from __future__ import annotations

from datetime import date
from unittest.mock import MagicMock

import httpx

from bib.dockets import Docket
from bib.regulations_gov import Comment, WalkResult, discover_docket, walk_docket
from bib.store import Store

D = "CMS-2026-2377"


def _c(n: int, lm: str) -> Comment:
    return Comment(
        id=f"{D}-{n}", title="", posted_date=lm[:10], received_date=lm[:10],
        docket_id=D, comment_on_id="x", raw={"attributes": {"lastModifiedDate": lm}},
    )


def _store() -> Store:
    return Store(":memory:", storage_dir="/tmp/nope")


def _docket(**kw) -> Docket:
    base = dict(id=D, rule_cms_id="CMS-1848-P", fr_object_id="obj", comment_end_date="2026-09-14")
    base.update(kw)
    return Docket(**base)


def test_discover_docket_picks_commentable_doc():
    api = MagicMock()
    api.find_documents_in_docket.return_value = [
        {"id": "X-1", "attributes": {"objectId": "o1"}},  # no comment window
        {"id": "X-2", "attributes": {"objectId": "o2", "commentEndDate": "2026-09-14T03:59:59Z"}},
    ]
    d = discover_docket(api, D, rule_cms_id="CMS-1848-P")
    assert d == Docket(id=D, rule_cms_id="CMS-1848-P", fr_document_id="X-2", fr_object_id="o2", comment_end_date="2026-09-14")
    api.find_documents_in_docket.assert_called_once_with(D)


def test_walk_creates_counts_and_advances_watermark():
    s = _store()
    s.docket_upsert(_docket())
    api = MagicMock()
    api.iter_comments.return_value = [_c(1, "2026-09-01T00:00:00Z"), _c(2, "2026-09-02T00:00:00Z")]
    r = walk_docket(s, api, s.docket_get(D), today=date(2026, 9, 8))
    assert r == WalkResult(created=2, updated=0, unchanged=0, watermark="2026-09-02T00:00:00Z", clean=True, sealed=False)
    d = s.docket_get(D)
    assert d.pull_watermark == "2026-09-02T00:00:00Z"
    assert d.last_pull_new == 2 and d.last_pull_at
    api.iter_comments.assert_called_once()
    assert api.iter_comments.call_args.kwargs["since"] == ""


def test_walk_passes_watermark_and_counts_unchanged():
    s = _store()
    s.docket_upsert(_docket())
    api = MagicMock()
    api.iter_comments.return_value = [_c(1, "2026-09-01T00:00:00Z")]
    walk_docket(s, api, s.docket_get(D), today=date(2026, 9, 8))
    api.iter_comments.return_value = [_c(1, "2026-09-01T00:00:00Z")]  # boundary re-yield
    r = walk_docket(s, api, s.docket_get(D), today=date(2026, 9, 8))
    assert api.iter_comments.call_args.kwargs["since"] == "2026-09-01T00:00:00Z"
    assert (r.created, r.unchanged) == (0, 1)
    assert s.docket_get(D).last_pull_new == 0


def test_unclean_walk_keeps_old_watermark():
    s = _store()
    s.docket_upsert(_docket(pull_watermark="2026-08-01T00:00:00Z"))
    api = MagicMock()

    def _iter(_obj, *, since="", on_error=None):
        yield _c(9, "2026-09-05T00:00:00Z")
        on_error(httpx.HTTPStatusError("boom", request=MagicMock(), response=MagicMock()))

    api.iter_comments.side_effect = _iter
    r = walk_docket(s, api, s.docket_get(D), today=date(2026, 9, 8))
    assert r.clean is False and r.created == 1
    assert s.docket_get(D).pull_watermark == "2026-08-01T00:00:00Z"


def test_force_walks_from_scratch_and_never_seals():
    s = _store()
    s.docket_upsert(_docket(pull_watermark="2026-08-01T00:00:00Z"))
    api = MagicMock()
    api.iter_comments.return_value = []
    r = walk_docket(s, api, s.docket_get(D), force=True, today=date(2027, 1, 1))
    assert api.iter_comments.call_args.kwargs["since"] == ""
    assert r.sealed is False and not s.docket_get(D).sealed


def test_auto_seal_after_quiet_empty_pull():
    s = _store()
    s.docket_upsert(_docket())
    api = MagicMock()
    api.iter_comments.return_value = []
    r = walk_docket(s, api, s.docket_get(D), today=date(2026, 10, 20), quiet_days=30)
    assert r.sealed is True
    d = s.docket_get(D)
    assert d.sealed and d.seal_reason == "auto"
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_walk_docket.py -q -p no:cacheprovider Expected: FAIL — ImportError: cannot import name 'WalkResult'

  • Step 3: Implement

Append to src/bib/regulations_gov.py (after upsert_comment):

# ── Docket walks ───────────────────────────────────────────────


def discover_docket(client: Client, docket_id: str, *, rule_cms_id: str = "") -> Docket:
    """One ``/documents`` listing → the docket's commentable document.

    Picks the document that has an ``objectId`` and a ``commentEndDate``
    (latest close date wins when several qualify). Called once per
    docket; the result is persisted so later runs make no API call.
    """
    best: dict | None = None
    for fr_doc in client.find_documents_in_docket(docket_id):
        attrs = fr_doc.get("attributes") or {}
        if not attrs.get("objectId") or not attrs.get("commentEndDate"):
            continue
        if best is None or attrs["commentEndDate"] > (best.get("attributes") or {})["commentEndDate"]:
            best = fr_doc
    attrs = (best or {}).get("attributes") or {}
    return Docket(
        id=docket_id,
        rule_cms_id=rule_cms_id,
        fr_document_id=(best or {}).get("id", ""),
        fr_object_id=attrs.get("objectId", ""),
        comment_end_date=(attrs.get("commentEndDate") or "")[:10],
    )


@dataclass
class WalkResult:
    created: int = 0
    updated: int = 0
    unchanged: int = 0
    watermark: str = ""
    clean: bool = True
    sealed: bool = False


def walk_docket(
    store: Store,
    client: Client,
    docket: Docket,
    *,
    attachments: bool = False,
    limit: int = 0,
    force: bool = False,
    quiet_days: int = 30,
    today: date | None = None,
    scratch_root: Path = Path(".state/comments"),
    echo: Callable[[str], None] | None = None,
) -> WalkResult:
    """Pull *docket*'s comments from the stored watermark, upsert them,
    advance the watermark on a clean walk, and auto-seal when
    :func:`should_seal` says so. ``force`` walks from page 1 and never
    seals. Returns per-status counts.
    """
    from bib.dockets import should_seal

    res = WalkResult()
    since = "" if force else docket.pull_watermark
    max_lm = docket.pull_watermark if not force else ""
    n = 0

    def _err(_e: Exception) -> None:
        res.clean = False

    scratch = scratch_root / docket.id
    for c in client.iter_comments(docket.fr_object_id, since=since, on_error=_err):
        if limit and n >= limit:
            res.clean = False  # a capped walk is not a complete one
            break
        key, status = upsert_comment_status(
            store, c, cms_id=docket.rule_cms_id, extra_tags=[f"reg-docket:{docket.id}"]
        )
        setattr(res, status, getattr(res, status) + 1)
        if attachments and c.attachment_count and status != "unchanged":
            for att in client.attachments_for(c.id):
                path = client.download_attachment(att.url, scratch / c.id)
                if path:
                    store.attach_file(key, path, title=att.filename)
        lm = (c.raw.get("attributes") or {}).get("lastModifiedDate") or ""
        if lm > max_lm:
            max_lm = lm
        n += 1
        if n % 50 == 0:
            store._con().commit()  # noqa: SLF001
            if echo:
                echo(f"    {n} comments")
    store._con().commit()  # noqa: SLF001
    res.watermark = max_lm

    if res.clean and not force:
        from datetime import datetime, timezone

        now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        updated = replace(
            docket, pull_watermark=max_lm, last_pull_at=now, last_pull_new=res.created
        )
        store.docket_upsert(updated)
        if should_seal(updated, today or date.today(), quiet_days):
            store.docket_seal(docket.id, reason="auto", counts=docket_counts(store, docket.id))
            res.sealed = True
    return res


def docket_counts(store: Store, docket_id: str) -> dict[str, int]:
    """``{"comments": n, "enriched": n}`` from SQL only (no filesystem)."""
    con = store._con()  # noqa: SLF001
    like = f"https://www.regulations.gov/comment/{docket_id}-%"
    comments = con.execute("SELECT count(*) FROM items WHERE url LIKE ?", (like,)).fetchone()[0]
    enriched = con.execute(
        """SELECT count(*) FROM items i WHERE i.url LIKE ? AND i.id IN (
             SELECT item_id FROM item_tags WHERE tag_id IN (SELECT id FROM tags WHERE name='enriched:ok'))""",
        (like,),
    ).fetchone()[0]
    return {"comments": comments, "enriched": enriched}

And split upsert_comment so the status is available:

def upsert_comment(store, comment, *, cms_id="", extra_tags=None) -> str:
    """Upsert the comment as a Source item, return bib key."""
    return upsert_comment_status(store, comment, cms_id=cms_id, extra_tags=extra_tags)[0]


def upsert_comment_status(store, comment, *, cms_id="", extra_tags=None) -> tuple[str, str]:
    """Like :func:`upsert_comment`, also returning created/updated/unchanged."""
    ...  # the existing body, ending with:
    return store.upsert_status(item)

Imports to add at the top of the module: from dataclasses import dataclass, field, replace, from datetime import date, from bib.dockets import Docket.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_walk_docket.py tests/bib -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/bib/regulations_gov.py tests/bib/test_walk_docket.py
git commit -m "feat(bib): discover_docket + walk_docket — watermark, counts, auto-seal (refs #615)"

Task 8: Fetch CLI rewired — sealed skip before any API call

Files:

  • Modify: src/cli/bib.py:129-298 (fetch_docket_comments, fetch_pfs_comments)
  • Test: tests/cli/test_bib_fetch_sealed.py; update tests/cli/test_bib_exercise.py::TestFetchDocketComments / TestFetchPfsComments to the new call shapes.

Interfaces:

  • Consumes: Store.docket_get/docket_for_rule/docket_upsert, discover_docket, walk_docket, bib.dockets.quiet_days.

  • Produces: stack bib fetch-pfs-comments [--docket ID] [--force] [--attachments] [--per-docket-limit N] [--since] [--until] [--sleep] and stack bib fetch-docket-comments <ID> [--cms-id] [--limit] [--attachments] [--sleep] [--force].

  • Step 1: Write the failing tests

# tests/cli/test_bib_fetch_sealed.py
"""fetch-pfs-comments / fetch-docket-comments must not call reg.gov for
sealed dockets and must reuse stored document ids for known ones."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

from typer.testing import CliRunner

from bib.dockets import Docket
from bib.item import Rule
from bib.regulations_gov import WalkResult
from bib.store import Store
from cli.bib import app

runner = CliRunner()


def _store() -> Store:
    return Store(":memory:", storage_dir="/tmp/nope")


def _rule_doc():
    doc = MagicMock()
    doc.type = "Proposed Rule"
    doc.publication_date = "2026-07-16"
    doc.dockets = ["CMS-1848-P"]
    doc.html_url = "https://example.com"
    doc.document_number = "2026-1"
    return doc


def _api():
    api = MagicMock()
    api.__enter__ = MagicMock(return_value=api)
    api.__exit__ = MagicMock(return_value=False)
    return api


@patch("bib.regulations_gov.walk_docket", return_value=WalkResult())
@patch("bib.regulations_gov.Client")
@patch("bib.translate.federal_register")
@patch("bib.federalregister.split_docket_ids", return_value=["CMS-1848-P"])
@patch("bib.federalregister.pfs_rules")
@patch("bib.connect")
def test_sealed_docket_skipped_before_any_call(mc_connect, mc_pfs, _split, mc_fr, mc_client, mc_walk):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="o", sealed_at="2026-10-20T00:00:00Z", seal_reason="auto"))
    mc_connect.return_value = s
    mc_pfs.return_value = [_rule_doc()]
    api = _api()
    mc_client.return_value = api

    result = runner.invoke(app, ["fetch-pfs-comments"])

    assert result.exit_code == 0, result.output
    assert "sealed" in result.output
    mc_fr.assert_not_called()               # no rule-metadata fetch
    api.resolve_docket.assert_not_called()
    api.find_documents_in_docket.assert_not_called()
    mc_walk.assert_not_called()


@patch("bib.regulations_gov.walk_docket", return_value=WalkResult(created=2))
@patch("bib.regulations_gov.Client")
@patch("bib.translate.federal_register")
@patch("bib.federalregister.split_docket_ids", return_value=["CMS-1848-P"])
@patch("bib.federalregister.pfs_rules")
@patch("bib.connect")
def test_known_open_docket_walks_without_resolve(mc_connect, mc_pfs, _split, mc_fr, mc_client, mc_walk):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="o", comment_end_date="2026-09-14"))
    mc_connect.return_value = s
    mc_pfs.return_value = [_rule_doc()]
    mc_fr.return_value = Rule(title="CY2027 PFS NPRM", url="https://www.federalregister.gov/d/2026-1")
    api = _api()
    mc_client.return_value = api

    result = runner.invoke(app, ["fetch-pfs-comments"])

    assert result.exit_code == 0, result.output
    api.resolve_docket.assert_not_called()
    api.find_documents_in_docket.assert_not_called()
    mc_walk.assert_called_once()
    assert mc_walk.call_args.args[2].id == "CMS-2026-2377"
    assert "created=2" in result.output


@patch("bib.regulations_gov.walk_docket", return_value=WalkResult())
@patch("bib.regulations_gov.discover_docket")
@patch("bib.regulations_gov.Client")
@patch("bib.translate.federal_register")
@patch("bib.federalregister.split_docket_ids", return_value=["CMS-1848-P"])
@patch("bib.federalregister.pfs_rules")
@patch("bib.connect")
def test_unknown_docket_is_resolved_once_and_stored(mc_connect, mc_pfs, _split, mc_fr, mc_client, mc_disc, mc_walk):
    s = _store()
    mc_connect.return_value = s
    mc_pfs.return_value = [_rule_doc()]
    mc_fr.return_value = Rule(title="CY2027 PFS NPRM", url="https://www.federalregister.gov/d/2026-1")
    api = _api()
    api.resolve_docket.return_value = "CMS-2026-2377"
    mc_client.return_value = api
    mc_disc.return_value = Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="o", comment_end_date="2026-09-14")

    result = runner.invoke(app, ["fetch-pfs-comments"])

    assert result.exit_code == 0, result.output
    api.resolve_docket.assert_called_once_with("CMS-1848-P")
    mc_disc.assert_called_once()
    assert s.docket_get("CMS-2026-2377").fr_object_id == "o"


@patch("bib.regulations_gov.walk_docket", return_value=WalkResult())
@patch("bib.regulations_gov.Client")
@patch("bib.translate.federal_register")
@patch("bib.federalregister.split_docket_ids", return_value=["CMS-1848-P"])
@patch("bib.federalregister.pfs_rules")
@patch("bib.connect")
def test_docket_filter_skips_other_known_dockets_without_calls(mc_connect, mc_pfs, _split, mc_fr, mc_client, mc_walk):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="o"))
    mc_connect.return_value = s
    mc_pfs.return_value = [_rule_doc()]
    api = _api()
    mc_client.return_value = api

    result = runner.invoke(app, ["fetch-pfs-comments", "--docket", "CMS-2019-0111"])

    assert result.exit_code == 0, result.output
    mc_fr.assert_not_called()
    api.resolve_docket.assert_not_called()
    mc_walk.assert_not_called()


@patch("bib.regulations_gov.walk_docket", return_value=WalkResult())
@patch("bib.regulations_gov.Client")
@patch("bib.translate.federal_register")
@patch("bib.federalregister.split_docket_ids", return_value=["CMS-1848-P"])
@patch("bib.federalregister.pfs_rules")
@patch("bib.connect")
def test_force_walks_sealed_docket(mc_connect, mc_pfs, _split, mc_fr, mc_client, mc_walk):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="o", sealed_at="2026-10-20T00:00:00Z"))
    mc_connect.return_value = s
    mc_pfs.return_value = [_rule_doc()]
    mc_fr.return_value = Rule(title="CY2027 PFS NPRM", url="https://www.federalregister.gov/d/2026-1")
    mc_client.return_value = _api()

    result = runner.invoke(app, ["fetch-pfs-comments", "--force"])

    assert result.exit_code == 0, result.output
    mc_walk.assert_called_once()
    assert mc_walk.call_args.kwargs["force"] is True


@patch("bib.regulations_gov.walk_docket", return_value=WalkResult(created=1))
@patch("bib.regulations_gov.discover_docket")
@patch("bib.regulations_gov.Client")
@patch("bib.connect")
def test_fetch_docket_comments_discovers_then_walks(mc_connect, mc_client, mc_disc, mc_walk):
    s = _store()
    mc_connect.return_value = s
    mc_client.return_value = _api()
    mc_disc.return_value = Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", fr_object_id="o", comment_end_date="2026-09-14")

    result = runner.invoke(app, ["fetch-docket-comments", "CMS-2026-2377", "--cms-id", "CMS-1848-P"])

    assert result.exit_code == 0, result.output
    mc_disc.assert_called_once()
    mc_walk.assert_called_once()
    assert s.docket_get("CMS-2026-2377") is not None


@patch("bib.regulations_gov.walk_docket")
@patch("bib.regulations_gov.Client")
@patch("bib.connect")
def test_fetch_docket_comments_sealed_notice(mc_connect, mc_client, mc_walk):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2019-0111", fr_object_id="o", sealed_at="2020-01-01T00:00:00Z"))
    mc_connect.return_value = s
    mc_client.return_value = _api()

    result = runner.invoke(app, ["fetch-docket-comments", "CMS-2019-0111"])

    assert result.exit_code == 0
    assert "sealed" in result.output
    mc_walk.assert_not_called()
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/cli/test_bib_fetch_sealed.py -q -p no:cacheprovider Expected: FAIL (no --force, walk_docket never called, resolve called).

  • Step 3: Implement

Replace fetch_docket_comments and fetch_pfs_comments in src/cli/bib.py:

def _walk_and_report(store, api, docket, *, attachments, limit, force, echo) -> None:
    from bib.dockets import quiet_days
    from bib.regulations_gov import walk_docket

    r = walk_docket(
        store, api, docket,
        attachments=attachments, limit=limit, force=force,
        quiet_days=quiet_days(), echo=echo,
    )
    echo(
        f"    {docket.id}: created={r.created} updated={r.updated} "
        f"unchanged={r.unchanged} clean={r.clean}"
        + (" — sealed" if r.sealed else "")
    )


@app.command(name="fetch-docket-comments")
def fetch_docket_comments(
    docket: str = typer.Argument(..., help="reg.gov docket id, e.g. CMS-2026-2377"),
    cms_id: str = typer.Option("", "--cms-id", help="Associate comments with a specific CMS rule tag."),
    limit: int = typer.Option(0, "--limit", "-n", help="Stop after N comments. 0 = all."),
    attachments: bool = typer.Option(False, "--attachments", help="Also download each comment's PDF/DOCX attachments."),
    sleep: float = typer.Option(1.3, "--sleep", help="Seconds between API calls (rate budget)."),
    force: bool = typer.Option(False, "--force", help="Walk from page 1 even if sealed / watermarked. Never unseals."),
) -> None:
    """Walk one docket's comments from its stored watermark; upsert as Source items.

    The first run discovers the docket's commentable FR document (one
    listing call) and stores it; later runs make no discovery calls.
    Sealed dockets are skipped unless --force.
    """
    from bib import connect
    from bib.regulations_gov import Client, discover_docket

    store = connect()
    with Client(sleep=sleep) as api:
        d = store.docket_get(docket)
        if d is None:
            d = discover_docket(api, docket, rule_cms_id=cms_id)
            store.docket_upsert(d)
        elif cms_id and not d.rule_cms_id:
            from dataclasses import replace

            d = replace(d, rule_cms_id=cms_id)
            store.docket_upsert(d)
        if d.sealed and not force:
            typer.echo(f"  {docket}: sealed {d.sealed_at[:10]} ({d.seal_reason}); skipping — use --force to re-walk")
            return
        if not d.fr_object_id:
            typer.echo(f"  {docket}: no commentable FR document found")
            return
        _walk_and_report(store, api, d, attachments=attachments, limit=limit, force=force, echo=typer.echo)


@app.command(name="fetch-pfs-comments")
def fetch_pfs_comments(
    since: str = typer.Option("2017-01-01", "--since"),
    until: str = typer.Option("", "--until"),
    attachments: bool = typer.Option(False, "--attachments"),
    per_docket_limit: int = typer.Option(0, "--per-docket-limit", help="Cap comments fetched per docket. 0 = unlimited."),
    sleep: float = typer.Option(1.3, "--sleep"),
    docket: str = typer.Option("", "--docket", help="Only pull this reg.gov docket (e.g. CMS-2026-2377); other dockets are skipped before any API call once known."),
    force: bool = typer.Option(False, "--force", help="Walk every docket from page 1, sealed or not. Never unseals."),
) -> None:
    """Discover every PFS proposed rule since *since* and pull new comments
    on each docket from its stored watermark.

    Sealed dockets (comment period closed + quiet period + an empty pull)
    cost nothing: no rule-metadata fetch, no resolve call, no walk.
    """
    from bib import connect
    from bib.federalregister import pfs_rules, split_docket_ids
    from bib.regulations_gov import Client, discover_docket
    from bib.tag import Tag
    from bib.translate import federal_register

    store = connect()
    rules = pfs_rules(since=since, until=until or None)
    typer.echo(f"==> {len(rules)} PFS rules since {since}")
    proposed = [r for r in rules if r.type == "Proposed Rule"]
    typer.echo(f"  {len(proposed)} proposed rules (the ones with comments)")

    with Client(sleep=sleep) as api:
        for doc in proposed:
            cms_ids = split_docket_ids(doc.dockets)
            known = {cid: store.docket_for_rule(cid) for cid in cms_ids}

            # Decide what this rule needs BEFORE touching the network.
            def _wanted(cid: str) -> bool:
                d = known[cid]
                if docket and d is not None and d.id != docket:
                    return False
                if d is not None and d.sealed and not force:
                    typer.echo(f"  {d.id}: sealed {d.sealed_at[:10]} ({d.seal_reason}); skipping")
                    return False
                return True

            todo = [cid for cid in cms_ids if _wanted(cid)]
            if not todo:
                continue

            try:
                rule = federal_register(
                    doc.html_url or f"https://www.federalregister.gov/documents/{doc.document_number}"
                )
            except Exception as e:  # noqa: BLE001
                typer.echo(f"  skipped rule meta: {e}")
                continue
            rule.add_tag(Tag.source("federal-register").label)
            rule.add_tag("module:pfs")
            for cid in cms_ids:
                rule.add_tag(f"cms-rule:{cid}")

            for cms_id in todo:
                d = known[cms_id]
                if d is None:
                    reg_docket = api.resolve_docket(cms_id)
                    if not reg_docket:
                        typer.echo(f"  skip {cms_id}: no reg.gov docket found")
                        continue
                    if docket and reg_docket != docket:
                        continue
                    d = discover_docket(api, reg_docket, rule_cms_id=cms_id)
                    store.docket_upsert(d)
                typer.echo(f"  {cms_id}{d.id} ({doc.publication_date})")
                rule.add_tag(f"reg-docket:{d.id}")
                store.upsert(rule)
                if not d.fr_object_id:
                    typer.echo(f"    {d.id}: no commentable FR document; skipping")
                    continue
                _walk_and_report(
                    store, api, d,
                    attachments=attachments, limit=per_docket_limit, force=force, echo=typer.echo,
                )

Update tests/cli/test_bib_exercise.py: TestFetchDocketComments and TestFetchPfsComments.test_full_loop currently drive api.iter_comments directly through a MagicMock store. Point them at the new shape: patch bib.regulations_gov.walk_docket (return WalkResult()) and bib.regulations_gov.discover_docket, and use a real in-memory Store for bib.connect. Keep test_no_rules, test_skip_bad_rule, test_no_docket (they still pass: resolve_docket returning None prints "skip").

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/cli/test_bib_fetch_sealed.py tests/cli/test_bib_exercise.py tests/cli/test_bib_deep.py -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/cli/bib.py tests/cli/test_bib_fetch_sealed.py tests/cli/test_bib_exercise.py
git commit -m "feat(cli): fetch commands walk from watermark, skip sealed dockets before any API call (refs #615)"

Task 9: Backfill skips sealed dockets (API + mirror)

Files:

  • Modify: src/bib/regulations_gov.py (backfill_details, backfill_from_mirror)
  • Modify: src/cli/bib.py (backfill_comments gains --force)
  • Test: tests/bib/test_backfill_sealed.py

Interfaces:

  • Produces: both backfills accept force: bool = False; when docket is sealed and not forced they return {"skipped_sealed": 1, "seen": 0, ...} before any listing or API call.

  • Step 1: Write the failing tests

# tests/bib/test_backfill_sealed.py
from __future__ import annotations

from unittest.mock import MagicMock

from bib.dockets import Docket
from bib.regulations_gov import backfill_details, backfill_from_mirror
from bib.store import Store

D = "CMS-2019-0111"


def _sealed_store() -> Store:
    s = Store(":memory:", storage_dir="/tmp/nope")
    s.docket_upsert(Docket(id=D, sealed_at="2020-06-01T00:00:00Z", seal_reason="manual"))
    return s


def test_api_backfill_skips_sealed_without_calls():
    s = _sealed_store()
    api = MagicMock()
    stats = backfill_details(s, api, docket=D)
    assert stats["skipped_sealed"] == 1 and stats["seen"] == 0
    api.get_comment_detail.assert_not_called()


def test_mirror_backfill_skips_sealed_without_listing():
    s = _sealed_store()
    m = MagicMock()
    stats = backfill_from_mirror(s, m, docket=D)
    assert stats["skipped_sealed"] == 1 and stats["seen"] == 0
    m.comment_ids.assert_not_called()
    m.attachment_keys.assert_not_called()


def test_force_runs_sealed_mirror_backfill():
    s = _sealed_store()
    m = MagicMock()
    m.comment_ids.return_value = []
    m.attachment_keys.return_value = {}
    stats = backfill_from_mirror(s, m, docket=D, force=True)
    assert "skipped_sealed" not in stats
    m.comment_ids.assert_called_once_with(D)
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/bib/test_backfill_sealed.py -q -p no:cacheprovider Expected: FAIL — KeyError: 'skipped_sealed' / TypeError on force.

  • Step 3: Implement

In both functions add force: bool = False to the keyword parameters and, as the first statement after the docstring:

    if docket and not force:
        d = store.docket_get(docket)
        if d is not None and d.sealed:
            log.info("%s sealed %s (%s); backfill skipped", docket, d.sealed_at[:10], d.seal_reason)
            print(f"{docket}: sealed {d.sealed_at[:10]} ({d.seal_reason}); skipped — use --force", flush=True)
            return {"skipped_sealed": 1, "seen": 0, "enriched": 0, "created": 0, "attached": 0, "errors": 0}

In cli/bib.py::backfill_comments add force: bool = typer.Option(False, "--force", help="Run even if the docket is sealed.") and pass force=force to both calls.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/bib/test_backfill_sealed.py tests/bib/test_regulations_gov_mirror.py tests/bib/test_regulations_gov_exercise.py tests/cli/test_bib_exercise.py -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/bib/regulations_gov.py src/cli/bib.py tests/bib/test_backfill_sealed.py
git commit -m "feat(bib): backfill-comments skips sealed dockets; --force override (refs #615)"

Task 10: Extract currency by mtime — source_paths, is_current, walker skip without reads

Files:

  • Modify: src/rex/comments/combine.py (add source_paths, is_current)
  • Modify: src/rex/comments/walker.py (walk_and_extract, _collect_dirs, _one)
  • Test: tests/rex/comments/test_currency.py; update tests/rex/comments/test_walker.py::test_on_extracted_called_for_skipped_dirs

Interfaces:

  • Produces: combine.source_paths(comment_dir) -> list[Path] (files that feed extraction: not combined.md, not *.md, not *.tmp, not dotfiles); combine.is_current(comment_dir) -> bool (combined.md exists and its mtime ≥ every source file's mtime); walker.walk_and_extract(..., skip_dockets: set[str] = frozenset(), reattach: bool = False). Skipped dirs are never read; on_extracted fires for them only when reattach=True.

  • Step 1: Write the failing tests

# tests/rex/comments/test_currency.py
from __future__ import annotations

import os
from pathlib import Path

from rex.comments.combine import is_current, source_paths
from rex.comments.walker import walk_and_extract


def _dir(tmp_path: Path, docket="CMS-2024-0001", cid="CMS-2024-0001-0001") -> Path:
    d = tmp_path / docket / cid
    d.mkdir(parents=True)
    return d


def test_source_paths_excludes_outputs(tmp_path: Path):
    d = _dir(tmp_path)
    (d / "attachment_1.pdf").write_bytes(b"x")
    (d / "attachment_1.pdf.md").write_text("sibling")
    (d / "combined.md").write_text("c")
    (d / "combined.md.tmp").write_text("t")
    (d / ".hidden").write_text("h")
    assert [p.name for p in source_paths(d)] == ["attachment_1.pdf"]


def test_is_current_false_without_combined(tmp_path: Path):
    d = _dir(tmp_path)
    (d / "attachment_1.pdf").write_bytes(b"x")
    assert is_current(d) is False


def test_is_current_true_when_combined_newer(tmp_path: Path):
    d = _dir(tmp_path)
    (d / "attachment_1.pdf").write_bytes(b"x")
    os.utime(d / "attachment_1.pdf", ns=(1_000, 1_000))
    (d / "combined.md").write_text("c")
    assert is_current(d) is True


def test_is_current_false_when_source_newer(tmp_path: Path):
    d = _dir(tmp_path)
    (d / "combined.md").write_text("c")
    os.utime(d / "combined.md", ns=(1_000, 1_000))
    (d / "attachment_2.pdf").write_bytes(b"new")
    assert is_current(d) is False


def test_walker_reextracts_stale_dir(tmp_path: Path, monkeypatch):
    d = _dir(tmp_path)
    (d / "combined.md").write_text("---\ncomment_id: x\n---\n\nold\n")
    os.utime(d / "combined.md", ns=(1_000, 1_000))
    (d / "attachment_1.pdf").write_bytes(b"%PDF")  # newer than combined.md
    called = []
    monkeypatch.setattr("rex.comments.walker.extract_comment", lambda cdir, **kw: called.append(cdir) or (cdir / "combined.md"))
    stats = walk_and_extract(tmp_path, inline_body_lookup=lambda _c: "", workers=1)
    assert stats["written"] == 1 and called == [d]


def test_walker_skips_current_dir_without_reading(tmp_path: Path, monkeypatch):
    d = _dir(tmp_path)
    (d / "attachment_1.pdf").write_bytes(b"%PDF")
    os.utime(d / "attachment_1.pdf", ns=(1_000, 1_000))
    (d / "combined.md").write_text("---\ncomment_id: x\n---\n\nbody\n")
    reads = []
    real_read_text = Path.read_text

    def spy(self, *a, **kw):
        if self.name == "combined.md":
            reads.append(self)
        return real_read_text(self, *a, **kw)

    monkeypatch.setattr(Path, "read_text", spy)
    seen = []
    stats = walk_and_extract(tmp_path, inline_body_lookup=lambda _c: "", workers=1, on_extracted=lambda cid, _d: seen.append(cid))
    assert stats == {"written": 0, "skipped": 1, "failed": 0}
    assert reads == []          # skipped dirs are not read
    assert seen == []           # and the callback does not fire without --reattach


def test_walker_reattach_fires_callback_for_skipped(tmp_path: Path):
    d = _dir(tmp_path)
    (d / "attachment_1.pdf").write_bytes(b"%PDF")
    os.utime(d / "attachment_1.pdf", ns=(1_000, 1_000))
    (d / "combined.md").write_text("---\ncomment_id: x\ndocket_id: y\n---\n\n## attachment_1.pdf\n\nbody\n")
    seen = []
    walk_and_extract(tmp_path, inline_body_lookup=lambda _c: "", workers=1, reattach=True, on_extracted=lambda cid, _d: seen.append(cid))
    assert seen == [d.name]
    assert (d / "attachment_1.pdf.md").is_file()  # siblings derived on reattach


def test_walker_skip_dockets(tmp_path: Path):
    d = _dir(tmp_path)
    (d / "attachment_1.pdf").write_bytes(b"%PDF")
    stats = walk_and_extract(tmp_path, inline_body_lookup=lambda _c: "", workers=1, skip_dockets={"CMS-2024-0001"})
    assert stats == {"written": 0, "skipped": 0, "failed": 0, "skipped_sealed": 1}
    assert not (d / "combined.md").exists()

Update tests/rex/comments/test_walker.py::test_on_extracted_called_for_skipped_dirs to pass reattach=True (its docstring becomes "with reattach=True, pre-existing combined.md still gets the callback…"). Also in test_walk_and_extract_skips_existing, after writing the pre-existing combined.md, add os.utime(a / "attachment_1.pdf", ns=(1_000, 1_000)) so the pre-existing combined.md counts as current (import os).

  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/rex/comments/test_currency.py -q -p no:cacheprovider Expected: FAIL — ImportError: cannot import name 'is_current'.

  • Step 3: Implement

In src/rex/comments/combine.py (public, after parse_combined):

def source_paths(comment_dir: Path) -> list[Path]:
    """Files that feed extraction: attachments and bodies, never our own
    outputs (``combined.md``, ``*.md`` siblings, ``*.tmp``) or dotfiles."""
    out: list[Path] = []
    for p in comment_dir.iterdir():
        n = p.name
        if not p.is_file() or n.startswith(".") or n == _FILENAME:
            continue
        if n.endswith(".md") or n.endswith(".tmp"):
            continue
        out.append(p)
    return sorted(out)


def is_current(comment_dir: Path) -> bool:
    """True when ``combined.md`` exists and is at least as new as every
    source file — a stat-only check, no reads."""
    out = comment_dir / _FILENAME
    try:
        out_m = out.stat().st_mtime_ns
    except FileNotFoundError:
        return False
    for p in source_paths(comment_dir):
        if p.stat().st_mtime_ns > out_m:
            return False
    return True

Make _attachment_paths delegate: return source_paths(comment_dir) (this also stops force re-extraction from feeding *.md siblings back into the extractor).

In src/rex/comments/walker.py:

from rex.comments.combine import derive_siblings_from_combined, extract_comment, is_current


def walk_and_extract(
    root: Path,
    *,
    inline_body_lookup: Callable[[str], str],
    docket: str | None = None,
    limit: int | None = None,
    workers: int = 8,
    force: bool = False,
    on_extracted: Callable[[str, Path], None] | None = None,
    skip_dockets: set[str] | frozenset[str] = frozenset(),
    reattach: bool = False,
) -> dict[str, int]:
    """...(existing docstring, amended:)

    A dir is *current* when its combined.md is newer than every source
    file (``combine.is_current``); current dirs are skipped without being
    read. *on_extracted* fires for newly written dirs; with *reattach*
    it also fires for skipped dirs (after deriving any missing sibling
    MDs), which is the repair path for bib notes. *skip_dockets* names
    docket dirs to ignore entirely (sealed dockets), unless *force*.
    """
    candidate_dirs, skipped_dirs, sealed = _collect_dirs(
        root, docket=docket, limit=limit, force=force, skip_dockets=skip_dockets
    )
    stats = {"written": 0, "skipped": len(skipped_dirs), "failed": 0}
    if sealed:
        stats["skipped_sealed"] = sealed
    ...  # pool section unchanged
    if on_extracted and reattach:
        for cdir in skipped_dirs:
            ...  # unchanged body
    return stats


def _collect_dirs(root, *, docket, limit, force, skip_dockets=frozenset()):
    todo, skipped, sealed = [], [], 0
    for docket_dir in sorted(root.iterdir()):
        if not docket_dir.is_dir():
            continue
        if docket and docket_dir.name != docket:
            continue
        if docket_dir.name in skip_dockets and not force:
            sealed += 1
            continue
        for cdir in sorted(docket_dir.iterdir()):
            if not cdir.is_dir():
                continue
            if not force and is_current(cdir):
                skipped.append(cdir)
                continue
            todo.append(cdir)
            if limit and len(todo) >= limit:
                return todo, skipped, sealed
    return todo, skipped, sealed


def _one(comment_dir, inline_body_lookup, force):
    if not force and is_current(comment_dir):
        return "skipped"
    ...  # unchanged; but call extract_comment(comment_dir, inline_body=body, force=True)

_one must pass force=True to extract_comment, because the walker has already decided the dir is stale and extract_comment's own "exists → return" guard would otherwise refuse to rewrite it.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/rex/comments -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/rex/comments/combine.py src/rex/comments/walker.py tests/rex/comments/test_currency.py tests/rex/comments/test_walker.py
git commit -m "feat(comments): mtime-based extract currency; skipped dirs are never read; skip_dockets + reattach (refs #615)"

Task 11: stack comments — extract with sealed skip + lazy body lookup; dockets / seal / unseal

Files:

  • Modify: src/cli/comments.py
  • Test: tests/cli/test_comments_dockets.py; extend tests/cli/test_comments.py

Interfaces:

  • Consumes: Store.dockets/docket_get/docket_upsert/docket_seal/docket_unseal/sealed_dockets, walk_and_extract(skip_dockets=, reattach=), discover_docket, docket_counts, _rule_tag_for_docket.

  • Produces: stack comments extract [--reattach] [--force] (sealed dockets skipped, note printed); stack comments dockets [--discover]; stack comments seal <ID> [--reason TEXT]; stack comments unseal <ID>.

  • Step 1: Write the failing tests

# tests/cli/test_comments_dockets.py
from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import fitz
from typer.testing import CliRunner

from bib.dockets import Docket
from bib.store import Store
from cli.comments import app

runner = CliRunner()


def _store() -> Store:
    return Store(":memory:", storage_dir="/tmp/nope")


def _seed_dir(root: Path, docket: str) -> Path:
    cdir = root / docket / f"{docket}-0001"
    cdir.mkdir(parents=True)
    doc = fitz.open()
    doc.new_page().insert_text((50, 72), "Long body content " * 10)
    doc.save(str(cdir / "attachment_1.pdf"))
    doc.close()
    return cdir


@patch("bib.connect")
def test_dockets_table_lists_rows(mc_connect):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2019-0111", rule_cms_id="CMS-1693-P", comment_end_date="2019-09-27", sealed_at="2020-01-01T00:00:00Z", seal_reason="manual"))
    s.docket_upsert(Docket(id="CMS-2026-2377", rule_cms_id="CMS-1848-P", comment_end_date="2026-09-14"))
    mc_connect.return_value = s
    result = runner.invoke(app, ["dockets"])
    assert result.exit_code == 0, result.output
    assert "CMS-2019-0111" in result.output and "sealed" in result.output
    assert "CMS-2026-2377" in result.output and "open" in result.output


@patch("bib.connect")
def test_seal_and_unseal(mc_connect):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2019-0111"))
    mc_connect.return_value = s
    assert runner.invoke(app, ["seal", "CMS-2019-0111", "--reason", "historical"]).exit_code == 0
    assert s.docket_get("CMS-2019-0111").seal_reason == "historical"
    assert runner.invoke(app, ["unseal", "CMS-2019-0111"]).exit_code == 0
    assert not s.docket_get("CMS-2019-0111").sealed


@patch("bib.connect")
def test_seal_unknown_docket_fails(mc_connect):
    mc_connect.return_value = _store()
    result = runner.invoke(app, ["seal", "CMS-0000-0000"])
    assert result.exit_code == 1
    assert "unknown docket" in result.output


@patch("bib.regulations_gov.discover_docket")
@patch("bib.regulations_gov.Client")
@patch("bib.connect")
def test_dockets_discover_populates_from_tags(mc_connect, mc_client, mc_disc):
    from bib.item import Source

    s = _store()
    it = Source(title="c", url="https://www.regulations.gov/comment/CMS-2019-0111-1")
    it.add_tag("reg-docket:CMS-2019-0111")
    it.add_tag("rule:CMS-1693-P")
    s.create(it)
    mc_connect.return_value = s
    api = MagicMock(); api.__enter__ = MagicMock(return_value=api); api.__exit__ = MagicMock(return_value=False)
    mc_client.return_value = api
    mc_disc.return_value = Docket(id="CMS-2019-0111", rule_cms_id="CMS-1693-P", fr_object_id="o", comment_end_date="2019-09-27")

    result = runner.invoke(app, ["dockets", "--discover"])

    assert result.exit_code == 0, result.output
    mc_disc.assert_called_once_with(api, "CMS-2019-0111", rule_cms_id="CMS-1693-P")
    assert s.docket_get("CMS-2019-0111").comment_end_date == "2019-09-27"


@patch("bib.connect")
def test_extract_skips_sealed_docket(mc_connect, tmp_path: Path):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2024-0001", sealed_at="2025-01-01T00:00:00Z", seal_reason="manual"))
    mc_connect.return_value = s
    cdir = _seed_dir(tmp_path, "CMS-2024-0001")
    result = runner.invoke(app, ["extract", "--root", str(tmp_path), "--workers", "1", "--no-attach"])
    assert result.exit_code == 0, result.output
    assert "skipped_sealed: 1" in result.output
    assert not (cdir / "combined.md").exists()


@patch("bib.connect")
def test_extract_force_runs_sealed_docket(mc_connect, tmp_path: Path):
    s = _store()
    s.docket_upsert(Docket(id="CMS-2024-0001", sealed_at="2025-01-01T00:00:00Z"))
    mc_connect.return_value = s
    cdir = _seed_dir(tmp_path, "CMS-2024-0001")
    result = runner.invoke(app, ["extract", "--root", str(tmp_path), "--workers", "1", "--no-attach", "--force"])
    assert result.exit_code == 0, result.output
    assert (cdir / "combined.md").is_file()
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/cli/test_comments_dockets.py -q -p no:cacheprovider Expected: FAIL — No such command 'dockets'.

  • Step 3: Implement

Rewrite _build_bib_helpers to look bodies up lazily (the URL index from Task 2 makes per-call lookups cheap) and return the store so extract can read seals:

def _build_bib_helpers(use_bib: bool, attach: bool):
    """Return ``(body_lookup, attach_callback, store_or_None)``.

    ``body_lookup`` queries bib per comment id (``items.url`` is indexed)
    under a lock — worker threads share one connection. Nothing is
    loaded up front, so a run that extracts nothing reads nothing.
    """
    if not use_bib:
        return (lambda _cid: ""), None, None

    import threading

    from bib import connect

    store = connect()
    con = store._con()  # noqa: SLF001
    lock = threading.Lock()

    def _row(comment_id: str):
        with lock:
            return con.execute(
                "SELECT key, abstract FROM items WHERE url = ?",
                (f"https://www.regulations.gov/comment/{comment_id}",),
            ).fetchone()

    def body_lookup(comment_id: str) -> str:
        row = _row(comment_id)
        return (row["abstract"] or "") if row else ""

    if not attach:
        return body_lookup, None, store

    from rex.comments.render import render_combined_md

    items_with_note: set[str] = {
        row[0]
        for row in con.execute(
            "SELECT i.key FROM notes n JOIN items i ON n.item_id = i.id WHERE n.title = ?",
            (NOTE_TITLE,),
        )
    }

    def attach_callback(comment_id: str, comment_dir: Path) -> None:
        row = _row(comment_id)
        if not row:
            return
        item_key = row["key"]
        if item_key in items_with_note:
            return
        combined = comment_dir / "combined.md"
        if not combined.is_file():
            return
        try:
            html = render_combined_md(combined.read_text(encoding="utf-8"))
            with lock:
                store.attach_note(item_key, html, title=NOTE_TITLE)
            items_with_note.add(item_key)
        except Exception as e:  # noqa: BLE001
            log.warning("attach_note failed for %s: %s", item_key, e)

    return body_lookup, attach_callback, store

Note: Store._con opens sqlite with the default check_same_thread=True, so worker threads calling body_lookup would raise. For file-backed stores open a second, thread-tolerant read connection for the lookups:

    if store._db_path == ":memory:":
        con = store._con()  # tests only — a fresh connection would be an empty DB
    else:
        con = sqlite3.connect(store._db_path, check_same_thread=False)
        con.row_factory = sqlite3.Row

(import sqlite3; attach_note still goes through store, which is main-thread only.) Keep _bib_lookup_factory returning body from the new triple. In the in-memory case a worker-thread lookup raises ProgrammingError, which _one already catches and logs (body falls back to ""), so the extract tests keep passing.

extract command: add reattach: bool = typer.Option(False, "--reattach", help="Also attach combined.md notes for dirs that were already current (repair)."); then:

    lookup, attach_cb, store = _build_bib_helpers(use_bib, attach=use_bib and attach)
    skip = set(store.sealed_dockets()) if (store is not None and not force) else set()
    stats = walk_and_extract(
        root, inline_body_lookup=lookup, docket=docket, limit=limit or None,
        workers=workers, force=force, on_extracted=attach_cb,
        skip_dockets=skip, reattach=reattach,
    )
    if docket and docket in skip:
        typer.echo(f"  {docket}: sealed; skipped — use --force")
    for k, v in stats.items():
        typer.echo(f"  {k}: {v}")

New commands:

@app.command()
def dockets(
    discover: bool = typer.Option(False, "--discover", help="Populate rows for every reg-docket: tag in bib that has no dockets row (one reg.gov listing call each)."),
) -> None:
    """List dockets: id, rule, close date, watermark, counts, seal."""
    from bib import connect
    from bib.regulations_gov import docket_counts

    store = connect()
    if discover:
        from bib.regulations_gov import Client, _rule_tag_for_docket, discover_docket

        tagged = [
            r[0].split(":", 1)[1]
            for r in store._con().execute("SELECT name FROM tags WHERE name LIKE 'reg-docket:%' ORDER BY name")  # noqa: SLF001
        ]
        missing = [d for d in tagged if store.docket_get(d) is None]
        with Client(sleep=1.3) as api:
            for d in missing:
                row = discover_docket(api, d, rule_cms_id=_rule_tag_for_docket(store, d))
                store.docket_upsert(row)
                typer.echo(f"  discovered {d}: closes {row.comment_end_date or '?'} rule {row.rule_cms_id or '?'}")
    rows = store.dockets()
    if not rows:
        typer.echo("no dockets recorded — run `stack comments dockets --discover`")
        return
    typer.echo(f"{'docket':<16}{'rule':<12}{'closes':<12}{'comments':>9}{'enriched':>9}  {'watermark':<20} state")
    for d in rows:
        c = docket_counts(store, d.id)
        state = f"sealed {d.sealed_at[:10]} ({d.seal_reason})" if d.sealed else "open"
        typer.echo(
            f"{d.id:<16}{d.rule_cms_id:<12}{d.comment_end_date:<12}"
            f"{c['comments']:>9}{c['enriched']:>9}  {d.pull_watermark[:19]:<20} {state}"
        )


@app.command()
def seal(
    docket: str = typer.Argument(..., help="reg.gov docket id"),
    reason: str = typer.Option("manual", "--reason"),
) -> None:
    """Mark a docket complete: every stage skips it until unsealed or --force."""
    from bib import connect
    from bib.regulations_gov import docket_counts

    store = connect()
    if store.docket_get(docket) is None:
        typer.echo(f"unknown docket {docket} — run `stack comments dockets --discover` first")
        raise typer.Exit(1)
    store.docket_seal(docket, reason=reason, counts=docket_counts(store, docket))
    typer.echo(f"sealed {docket} ({reason})")


@app.command()
def unseal(docket: str = typer.Argument(..., help="reg.gov docket id")) -> None:
    """Reopen a sealed docket."""
    from bib import connect

    store = connect()
    if store.docket_get(docket) is None:
        typer.echo(f"unknown docket {docket}")
        raise typer.Exit(1)
    store.docket_unseal(docket)
    typer.echo(f"unsealed {docket}")

Update the module docstring's command list.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/cli/test_comments_dockets.py tests/cli/test_comments.py -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/cli/comments.py tests/cli/test_comments_dockets.py tests/cli/test_comments.py
git commit -m "feat(cli): stack comments dockets/seal/unseal; extract skips sealed dockets, lazy bib lookup, --reattach (refs #615)"

Task 12: llm migrations — index_state.fingerprint, index_docket_state

Files:

  • Modify: src/llm/migrate.py
  • Test: tests/llm/test_migrate.py

Interfaces:

  • Produces: INDEX_STATE_DDL gains fingerprint TEXT NOT NULL DEFAULT ''; INDEX_STATE_ALTER = "ALTER TABLE index_state ADD COLUMN IF NOT EXISTS fingerprint TEXT NOT NULL DEFAULT ''"; INDEX_DOCKET_STATE_DDL; migrate(engine) runs all three.

  • Step 1: Write the failing tests (append to tests/llm/test_migrate.py::TestDdl)

    def test_fingerprint_column_and_docket_state(self):
        assert "fingerprint" in migrate.INDEX_STATE_DDL
        assert "ADD COLUMN IF NOT EXISTS fingerprint" in migrate.INDEX_STATE_ALTER
        assert "CREATE TABLE IF NOT EXISTS index_docket_state" in migrate.INDEX_DOCKET_STATE_DDL
        assert "PRIMARY KEY (collection, docket)" in migrate.INDEX_DOCKET_STATE_DDL

    def test_migrate_runs_alter_and_docket_state(self):
        engine = MagicMock()
        conn = engine.begin.return_value.__enter__.return_value
        migrate.migrate(engine)
        executed = " ".join(str(call.args[0]) for call in conn.execute.call_args_list)
        assert "ADD COLUMN IF NOT EXISTS fingerprint" in executed
        assert "index_docket_state" in executed
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/llm/test_migrate.py -q -p no:cacheprovider Expected: FAIL — AttributeError: module 'llm.migrate' has no attribute 'INDEX_STATE_ALTER'.

  • Step 3: Implement
INDEX_STATE_DDL = """
CREATE TABLE IF NOT EXISTS index_state (
    item_key     TEXT        NOT NULL,
    collection   TEXT        NOT NULL,
    content_hash TEXT        NOT NULL,
    chunk_count  INTEGER     NOT NULL DEFAULT 0,
    indexed_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    fingerprint  TEXT        NOT NULL DEFAULT '',
    PRIMARY KEY (item_key, collection)
)
"""

# Existing databases predate the column.
INDEX_STATE_ALTER = (
    "ALTER TABLE index_state ADD COLUMN IF NOT EXISTS fingerprint TEXT NOT NULL DEFAULT ''"
)

# A sealed docket that was fully indexed under a given seal: the iterator
# does not even list its items until the seal changes (unseal/reseal
# writes a new sealed_at in bib, which no longer matches).
INDEX_DOCKET_STATE_DDL = """
CREATE TABLE IF NOT EXISTS index_docket_state (
    collection TEXT        NOT NULL,
    docket     TEXT        NOT NULL,
    sealed_at  TEXT        NOT NULL,
    indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (collection, docket)
)
"""


def migrate(engine: Engine) -> None:
    """Create llm-owned tables/columns. Safe to run on every start."""
    with engine.begin() as conn:
        conn.execute(text(INDEX_STATE_DDL))
        conn.execute(text(INDEX_STATE_ALTER))
        conn.execute(text(INDEX_DOCKET_STATE_DDL))
  • Step 4: Run to verify passuv run --no-sync pytest tests/llm/test_migrate.py -q -p no:cacheprovider → pass.

  • Step 5: Commit

git add src/llm/migrate.py tests/llm/test_migrate.py
git commit -m "feat(llm): index_state.fingerprint + index_docket_state migrations (refs #615)"

Task 13: DocRef + lazy iterators + lazy Zotero snapshot

Files:

  • Modify: src/llm/source.py
  • Test: tests/llm/test_source_refs.py; extend tests/llm/test_source.py::TestCommentDocs only if a helper signature changes (it should not — iter_comment_docs/iter_corpus_docs/iter_rule_docs stay and become ref.load() loops).

Interfaces:

  • Consumes: bib.dockets.fingerprint_files, Store.sealed_dockets.

  • Produces:

    • DocRef(key, collection, docket, fingerprint, load: Callable[[], Doc | None]) (frozen dataclass; load() returns None when the item has no text).
    • iter_comment_refs(store, *, docket="", root=None, skip_dockets=frozenset()) -> Iterator[DocRef] — one SQL query for all rows (key, url, date, title, updated_at, year via correlated subquery), newest first; fingerprint = fingerprint_files(combined.md + attachment files) + "|" + updated_at.
    • iter_rule_refs(store, *, keys=(), tag="") -> Iterator[DocRef] — fingerprint = fr_anchor_docs.sha256 when present else fingerprint_files(attachment paths) + "|" + updated_at.
    • iter_corpus_refs(store, *, tag="", zotero=None) -> Iterator[DocRef] — keys selected in SQL excluding doctype:comment; fingerprint = updated_at + "|" + fingerprint_files(bib attachment paths).
    • ZoteroPdfIndex.lazy(sqlite_path, storage_dir, tmp_dir) -> ZoteroPdfIndex — no copy until the first pdfs_for(); copies only when the source mtime is newer than an existing snapshot.
    • iter_comment_docs / iter_rule_docs / iter_corpus_docs keep their signatures and yield ref.load() for each ref (skipping None).
  • Step 1: Write the failing tests

# tests/llm/test_source_refs.py
"""llm.source — lazy DocRefs: fingerprints without loads, docket skips,
lazy Zotero snapshot."""

from __future__ import annotations

import os
from pathlib import Path

import pytest

from bib.item import Item, Rule
from bib.store import Store
from llm.source import DocRef, ZoteroPdfIndex, iter_comment_refs, iter_corpus_refs, iter_rule_refs

DOCKET = "CMS-2019-0111"
CID = f"{DOCKET}-0042"
COMBINED = f"---\ncomment_id: {CID}\ndocket_id: {DOCKET}\n---\n\nWe object.\n"


@pytest.fixture
def store(tmp_path):
    s = Store(":memory:", storage_dir=tmp_path / "storage")
    key = s.create(Item(item_type="report", title="A comment", url=f"https://www.regulations.gov/comment/{CID}", abstract="Inline.", date_published="2019-09-27"))
    for tag in ("doctype:comment", "year:2019", f"reg-docket:{DOCKET}"):
        s.add_tag(key, tag)
    s._comment_key = key
    return s


@pytest.fixture
def root(tmp_path):
    d = tmp_path / DOCKET / CID
    d.mkdir(parents=True)
    (d / "combined.md").write_text(COMBINED)
    return tmp_path


class TestCommentRefs:
    def test_ref_has_fingerprint_and_lazy_load(self, store, root, monkeypatch):
        reads = []
        real = Path.read_text
        monkeypatch.setattr(Path, "read_text", lambda self, *a, **k: reads.append(self) or real(self, *a, **k))
        refs = list(iter_comment_refs(store, docket=DOCKET, root=root))
        assert len(refs) == 1
        r = refs[0]
        assert isinstance(r, DocRef)
        assert (r.key, r.collection, r.docket) == (store._comment_key, "comments", DOCKET)
        assert len(r.fingerprint) > 64 and "|" in r.fingerprint
        assert reads == []  # nothing read yet
        doc = r.load()
        assert "We object" in doc.text
        assert doc.metadata["year"] == "2019" and doc.metadata["docket"] == DOCKET
        assert reads  # load() read combined.md

    def test_fingerprint_changes_with_new_attachment(self, store, root):
        f1 = next(iter_comment_refs(store, docket=DOCKET, root=root)).fingerprint
        (root / DOCKET / CID / "attachment_1.pdf").write_bytes(b"%PDF")
        f2 = next(iter_comment_refs(store, docket=DOCKET, root=root)).fingerprint
        assert f1 != f2

    def test_fingerprint_changes_with_updated_at(self, store, root):
        f1 = next(iter_comment_refs(store, docket=DOCKET, root=root)).fingerprint
        store._con().execute("UPDATE items SET updated_at='2030-01-01T00:00:00Z' WHERE key=?", (store._comment_key,))
        f2 = next(iter_comment_refs(store, docket=DOCKET, root=root)).fingerprint
        assert f1 != f2

    def test_skip_dockets_excludes_rows(self, store, root):
        assert list(iter_comment_refs(store, root=root, skip_dockets={DOCKET})) == []

    def test_unextracted_load_falls_back_to_abstract(self, store, tmp_path):
        r = next(iter_comment_refs(store, docket=DOCKET, root=tmp_path))
        assert r.load().text == "Inline."

    def test_empty_comment_load_returns_none(self, store, tmp_path):
        store._con().execute("UPDATE items SET abstract='' WHERE key=?", (store._comment_key,))
        r = next(iter_comment_refs(store, docket=DOCKET, root=tmp_path))
        assert r.load() is None

    def test_single_query_for_year(self, store, root, monkeypatch):
        """No per-comment year lookups: exactly one SELECT on items for the listing."""
        calls: list[str] = []
        store._con().set_trace_callback(calls.append)
        list(iter_comment_refs(store, docket=DOCKET, root=root))
        store._con().set_trace_callback(None)
        selects = [c for c in calls if c.lstrip().upper().startswith("SELECT")]
        assert len(selects) == 1


class TestRuleRefs:
    def test_fingerprint_from_anchor_sha(self, store):
        key = store.create(Rule(title="R", url="https://fr/1", document_number="2019-1"))
        store._con().execute(
            "INSERT INTO fr_anchor_docs (item_key, document_number, html_url, start_page, end_page, fr_volume, sha256) VALUES (?,?,?,?,?,?,?)",
            (key, "2019-1", "https://fr/1", 1, 2, 84, "abc"),
        )
        store._con().execute("INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) VALUES (?,?,?,?,?)", (key, 1, 1, 1, "Para one."))
        refs = list(iter_rule_refs(store))
        assert [r.key for r in refs] == [key]
        assert refs[0].fingerprint == "anchors:abc"
        assert refs[0].collection == "rules" and refs[0].docket is None
        assert refs[0].load().text == "Para one."


class TestCorpusRefs:
    def test_excludes_comments_and_is_lazy(self, store):
        key = store.create(Item(item_type="report", title="Report", url="https://x/r", abstract="Body."))
        refs = list(iter_corpus_refs(store))
        assert [r.key for r in refs] == [key]
        assert refs[0].collection == "corpus"
        assert refs[0].load().metadata["kind"] == "corpus"

    def test_zotero_not_consulted_until_load(self, store, tmp_path):
        store.create(Item(item_type="report", title="Report", url="https://x/r", abstract="Body."))
        calls = []

        class Z(ZoteroPdfIndex):
            def pdfs_for(self, key):
                calls.append(key)
                return []

        refs = list(iter_corpus_refs(store, zotero=Z({})))
        assert calls == []
        refs[0].load()
        assert calls


class TestLazyZotero:
    def test_no_copy_until_first_lookup(self, tmp_path):
        src = tmp_path / "zotero.sqlite"
        import sqlite3

        con = sqlite3.connect(src)
        con.executescript(
            "CREATE TABLE items(itemID INTEGER, key TEXT); CREATE TABLE itemAttachments(itemID INTEGER, parentItemID INTEGER, path TEXT); CREATE TABLE deletedItems(itemID INTEGER);"
        )
        con.close()
        snap_dir = tmp_path / "snap"
        z = ZoteroPdfIndex.lazy(src, tmp_path / "storage", snap_dir)
        assert not (snap_dir / "zotero.sqlite").exists()
        assert z.pdfs_for("ABCD1234") == []
        assert (snap_dir / "zotero.sqlite").exists()
        m1 = (snap_dir / "zotero.sqlite").stat().st_mtime_ns
        z2 = ZoteroPdfIndex.lazy(src, tmp_path / "storage", snap_dir)
        z2.pdfs_for("ABCD1234")
        assert (snap_dir / "zotero.sqlite").stat().st_mtime_ns == m1  # source unchanged → no recopy
        bump = src.stat().st_mtime_ns + 10**9
        os.utime(src, ns=(bump, bump))  # source now newer than the snapshot
        z3 = ZoteroPdfIndex.lazy(src, tmp_path / "storage", snap_dir)
        z3.pdfs_for("ABCD1234")
        assert (snap_dir / "zotero.sqlite").stat().st_mtime_ns != m1
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/llm/test_source_refs.py -q -p no:cacheprovider Expected: FAIL — ImportError: cannot import name 'DocRef'.

  • Step 3: Implement

Add to src/llm/source.py:

from dataclasses import dataclass
from typing import Callable

from bib.dockets import fingerprint_files


@dataclass(frozen=True)
class DocRef:
    """A document the indexer *may* need: enough to decide (key +
    fingerprint) without building it. ``load()`` does the expensive part
    and returns ``None`` when the item has no text."""

    key: str
    collection: str
    docket: str | None
    fingerprint: str
    load: Callable[[], "Doc | None"]


# ── comments ──


def _comment_listing(store: Store, docket: str = "") -> list[sqlite3.Row]:
    """One query: key, comment_id, date, title, updated_at, year — newest first."""
    pattern = f"{_COMMENT_URL_PREFIX}{docket}-%" if docket else f"{_COMMENT_URL_PREFIX}%"
    return (
        store._con()
        .execute(
            "SELECT i.key, i.url, COALESCE(i.date_published,'') AS date, "
            "COALESCE(i.title,'') AS title, COALESCE(i.updated_at,'') AS updated_at, "
            "COALESCE((SELECT t.name FROM tags t JOIN item_tags it ON it.tag_id = t.id "
            "  WHERE it.item_id = i.id AND t.name LIKE 'year:%' LIMIT 1), '') AS year "
            "FROM items i WHERE i.url LIKE ? "
            "ORDER BY i.date_published DESC, i.key",
            (pattern,),
        )
        .fetchall()
    )


def iter_comment_refs(
    store: Store,
    *,
    docket: str = "",
    root: Path | None = None,
    skip_dockets: set[str] | frozenset[str] = frozenset(),
) -> Iterator[DocRef]:
    """One DocRef per comment, newest first. Fingerprint = file stats of
    combined.md + attachments + the item's updated_at; no file is read."""
    from rex.comments.combine import parse_combined

    root = root if root is not None else _default_root()
    for row in _comment_listing(store, docket):
        comment_id = row["url"].rsplit("/", 1)[-1]
        dk = docket or comment_id.rsplit("-", 1)[0]
        if dk in skip_dockets:
            continue
        key, date, title, updated_at, year = row["key"], row["date"], row["title"], row["updated_at"], row["year"].split(":", 1)[-1]
        comment_dir = root / dk / comment_id
        combined = comment_dir / "combined.md"
        files = _comment_files(comment_dir)
        fp = fingerprint_files([combined, *(Path(p) for _, p in files)]) + "|" + updated_at
        meta = {"docket": dk, "comment_id": comment_id, "doctype": "comment", "kind": "comment", "year": year, "date": date[:10], "title": title}

        def _load(key=key, meta=meta, combined=combined, files=files) -> Doc | None:
            if combined.exists():
                _, body = parse_combined(combined.read_text())
                if body.strip():
                    return Doc(key=key, text=body, metadata=meta, files=files)
            row = store._con().execute("SELECT abstract FROM items WHERE key = ?", (key,)).fetchone()
            abstract = (row["abstract"] if row else "") or ""
            return Doc(key=key, text=abstract, metadata=meta) if abstract.strip() else None

        yield DocRef(key=key, collection="comments", docket=dk, fingerprint=fp, load=_load)


def iter_comment_docs(store: Store, *, docket: str = "", root: Path | None = None) -> Iterator[Doc]:
    """One Doc per comment, newest first: extraction body, else abstract."""
    for ref in iter_comment_refs(store, docket=docket, root=root):
        doc = ref.load()
        if doc is not None:
            yield doc

comment_key_map becomes {cid: (key, year)} over _comment_listing (drop _year_of use there; keep _year_of for the rule/corpus loaders).

Rules:

def _attachment_paths(store: Store, item_key: str) -> list[Path]:
    rows = store._con().execute(
        "SELECT a.storage_path FROM attachments a JOIN items i ON i.id = a.item_id WHERE i.key = ?",
        (item_key,),
    ).fetchall()
    return [Path(r[0]) for r in rows]


def iter_rule_refs(store: Store, *, keys: tuple[str, ...] = (), tag: str = "") -> Iterator[DocRef]:
    rows = store._con().execute(
        "SELECT i.key, COALESCE(i.updated_at,'') AS updated_at, "
        "(SELECT d.sha256 FROM fr_anchor_docs d WHERE d.item_key = i.key) AS anchor_sha "
        "FROM items i WHERE i.item_type = 'rule'"
        + (" AND i.id IN (SELECT item_id FROM item_tags WHERE tag_id IN (SELECT id FROM tags WHERE name = ?))" if tag else "")
        + " ORDER BY i.id",
        (tag,) if tag else (),
    ).fetchall()
    for row in rows:
        key = row["key"]
        if keys and key not in keys:
            continue
        if row["anchor_sha"]:
            fp = f"anchors:{row['anchor_sha']}"
        else:
            fp = fingerprint_files(_attachment_paths(store, key)) + "|" + row["updated_at"]

        def _load(key=key) -> Doc | None:
            return _build_rule_doc(store, store.get(key))

        yield DocRef(key=key, collection="rules", docket=None, fingerprint=fp, load=_load)


def _build_rule_doc(store: Store, item) -> Doc | None:
    ...  # the current body of iter_rule_docs's loop for one item; return None when text is empty


def iter_rule_docs(store, *, keys=(), tag="") -> Iterator[Doc]:
    for ref in iter_rule_refs(store, keys=keys, tag=tag):
        doc = ref.load()
        if doc is not None:
            yield doc

Corpus:

def iter_corpus_refs(store: Store, *, tag: str = "", zotero: "ZoteroPdfIndex | None" = None) -> Iterator[DocRef]:
    sql = (
        "SELECT i.key, COALESCE(i.updated_at,'') AS updated_at FROM items i "
        "WHERE i.id NOT IN (SELECT item_id FROM item_tags WHERE tag_id IN (SELECT id FROM tags WHERE name = 'doctype:comment'))"
        + (" AND i.id IN (SELECT item_id FROM item_tags WHERE tag_id IN (SELECT id FROM tags WHERE name = ?))" if tag else "")
        + " ORDER BY i.id"
    )
    for row in store._con().execute(sql, (tag,) if tag else ()).fetchall():
        key = row["key"]
        fp = row["updated_at"] + "|" + fingerprint_files(_attachment_paths(store, key))

        def _load(key=key) -> Doc | None:
            return _build_corpus_doc(store, store.get(key), zotero)

        yield DocRef(key=key, collection="corpus", docket=None, fingerprint=fp, load=_load)


def _build_corpus_doc(store, item, zotero) -> Doc | None:
    ...  # the current body of iter_corpus_docs's loop for one item; return None when text is empty


def iter_corpus_docs(store, *, tag="", zotero=None) -> Iterator[Doc]:
    for ref in iter_corpus_refs(store, tag=tag, zotero=zotero):
        doc = ref.load()
        if doc is not None:
            yield doc

Lazy Zotero:

class ZoteroPdfIndex:
    def __init__(self, by_key: dict[str, list[Path]]) -> None:
        self._by_key = by_key
        self._loader: Callable[[], dict[str, list[Path]]] | None = None

    @classmethod
    def lazy(cls, sqlite_path: Path, storage_dir: Path, tmp_dir: Path) -> "ZoteroPdfIndex":
        """Snapshot on first ``pdfs_for``; skip the copy when the existing
        snapshot is already as new as the source."""
        inst = cls({})

        def _load() -> dict[str, list[Path]]:
            snap = Path(tmp_dir) / "zotero.sqlite"
            src = Path(sqlite_path)
            if snap.exists() and src.exists() and snap.stat().st_mtime_ns >= src.stat().st_mtime_ns:
                return cls._read(snap, storage_dir)
            return cls.snapshot(src, storage_dir, Path(tmp_dir))._by_key

        inst._loader = _load
        return inst

    @classmethod
    def snapshot(cls, sqlite_path, storage_dir, tmp_dir):
        ...  # unchanged, except the SELECT moves into cls._read(snap, storage_dir)

    @classmethod
    def _read(cls, snap: Path, storage_dir: Path) -> dict[str, list[Path]]:
        ...  # the SELECT + by_key build from the current snapshot(), returning the dict; {} on sqlite3.Error

    def pdfs_for(self, key: str) -> list[Path]:
        if self._loader is not None:
            self._by_key = self._loader()
            self._loader = None
        return list(self._by_key.get(key, []))

snapshot() must os.utime(snap) after copying is not needed: copy2 preserves the source mtime, so snap.mtime >= src.mtime holds until the source changes.

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/llm/test_source_refs.py tests/llm/test_source.py -q -p no:cacheprovider Expected: pass (existing TestCommentDocs, TestCorpusDocs, TestRuleDocs still green through the wrappers).

  • Step 5: Commit
git add src/llm/source.py tests/llm/test_source_refs.py
git commit -m "feat(llm): lazy DocRefs with fingerprints, single-query listings, lazy Zotero snapshot (refs #615)"

Task 14: index_refs — fingerprint-first loop, PDFs only on the embed path, docket completion

Files:

  • Modify: src/llm/index.py
  • Test: tests/llm/test_index_refs.py; update tests/llm/test_index.py::TestPageEnrichmentHook

Interfaces:

  • Consumes: DocRef, migrate, index_docket_state.

  • Produces:

    • index_refs(refs, *, collection, cfg, pool, force=False, sealed: dict[str, str] | None = None, mark_complete=True) -> dict with stats keys indexed, skipped, chunks, fingerprint_skipped, hash_skipped, docket_complete.
    • index_docs(docs, ...) unchanged signature, implemented as index_refs over DocRef(key, collection, docket=metadata.get("docket"), fingerprint="", load=lambda: doc).
    • docket_complete(engine, collection) -> dict[str, str] (docket → sealed_at recorded).
    • _state(engine, collection) -> dict[str, tuple[str, str]] (key → (content_hash, fingerprint)).
  • Step 1: Write the failing tests

# tests/llm/test_index_refs.py
from __future__ import annotations

from unittest.mock import MagicMock, patch

from llm.chunk import Doc, content_hash
from llm.config import LlmConfig
from llm.index import index_refs
from llm.source import DocRef

CFG = LlmConfig(ollama_hosts=("http://h1:11434",), embed_model="m", instruct_model="g", embed_dim=768, build_ann_index=False, pg_host="x", pg_port=5432, pg_db="llm", pg_user="llm")
DOC = Doc(key="K1", text="Some body text.", metadata={"docket": "D"})


def _ref(fp="fp1", docket="D", loads=None, doc=DOC):
    def _load():
        if loads is not None:
            loads.append(doc.key)
        return doc
    return DocRef(key=doc.key, collection="comments", docket=docket, fingerprint=fp, load=_load)


def _run(refs, state_rows, *, force=False, sealed=None, mark_complete=True, complete_rows=()):
    store = MagicMock()
    engine = MagicMock()
    conn = engine.begin.return_value.__enter__.return_value

    def fake_execute(clause, *a, **k):
        sql = str(clause)
        r = MagicMock()
        if "FROM index_docket_state" in sql:
            r.fetchall.return_value = list(complete_rows)
        elif "FROM index_state" in sql:
            r.fetchall.return_value = state_rows
        else:
            r.fetchall.return_value = []
        return r

    conn.execute.side_effect = fake_execute
    with (
        patch("llm.index._engine", return_value=engine),
        patch("llm.index.vectorstore", return_value=store),
        patch("llm.index.embed_texts", return_value=[[0.0] * 3]),
        patch("llm.index.ensure_hnsw"),
        patch("llm.index.enrich_pdf_pages", side_effect=lambda d, c: c) as enrich,
        patch("llm.index.HostPool") as MockPool,
    ):
        MockPool.return_value.check.return_value = ["http://h1:11434"]
        stats = index_refs(refs, collection="comments", cfg=CFG, pool=MockPool.return_value, force=force, sealed=sealed, mark_complete=mark_complete)
    return stats, store, conn, enrich


def test_fingerprint_match_skips_without_load():
    loads = []
    stats, store, _, enrich = _run([_ref("fp1", loads=loads)], [("K1", "h-old", "fp1")])
    assert stats["fingerprint_skipped"] == 1 and stats["indexed"] == 0
    assert loads == []
    store.add_embeddings.assert_not_called()
    enrich.assert_not_called()


def test_hash_match_updates_fingerprint_without_embedding():
    h = content_hash(DOC.text)
    stats, store, conn, enrich = _run([_ref("fp2")], [("K1", h, "fp1")])
    assert stats["hash_skipped"] == 1 and stats["indexed"] == 0
    store.add_embeddings.assert_not_called()
    enrich.assert_not_called()
    upd = [c for c in conn.execute.call_args_list if "UPDATE index_state SET fingerprint" in str(c.args[0])]
    assert len(upd) == 1 and upd[0].args[1]["f"] == "fp2"


def test_changed_doc_embeds_and_records_fingerprint():
    stats, store, conn, enrich = _run([_ref("fp2")], [("K1", "stale", "fp1")])
    assert stats["indexed"] == 1 and stats["chunks"] == 1
    store.add_embeddings.assert_called_once()
    enrich.assert_called_once()
    ins = [c for c in conn.execute.call_args_list if "INSERT INTO index_state" in str(c.args[0])]
    assert ins[0].args[1]["f"] == "fp2"


def test_empty_fingerprint_never_matches():
    stats, store, _, _ = _run([_ref("")], [("K1", "stale", "")])
    assert stats["indexed"] == 1


def test_force_ignores_fingerprint_and_hash():
    h = content_hash(DOC.text)
    stats, store, _, _ = _run([_ref("fp1")], [("K1", h, "fp1")], force=True)
    assert stats["indexed"] == 1


def test_load_none_counts_skipped():
    ref = DocRef(key="K9", collection="comments", docket="D", fingerprint="x", load=lambda: None)
    stats, *_ = _run([ref], [])
    assert stats["skipped"] == 1 and stats["indexed"] == 0


def test_sealed_docket_marked_complete_after_clean_run():
    stats, _, conn, _ = _run([_ref("fp1")], [], sealed={"D": "2026-10-20T00:00:00Z"})
    assert stats["docket_complete"] == 1
    ins = [c for c in conn.execute.call_args_list if "INSERT INTO index_docket_state" in str(c.args[0])]
    assert ins[0].args[1] == {"c": "comments", "d": "D", "s": "2026-10-20T00:00:00Z"}


def test_not_marked_when_mark_complete_false_or_unsealed():
    stats, _, conn, _ = _run([_ref("fp1")], [], sealed={"D": "s"}, mark_complete=False)
    assert stats["docket_complete"] == 0
    stats, _, conn, _ = _run([_ref("fp1")], [], sealed={})
    assert stats["docket_complete"] == 0

Update tests/llm/test_index.py::TestPageEnrichmentHook::test_enriches_chunks_before_add → rename to test_enriches_only_docs_that_embed and assert that a doc whose hash matches state is not enriched:

    def test_enriches_only_docs_that_embed(self, monkeypatch):
        from llm import index as index_mod
        seen = []
        monkeypatch.setattr(index_mod, "enrich_pdf_pages", lambda doc, chunks: seen.append(doc.key) or chunks)
        _run([DOC], [])
        assert seen == ["K1"]
        seen.clear()
        _run([DOC], [("K1", content_hash(DOC.text), "")])
        assert seen == []

_run in that file feeds state_rows as 2-tuples; change them to 3-tuples (key, hash, fingerprint) throughout (("K1", h)("K1", h, "")).

  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/llm/test_index_refs.py -q -p no:cacheprovider Expected: FAIL — ImportError: cannot import name 'index_refs'.

  • Step 3: Implement
def _state(engine: Engine, collection: str) -> dict[str, tuple[str, str]]:
    with engine.begin() as conn:
        rows = conn.execute(
            text("SELECT item_key, content_hash, fingerprint FROM index_state WHERE collection = :c"),
            {"c": collection},
        ).fetchall()
    return {r[0]: (r[1], r[2] or "") for r in rows}


def docket_complete(engine: Engine, collection: str) -> dict[str, str]:
    """docket → sealed_at for dockets fully indexed under that seal."""
    with engine.begin() as conn:
        rows = conn.execute(
            text("SELECT docket, sealed_at FROM index_docket_state WHERE collection = :c"),
            {"c": collection},
        ).fetchall()
    return {r[0]: r[1] for r in rows}


def index_refs(
    refs: Iterable[DocRef],
    *,
    collection: str,
    cfg: LlmConfig,
    pool: HostPool,
    force: bool = False,
    sealed: dict[str, str] | None = None,
    mark_complete: bool = True,
) -> dict:
    """Fingerprint-first incremental indexing.

    Per ref: (1) fingerprint equal to the stored one → skip without
    loading; (2) load, hash the text; hash equal → record the new
    fingerprint, skip embedding; (3) chunk, locate PDF pages, embed,
    write. PDFs are opened only on path (3). After the loop, every
    docket in *sealed* that was iterated is recorded in
    ``index_docket_state`` so the next run does not list it at all —
    unless *mark_complete* is False (a ``--limit`` run is never complete).
    """
    engine = _engine(cfg)
    migrate(engine)
    pool.check(cfg.embed_model)
    store = vectorstore(collection, cfg, pool)
    seen = _state(engine, collection)
    stats = {"indexed": 0, "skipped": 0, "chunks": 0, "fingerprint_skipped": 0, "hash_skipped": 0, "docket_complete": 0}
    dockets_seen: set[str] = set()

    for ref in refs:
        if ref.docket:
            dockets_seen.add(ref.docket)
        prev_hash, prev_fp = seen.get(ref.key, ("", ""))
        if not force and ref.fingerprint and ref.fingerprint == prev_fp:
            stats["fingerprint_skipped"] += 1
            continue
        doc = ref.load()
        if doc is None or not doc.text.strip():
            stats["skipped"] += 1
            continue
        h = content_hash(doc.text)
        if not force and prev_hash == h:
            with engine.begin() as conn:
                conn.execute(
                    text("UPDATE index_state SET fingerprint = :f WHERE item_key = :k AND collection = :c"),
                    {"f": ref.fingerprint, "k": ref.key, "c": collection},
                )
            stats["hash_skipped"] += 1
            continue
        chunks = enrich_pdf_pages(doc, chunk_doc(doc))
        if not chunks:
            stats["skipped"] += 1
            continue
        vectors = embed_texts(pool, cfg.embed_model, [c.text for c in chunks])
        _delete_old_chunks(engine, ref.key, collection)
        store.add_embeddings(texts=[c.text for c in chunks], embeddings=vectors, metadatas=[c.metadata for c in chunks], ids=[c.id for c in chunks])
        with engine.begin() as conn:
            conn.execute(
                text(
                    "INSERT INTO index_state (item_key, collection, content_hash, chunk_count, fingerprint) "
                    "VALUES (:k, :c, :h, :n, :f) "
                    "ON CONFLICT (item_key, collection) DO UPDATE SET "
                    "content_hash = :h, chunk_count = :n, fingerprint = :f, indexed_at = now()"
                ),
                {"k": ref.key, "c": collection, "h": h, "n": len(chunks), "f": ref.fingerprint},
            )
        stats["indexed"] += 1
        stats["chunks"] += len(chunks)
        if stats["indexed"] % 100 == 0:
            log.info("indexed %(indexed)s (+%(chunks)s)", stats)  # pragma: no cover

    if mark_complete and sealed:
        for d in sorted(dockets_seen & set(sealed)):
            with engine.begin() as conn:
                conn.execute(
                    text(
                        "INSERT INTO index_docket_state (collection, docket, sealed_at) VALUES (:c, :d, :s) "
                        "ON CONFLICT (collection, docket) DO UPDATE SET sealed_at = :s, indexed_at = now()"
                    ),
                    {"c": collection, "d": d, "s": sealed[d]},
                )
            stats["docket_complete"] += 1

    if cfg.build_ann_index:
        ensure_hnsw(engine, cfg.embed_dim)
    else:
        log.info("ANN index skipped (build_ann_index=false); using exact search")
    return stats


def index_docs(docs: Iterable[Doc], *, collection: str, cfg: LlmConfig, pool: HostPool, force: bool = False) -> dict:
    """Back-compat wrapper: Docs without fingerprints (never fingerprint-skipped)."""
    from llm.source import DocRef

    refs = (
        DocRef(key=d.key, collection=collection, docket=d.metadata.get("docket") or None, fingerprint="", load=(lambda d=d: d))
        for d in docs
    )
    stats = index_refs(refs, collection=collection, cfg=cfg, pool=pool, force=force, sealed=None)
    return {k: stats[k] for k in ("indexed", "skipped", "chunks")}

Import DocRef lazily inside index_docs (as shown) to avoid an import cycle (llm.source imports llm.chunk, not llm.index, so a top-level from llm.source import DocRef is also fine; either works).

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/llm -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/llm/index.py tests/llm/test_index_refs.py tests/llm/test_index.py
git commit -m "feat(llm): fingerprint-first index_refs; PDFs opened only when embedding; docket completion (refs #615)"

Task 15: stack llm index wiring

Files:

  • Modify: src/cli/llm.py
  • Test: tests/cli/test_llm_exercise.py (update TestIndexComments, TestIndexCorpus, TestIndexAll, TestIndexLimit to the ref iterators), add tests/cli/test_llm_index_sealed.py

Interfaces:

  • Consumes: iter_comment_refs/iter_rule_refs/iter_corpus_refs, ZoteroPdfIndex.lazy, index_refs, docket_complete, Store.sealed_dockets.

  • Produces: stack llm index unchanged flags; output line adds fp_skipped= hash_skipped= docket_complete=.

  • Step 1: Write the failing tests

# tests/cli/test_llm_index_sealed.py
from __future__ import annotations

from unittest.mock import MagicMock, patch

from typer.testing import CliRunner

from cli.llm import app

runner = CliRunner()
_STATS = {"indexed": 0, "skipped": 0, "chunks": 0, "fingerprint_skipped": 5, "hash_skipped": 0, "docket_complete": 0}


@patch("llm.index._engine")
@patch("llm.index.docket_complete", return_value={"CMS-2019-0111": "s1", "CMS-2020-0088": "old"})
@patch("llm.source.iter_comment_refs")
@patch("llm.pool.HostPool.from_config")
@patch("llm.index.index_refs")
@patch("conf.connect.bib")
@patch("llm.config.load")
def test_complete_sealed_dockets_are_not_listed(mock_load, mock_bib, mock_index, mock_pool, mock_iter, mock_complete, _engine):
    mock_load.return_value = MagicMock()
    store = MagicMock()
    store.sealed_dockets.return_value = {"CMS-2019-0111": "s1", "CMS-2020-0088": "s2"}
    mock_bib.return_value = store
    mock_iter.return_value = iter([])
    mock_index.return_value = _STATS

    result = runner.invoke(app, ["index"])

    assert result.exit_code == 0, result.output
    kwargs = mock_iter.call_args.kwargs
    assert kwargs["skip_dockets"] == {"CMS-2019-0111"}      # seal matches → skipped
    ikw = mock_index.call_args.kwargs
    assert ikw["sealed"] == {"CMS-2020-0088": "s2"}          # re-sealed docket will be re-marked
    assert ikw["mark_complete"] is True
    assert "fp_skipped=5" in result.output


@patch("llm.index._engine")
@patch("llm.index.docket_complete", return_value={})
@patch("llm.source.iter_comment_refs")
@patch("llm.pool.HostPool.from_config")
@patch("llm.index.index_refs")
@patch("conf.connect.bib")
@patch("llm.config.load")
def test_force_and_limit_disable_skips_and_completion(mock_load, mock_bib, mock_index, mock_pool, mock_iter, mock_complete, _engine):
    mock_load.return_value = MagicMock()
    store = MagicMock()
    store.sealed_dockets.return_value = {"CMS-2019-0111": "s1"}
    mock_bib.return_value = store
    mock_iter.return_value = iter([])
    mock_index.return_value = _STATS

    result = runner.invoke(app, ["index", "--force", "--limit", "5"])

    assert result.exit_code == 0, result.output
    assert mock_iter.call_args.kwargs["skip_dockets"] == set()
    assert mock_index.call_args.kwargs["mark_complete"] is False
  • Step 2: Run to verify failure

Run: uv run --no-sync pytest tests/cli/test_llm_index_sealed.py -q -p no:cacheprovider Expected: FAIL — iter_comment_refs not called / index_refs not called.

  • Step 3: Implement
def _refs_for(collection: str, store, docket: str, keys: tuple[str, ...], skip_dockets: set[str]):
    from llm.source import ZoteroPdfIndex, iter_comment_refs, iter_corpus_refs, iter_rule_refs

    if collection == "comments":
        return iter_comment_refs(store, docket=docket, skip_dockets=skip_dockets)
    if collection == "rules":
        return iter_rule_refs(store, keys=keys)
    from conf import ROOT, path

    zotero = ZoteroPdfIndex.lazy(path("db.zotero"), path("storage.zotero"), ROOT / ".state" / "llm")
    return iter_corpus_refs(store, zotero=zotero)


@app.command()
def index(...same options...) -> None:
    import itertools

    from conf.connect import bib
    from llm import config as llm_config
    from llm.index import _engine, docket_complete, index_refs
    from llm.pool import HostPool

    targets = _COLLECTIONS if collection == "all" else (collection,)
    if any(t not in _COLLECTIONS for t in targets):
        raise typer.BadParameter("collection must be comments, rules, corpus or all")
    cfg = llm_config.load()
    store = bib()
    sealed_all = {} if force else store.sealed_dockets()
    for target in targets:
        complete = docket_complete(_engine(cfg), target) if (sealed_all and target == "comments") else {}
        skip = {d for d, s in sealed_all.items() if complete.get(d) == s}
        pending_seals = {d: s for d, s in sealed_all.items() if d not in skip}
        refs = _refs_for(target, store, docket, tuple(key), skip)
        if limit:
            refs = itertools.islice(refs, limit)
        stats = index_refs(
            refs, collection=target, cfg=cfg, pool=HostPool.from_config(cfg),
            force=force, sealed=pending_seals if target == "comments" else None,
            mark_complete=not limit,
        )
        if skip:
            typer.echo(f"{target}: {len(skip)} sealed docket(s) already complete — not listed")
        typer.echo(
            f"{target}: indexed={stats['indexed']} skipped={stats['skipped']} chunks={stats['chunks']} "
            f"fp_skipped={stats['fingerprint_skipped']} hash_skipped={stats['hash_skipped']} "
            f"docket_complete={stats['docket_complete']}"
        )

Update tests/cli/test_llm_exercise.py: patch llm.source.iter_comment_refs / iter_rule_refs / iter_corpus_refs and llm.source.ZoteroPdfIndex.lazy instead of the _docs/snapshot names; patch llm.index.index_refs and llm.index.docket_complete (return {}) and llm.index._engine; _STATS gains the three new keys; the comments assertion becomes mock_iter.assert_called_once_with(store, docket="", skip_dockets=set()) and the corpus one mock_iter.assert_called_once_with(store, zotero=zot).

  • Step 4: Run to verify pass

Run: uv run --no-sync pytest tests/cli/test_llm_index_sealed.py tests/cli/test_llm_exercise.py -q -p no:cacheprovider Expected: pass.

  • Step 5: Commit
git add src/cli/llm.py tests/cli/test_llm_index_sealed.py tests/cli/test_llm_exercise.py
git commit -m "feat(cli): stack llm index lists only unsealed/incomplete dockets, lazy Zotero snapshot (refs #615)"

Task 16: Re-farm script to the plain chain; full test run

Files:

  • Modify: dev/scripts/refarm_cms_2026_2377.sh

  • Step 1: Simplify the chain

Replace the two-phase block with the plain chain (every step is now cheap when idle), keeping the step helper:

# Every step is incremental: sealed dockets are skipped outright, open
# dockets pull from the stored watermark, extract/index compare cheap
# fingerprints before doing any work. Mirror first (bulk, no cap), then
# the API for anything the mirror lags on.
step "mirror backfill" uv run stack bib backfill-comments --docket CMS-2026-2377 --mirror
step "api fetch"       uv run stack bib fetch-pfs-comments --docket CMS-2026-2377
step "api backfill"    uv run stack bib backfill-comments --docket CMS-2026-2377
step "extract"         uv run stack comments extract --docket CMS-2026-2377
step "index"           uv run stack llm index --collection comments --docket CMS-2026-2377
  • Step 2: Run the whole suite

Run: uv run --no-sync pytest tests -q -p no:cacheprovider -x Expected: all pass. Fix anything the earlier tasks left behind before committing.

  • Step 3: Commit
git add dev/scripts/refarm_cms_2026_2377.sh
git commit -m "chore(comments): re-farm script is the plain chain now that every stage is incremental (refs #615)"

Task 17: Rollout on the live data

Run from /home/kert/stack with set -a; . ./.env; set +a first. Record every number in a comment on Gitea #615.

  • Step 1: Schema + discover
uv run stack comments dockets --discover     # 12 rows, ≤12 API calls, once
uv run stack comments dockets

Expected: 12 rows with close dates; CMS-2026-2377 closes 2026-09-14.

  • Step 2: Attachment dedupe
uv run python dev/scripts/dedupe_attachments.py             # dry run: ~33,379 groups
uv run python dev/scripts/dedupe_attachments.py --apply
uv run stack comments dockets                               # reopens the store → unique index created
sqlite3 data/bib.sqlite "SELECT name FROM sqlite_master WHERE name='idx_attachments_item_filename'"
  • Step 2b: Recover the abstracts blanked on 2026-09-08

The old upsert overwrote 17,607 enriched bodies in CMS-2026-2377 and 1,115 in CMS-2017-0092 with '' (incident in the SDD ledger). They still carry enriched:ok, so backfill skips them. Clear the tag on those rows and re-enrich from the mirror (idempotent attach + data-preserving upsert are in place by now):

for d in CMS-2026-2377 CMS-2017-0092; do
  sqlite3 data/bib.sqlite "DELETE FROM item_tags WHERE tag_id=(SELECT id FROM tags WHERE name='enriched:ok') AND item_id IN (SELECT id FROM items WHERE url LIKE 'https://www.regulations.gov/comment/$d-%' AND coalesce(abstract,'')='')"
  uv run stack bib backfill-comments --docket $d --mirror
done
sqlite3 data/bib.sqlite "SELECT substr(url,37,13), count(*), sum(coalesce(abstract,'')<>'') FROM items WHERE url LIKE 'https://www.regulations.gov/comment/CMS-2026-2377-%' OR url LIKE 'https://www.regulations.gov/comment/CMS-2017-0092-%' GROUP BY 1"

Expected: non-empty abstracts ≈ total for both dockets (a few genuinely empty bodies are fine). Then re-run stack comments extract and stack llm index --collection comments so any comment whose text was lost from combined.md/index is restored (the index still holds pre-wipe text; fingerprints change because updated_at moved, hash-skip absorbs unchanged text).

  • Step 3: Seal the historical dockets
for d in CMS-2017-0092 CMS-2018-0076 CMS-2019-0111 CMS-2020-0088 CMS-2021-0119 CMS-2022-0113 CMS-2023-0121 CMS-2024-0256 CMS-2025-0304; do
  uv run stack comments seal $d --reason historical
done

(Check stack comments dockets for any docket id outside this list — there were 12 tags on 2026-09-08; seal every one except CMS-2026-2377.)

  • Step 4: Last full walks (stamp fingerprints, mark completion)
time uv run stack llm index --collection all      # stat-only over 196k items; expect hash_skipped≈196k, indexed≈0
time uv run stack llm index --collection all      # second run: fp_skipped only for CMS-2026-2377; 11 dockets "already complete"
time uv run stack comments extract                # skipped_sealed: 11
time uv run stack bib fetch-pfs-comments          # 11 "sealed … skipping" lines, one walk from watermark

Acceptance: second index run < 60 s with no pdf pages log lines and no change to .state/llm/zotero.sqlite mtime; fetch makes ≤ 4 API calls (count _get log lines or watch the run time: ≤ 4 × sleep).

  • Step 5: Report + memory

Post the timings and counts to #615. Update the memory note palliative_rfi_project.md (re-farm section) with: sealed dockets, the dockets/seal/unseal commands, and that --force is the only way to redo sealed work.