All checks were successful
CI / lint (push) Successful in 33s
CI / notebooks-smoke (push) Successful in 1m22s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 1m4s
Infra CI / zotero (push) Successful in 13s
Infra CI / docs (push) Successful in 16s
Infra CI / api (push) Successful in 1m14s
Infra CI / llm (push) Successful in 42s
Infra CI / mc (push) Successful in 13s
Deploy / report (push) Successful in 13s
CI / test (push) Successful in 13m12s
292 lines
18 KiB
Markdown
292 lines
18 KiB
Markdown
# P38: Zotero Rule Sync 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:** Land the CY2027 PFS NPRM (bib `5ITGVDJV`) in the Zotero library and make the whole rule-sync path unable to silently fail again.
|
||
|
||
**Architecture:** Four small moves, strictly ordered: (1) fix `Store.upsert` so the URL-dedupe update path *merges* tags/collections with what is already stored instead of wipe-and-replace; (2) repair the wiped data by re-running the existing idempotent `fetch_nprm_addenda --register` for 2026 and 2027; (3) run the tag-scoped `sync-zotero` that P37 skipped; (4) add a `source:federal-register`-scoped step to the generated nightly zotero-sync workflow. The fix must land before the repair, or the next comment-farm/mail-poller upsert re-wipes the repair (mail-poller mounts `./src` live, so the host commit takes effect on its next 600s loop; the 2026-09-15 refarm cron also runs from this checkout).
|
||
|
||
**Tech Stack:** Python (sqlite3 stdlib, typer CLI), pytest, Gitea Actions workflows generated by `dev/scripts/backends/gitea.py` via `dev/scripts/gen_config.py`.
|
||
|
||
**Spec:** No standalone spec — the incident diagnosis and requirements live in the Gitea milestone and issues below (real links):
|
||
|
||
- Milestone: [P38: Zotero Rule Sync — upsert tag merge, tag repair, nightly rules scope](https://git.fhirworx.io/homelab/stack/milestone/39)
|
||
- [#624 — bib: Store.upsert clobbers curated tags/collections on URL-dedupe update](https://git.fhirworx.io/homelab/stack/issues/624)
|
||
- [#625 — bib,pfs: restore sup:*_PFS_NPRM / file:rvu / year tags wiped from 2KVJ2HKX and 5ITGVDJV](https://git.fhirworx.io/homelab/stack/issues/625)
|
||
- [#626 — bib: CY2027 NPRM (5ITGVDJV) never synced to Zotero — run the tag-scoped sync P37 skipped](https://git.fhirworx.io/homelab/stack/issues/626)
|
||
- [#627 — ci: nightly zotero-sync only covers source:email — add a rules scope](https://git.fhirworx.io/homelab/stack/issues/627)
|
||
- Background: [#623](https://git.fhirworx.io/homelab/stack/issues/623) (docs tag-drift symptom of the same clobber), P37 plan `docs/superpowers/plans/2026-08-13-cy2027-pfs-proposed-rule-p37.md`, P36 plan Step 5 (the sync step P37 skipped).
|
||
|
||
## Global Constraints
|
||
|
||
- Commit messages: conventional-commit style, reference issues as `(closes #N)` / `(refs #N)`; **no Co-Authored-By trailer**.
|
||
- Task order is load-bearing: Task 1 (merge fix) MUST be committed before Task 3 (data repair) runs.
|
||
- Never run an unscoped `sync-zotero` — it hydrates ~180k bib items (N+1). Always `--tag`.
|
||
- `sync-zotero` writes into `data/zotero/data/zotero.sqlite`, which the running `zotero` container holds an exclusive lock on. The CLI's default `--hold-zotero` stops/starts the container itself when run from the host (docker required).
|
||
- `Store.update(tags=...)` keeps its explicit replace semantics; only `Store.upsert`'s dedupe path changes to merge. Deliberate tag removal stays `Store.remove_tag`.
|
||
- Zotero item keys mirror bib keys 1:1 (verified: all prior rules exist in Zotero under their bib key).
|
||
|
||
---
|
||
|
||
### Task 1: `Store.upsert` merges tags/collections on the dedupe path ([#624](https://git.fhirworx.io/homelab/stack/issues/624))
|
||
|
||
**Files:**
|
||
- Modify: `src/bib/store.py:207-239` (`Store.upsert`)
|
||
- Test: `tests/bib/test_store.py` (extend `TestUpsertDedup`, ~line 678)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Store.get(key) -> Item` (existing; returns item with `.tags: list[str]`, `.collections: list[str]`), `Store.update(key, **fields)` (existing; `tags=`/`collections=` are replace-semantics).
|
||
- Produces: `Store.upsert(item, *, tags=None, collection="") -> str` — unchanged signature; on the existing-URL path the stored row ends up with the **union** (existing ∪ incoming, order: existing first, dupes dropped) of tags and of collections. Tasks 3–4 rely on curated tags surviving re-ingest.
|
||
|
||
- [x] **Step 1: Write the failing tests** — append to `TestUpsertDedup` in `tests/bib/test_store.py`:
|
||
|
||
```python
|
||
def test_upsert_preserves_curated_tags_on_existing(self) -> None:
|
||
"""Regression #624: a re-ingest upsert must not clobber tags
|
||
added to the stored item since the last ingest (the CY2027 NPRM
|
||
lost sup:2027_PFS_NPRM this way and never reached Zotero)."""
|
||
s = Store(":memory:")
|
||
item1 = Rule(title="First", url="https://example.com/keep")
|
||
key = s.upsert(item1, tags=["source:federal-register"])
|
||
s.add_tag(key, "sup:2027_PFS_NPRM") # curated after ingest
|
||
|
||
item2 = Rule(title="Re-ingested", url="https://example.com/keep")
|
||
item2.add_tag("source:federal-register")
|
||
key2 = s.upsert(item2)
|
||
assert key2 == key
|
||
got = s.get(key)
|
||
assert "sup:2027_PFS_NPRM" in got.tags
|
||
assert "source:federal-register" in got.tags
|
||
assert got.tags.count("source:federal-register") == 1
|
||
s.close()
|
||
|
||
def test_upsert_preserves_existing_collections(self) -> None:
|
||
"""Regression #624: same wipe hazard via _sync_collections."""
|
||
s = Store(":memory:")
|
||
coll = s.ensure_collections({"Rules": {}})["Rules"]
|
||
item1 = Rule(title="First", url="https://example.com/keepc")
|
||
key = s.upsert(item1, collection=coll)
|
||
|
||
item2 = Rule(title="Re-ingested", url="https://example.com/keepc")
|
||
key2 = s.upsert(item2)
|
||
assert key2 == key
|
||
assert coll in s.get(key).collections
|
||
s.close()
|
||
```
|
||
|
||
- [x] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `uv run pytest tests/bib/test_store.py::TestUpsertDedup -v`
|
||
Expected: the two new tests FAIL (`sup:2027_PFS_NPRM` / `coll` missing after second upsert); the three existing tests PASS.
|
||
|
||
- [x] **Step 3: Implement the merge** — replace the existing-URL branch of `Store.upsert` (`src/bib/store.py:220-237`). Note the current code computes `row = item.to_row()` twice (line 222 is dead); the replacement drops it:
|
||
|
||
```python
|
||
if existing:
|
||
ekey = existing["key"]
|
||
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)
|
||
item.stamp_access()
|
||
# Merge with what's already stored — a re-ingest must
|
||
# never clobber tags/collections curated on the row
|
||
# since the last ingest (#624: the CY2027 NPRM lost its
|
||
# sup: registration tag to this and silently vanished
|
||
# from the tag-scoped Zotero sync). Deliberate removal
|
||
# goes through remove_tag, not upsert.
|
||
current = self.get(ekey)
|
||
row = item.to_row()
|
||
row.pop("key", None)
|
||
row["tags"] = list(dict.fromkeys([*current.tags, *item.tags]))
|
||
row["collections"] = list(
|
||
dict.fromkeys([*current.collections, *item.collections])
|
||
)
|
||
self.update(ekey, **row)
|
||
return ekey
|
||
```
|
||
|
||
- [x] **Step 4: Run the full store + sync test files**
|
||
|
||
Run: `uv run pytest tests/bib/test_store.py tests/bib/test_sync.py tests/bib/test_sync_full.py tests/bib/test_sync_deeper.py tests/bib/test_sync_exercise.py -q`
|
||
Expected: all PASS (no existing test asserts wipe semantics — verified before planning).
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/bib/store.py tests/bib/test_store.py
|
||
git commit -m "fix(bib): upsert merges tags/collections with stored row on URL dedupe (closes #624)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Nightly zotero-sync gains a rules scope ([#627](https://git.fhirworx.io/homelab/stack/issues/627))
|
||
|
||
**Files:**
|
||
- Modify: `dev/scripts/backends/gitea.py:584-634` (`_gen_zotero_sync`)
|
||
- Generated: `.gitea/workflows/zotero-sync.yml` (via `uv run python dev/scripts/gen_config.py` — never hand-edit)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `stack bib sync-zotero --tag <t> --no-hold` CLI (existing; idempotent — existing Zotero items are skipped/refreshed, not duplicated).
|
||
- Produces: nightly workflow with a second sync step scoped `--tag source:federal-register` (78 items today), between the email-sync step and the restart step.
|
||
|
||
- [x] **Step 1: Add the step to the generator** — in `_gen_zotero_sync`, insert after the email-sync step (before `Restart zotero`):
|
||
|
||
```python
|
||
- name: Sync Federal Register rule items into Zotero
|
||
# Rule items only ever reached Zotero via one-off manual syncs
|
||
# (P36 step 5); P37 skipped that step and the CY2027 NPRM never
|
||
# arrived (#626/#627). Tag-scoped: a full unscoped sync would
|
||
# hydrate ~180k bib items.
|
||
run: |
|
||
docker exec api uv run --no-sync \\
|
||
stack bib sync-zotero --tag source:federal-register --no-hold
|
||
```
|
||
|
||
(inside the f-string `content`, matching the surrounding indentation and `\\` line-continuation style of the email step)
|
||
|
||
- [x] **Step 2: Regenerate and inspect**
|
||
|
||
Run: `uv run python dev/scripts/gen_config.py && git diff .gitea/workflows/`
|
||
Expected: only `zotero-sync.yml` changes, gaining exactly the new step; header still says generated.
|
||
|
||
- [x] **Step 3: Determinism check**
|
||
|
||
Run: `uv run python dev/scripts/gen_config.py && git diff --stat`
|
||
Expected: identical diff (no churn on re-run).
|
||
|
||
- [x] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add dev/scripts/backends/gitea.py .gitea/workflows/zotero-sync.yml
|
||
git commit -m "feat(ci): nightly zotero-sync also pushes source:federal-register rule items (closes #627)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Repair the wiped registration tags ([#625](https://git.fhirworx.io/homelab/stack/issues/625))
|
||
|
||
**Files:**
|
||
- No source changes. Runs `dev/scripts/fetch_nprm_addenda.py` (existing, idempotent: attach is guarded by title-dedupe, tags via `add_tag`'s INSERT OR IGNORE). Data: `data/bib.sqlite`.
|
||
|
||
**Interfaces:**
|
||
- Consumes: `fetch_nprm_addenda.py --year {2026,2027} --register`; `CONFIGS[year].register_tags` = `sup:<year>_PFS_NPRM`, `module:pfs`, `file:rvu`, `year:<year>`.
|
||
- Produces: `2KVJ2HKX` and `5ITGVDJV` carry their full register tag sets again; `pfs.nprm._addendum_b_path(year)` resolves for both years. Task 4 keys its sync on these tags.
|
||
|
||
**Precondition: Task 1 is committed** (mail-poller picks the fixed code up live; the repair must not be re-wipeable).
|
||
|
||
- [x] **Step 1: Re-run registration for both years** (ZIPs already on disk → download step self-skips; attach guard prints `SKIP attach`):
|
||
|
||
Run: `uv run python dev/scripts/fetch_nprm_addenda.py --year 2026 --register && uv run python dev/scripts/fetch_nprm_addenda.py --year 2027 --register`
|
||
Expected: both print `SKIP attach — ... already has an attachment` and `Current tags on <key>: [...]` including the `sup:` tag. If either *attaches* instead of skipping, STOP — the guard regressed; investigate before continuing.
|
||
|
||
- [x] **Step 2: Verify tag restoration + loader discovery**
|
||
|
||
```bash
|
||
uv run python - <<'EOF'
|
||
from conf import connect
|
||
from pfs import nprm
|
||
store = connect.bib()
|
||
for year, tag, key in ((2026, "sup:2026_PFS_NPRM", "2KVJ2HKX"), (2027, "sup:2027_PFS_NPRM", "5ITGVDJV")):
|
||
items = store.list_items(tag=tag)
|
||
assert [i.key for i in items] == [key], (tag, [i.key for i in items])
|
||
path = nprm._addendum_b_path(year)
|
||
print(year, "OK:", key, "->", path)
|
||
EOF
|
||
```
|
||
Expected: `2026 OK: ...` and `2027 OK: ...` with real xlsx paths.
|
||
|
||
- [x] **Step 3: Simulate the wiper to prove the fix holds** (read-only on real data — run the exact upsert the comment farm performs against a *copy* of bib.sqlite, assert `sup:2027_PFS_NPRM` survives):
|
||
|
||
```bash
|
||
cp data/bib.sqlite /tmp/claude-1000/-home-kert-stack/e01452a7-3395-4fb6-bd66-ed6fd8c06398/scratchpad/bib_copy.sqlite
|
||
uv run python - <<'EOF'
|
||
from bib.store import Store
|
||
from bib.translate import federal_register
|
||
s = Store("/tmp/claude-1000/-home-kert-stack/e01452a7-3395-4fb6-bd66-ed6fd8c06398/scratchpad/bib_copy.sqlite")
|
||
rule = federal_register("https://www.federalregister.gov/documents/2026-14327")
|
||
rule.add_tag("source:federal-register")
|
||
rule.add_tag("module:pfs")
|
||
key = s.upsert(rule)
|
||
assert key == "5ITGVDJV", key
|
||
assert "sup:2027_PFS_NPRM" in s.get(key).tags
|
||
print("survives re-ingest OK")
|
||
EOF
|
||
```
|
||
Expected: `survives re-ingest OK`. (Network hiccup on the FR fetch → retry; this step is diagnostic only.)
|
||
|
||
- [x] **Step 4: Comment the verification output on [#625](https://git.fhirworx.io/homelab/stack/issues/625) and close it** (data-only task, nothing to commit).
|
||
|
||
---
|
||
|
||
### Task 4: Sync the CY2027 NPRM into Zotero ([#626](https://git.fhirworx.io/homelab/stack/issues/626))
|
||
|
||
**Files:**
|
||
- No source changes. Data: `data/zotero/data/zotero.sqlite`. Requires docker on the host (CLI default `--hold-zotero` stops/starts the `zotero` container around the write).
|
||
|
||
**Interfaces:**
|
||
- Consumes: restored `sup:` tags from Task 3; `stack bib sync-zotero --tag <t>`.
|
||
- Produces: Zotero item `5ITGVDJV` with 3 child attachments; refreshed tag set on Zotero's `2KVJ2HKX`.
|
||
|
||
- [x] **Step 1: Confirm the zotero container is running and no notebooks-integration run is active** (`docker ps --format '{{.Names}}' | grep zotero`; the nightly runs at 03:30/04:15 — any daytime run is clear).
|
||
|
||
- [x] **Step 2: Push both years' tag scopes**
|
||
|
||
Run: `uv run stack bib sync-zotero --tag sup:2027_PFS_NPRM && uv run stack bib sync-zotero --tag sup:2026_PFS_NPRM`
|
||
Expected: each prints sync stats (`created: 1 …` for 2027; `skipped: 1` + tag refresh for 2026); zotero container restarted after each (CLI handles it).
|
||
|
||
- [x] **Step 3: Verify in zotero.sqlite**
|
||
|
||
```bash
|
||
uv run python - <<'EOF'
|
||
import sqlite3
|
||
z = sqlite3.connect("file:data/zotero/data/zotero.sqlite?mode=ro&immutable=1", uri=True)
|
||
iid = z.execute("SELECT itemID FROM items WHERE key='5ITGVDJV'").fetchone()
|
||
assert iid, "5ITGVDJV missing from Zotero"
|
||
kids = list(z.execute("SELECT path FROM itemAttachments WHERE parentItemID=?", (iid[0],)))
|
||
assert len(kids) == 3, kids
|
||
tags = {r[0] for r in z.execute("SELECT t.name FROM itemTags it JOIN tags t ON t.tagID=it.tagID WHERE it.itemID=?", (iid[0],))}
|
||
assert "sup:2027_PFS_NPRM" in tags, tags
|
||
t26 = {r[0] for r in z.execute("SELECT t.name FROM itemTags it JOIN tags t ON t.tagID=it.tagID JOIN items i ON i.itemID=it.itemID WHERE i.key='2KVJ2HKX'")}
|
||
assert "sup:2026_PFS_NPRM" in t26 and "file:rvu" in t26, t26
|
||
print("Zotero verified: 5ITGVDJV +", len(kids), "attachments; tags OK both years")
|
||
EOF
|
||
```
|
||
Expected: `Zotero verified: 5ITGVDJV + 3 attachments; tags OK both years`. Also confirm zotero container is back up (`docker ps | grep zotero`).
|
||
|
||
- [x] **Step 4: Comment verification on [#626](https://git.fhirworx.io/homelab/stack/issues/626) and close it.**
|
||
|
||
---
|
||
|
||
### Task 5: Close the loop
|
||
|
||
**Files:**
|
||
- Modify: `docs/superpowers/plans/2026-08-17-zotero-rule-sync-p38.md` (check boxes)
|
||
- Memory: `~/.claude/projects/-home-kert-stack/memory/` (update `cms_mail_pipeline.md`; new incident memory)
|
||
|
||
- [x] **Step 1: Push commits** (`git push`), confirm CI green on the two commits (or file/watch per gitea-tracker-ops; the auto-filer leaves stale failure issues — check latest run on HEAD).
|
||
- [x] **Step 2: Close [#624](https://git.fhirworx.io/homelab/stack/issues/624) and [#627](https://git.fhirworx.io/homelab/stack/issues/627)** if the `closes #N` footers didn't auto-close them (Gitea closes on push to main), each with a comment linking the commit SHA.
|
||
- [x] **Step 3: Close the [P38 milestone](https://git.fhirworx.io/homelab/stack/milestone/39).**
|
||
- [x] **Step 4: Append a "P38 build outcomes" section** to this plan (what shipped, SHAs, verification results — pattern from P36/P37 specs), commit as `docs(plan): P38 outcomes (refs #624-#627)`.
|
||
- [x] **Step 5: Update memory** — `cms_mail_pipeline.md` gains the rules-scope nightly step; new memory `zotero-rule-sync-incident.md` (upsert used to clobber curated tags; sup: tags are load-bearing for pfs.nprm discovery; sync coverage now email + federal-register).
|
||
|
||
## P38 build outcomes (2026-08-17)
|
||
|
||
Issues [#624](https://git.fhirworx.io/homelab/stack/issues/624)–[#628](https://git.fhirworx.io/homelab/stack/issues/628), milestone [P38](https://git.fhirworx.io/homelab/stack/milestone/39). Executed same-day on main (deliberate worktree deviation: mail-poller live-mounts this checkout's `./src`, and Tasks 3–4 operate on the live databases here).
|
||
|
||
- **#624 fixed (91c3306)**: `Store.upsert` now unions existing ∪ incoming tags and collections on the URL-dedupe path; two regression tests (both red pre-fix). 137 store+sync tests green.
|
||
- **#627 fixed (c9e8e1e)**: `_gen_zotero_sync` emits a second nightly step, `sync-zotero --tag source:federal-register --no-hold` (78 items); regenerated `zotero-sync.yml`; generator deterministic.
|
||
- **#625 repaired (data-only, closed with verification)**: re-ran `fetch_nprm_addenda --year {2026,2027} --register` — attach guards held (`SKIP attach`), full tag sets restored on `2KVJ2HKX` and `5ITGVDJV`; `_discover_addendum_b` resolves for both years; wiper simulation on a bib copy (comment-farm-style upsert, same URL + canonical FR tag set) confirms `sup:`/`file:rvu` survive.
|
||
- **#628 found en route + fixed (18c1066)**: P37's worktree run left a dead `.worktrees/p37/...` `storage_path` on the CY2027 Addendum B attachment (`attach_file` stored the unresolved path; file itself was safe in canonical storage). `attach_file` now stores `dest.resolve()` (regression test with symlinked storage dir); the single affected row rewritten. Audit: no other `.worktrees` paths.
|
||
- **#626 done (closed with verification)**: `sync-zotero --tag sup:2027_PFS_NPRM` → created 1 item + 9 tags + 3 attachments; `--tag sup:2026_PFS_NPRM` → skipped (Zotero's snapshot already matched). zotero.sqlite now holds `5ITGVDJV` (statute-typed, 91 FR 43842, field parity with 2KVJ2HKX) with all 3 files physically in storage (39 MB PDF, 2.6 MB TXT, 1.2 MB Addendum B xlsx). Zotero container held during sync, restarted healthy.
|
||
- Plan deviations: the Task 3 wiper simulation ran offline (the FR API 404s on the bare-document-number URL form; same upsert, locally constructed item). `nature.csl` working-tree drift observed (Zotero app self-update, container-owned) — left uncommitted.
|
||
|
||
## Self-Review
|
||
|
||
- Spec coverage: #624→Task 1, #625→Task 3, #626→Task 4, #627→Task 2, closure/tracking→Task 5. No gaps.
|
||
- Placeholder scan: none — every step has runnable code/commands and expected output.
|
||
- Type consistency: `Store.get` returns `Item` with `.tags`/`.collections` lists (verified in src); `list_items(tag=)` verified; `nprm._addendum_b_path(year)` verified private helper exists.
|
||
- Ordering hazard called out twice (Task 1 before Task 3) since mail-poller runs live-mounted source every 600s.
|