---
phase: 58-service-extraction
plan: 04
type: execute
wave: 3
depends_on: ["058-01", "058-02", "058-03"]
files_modified:
  - paperforge/services/__init__.py
  - paperforge/services/sync_service.py
  - paperforge/worker/sync.py
  - paperforge/worker/__init__.py
  - tests/unit/adapters/__init__.py
  - tests/unit/services/__init__.py
  - tests/unit/services/test_sync_service.py
autonomous: true
requirements: [SYNC-04, SYNC-05]

must_haves:
  truths:
    - "paperforge/services/sync_service.py exists with a SyncService class"
    - "SyncService uses adapter modules (zotero_paths, bbt, obsidian_frontmatter) as dependencies"
    - "SyncService.run_sync() produces the same result as sync.py's original run_selection_sync()"
    - "worker/sync.py imports SyncService and delegates to it — no business logic remains"
    - "worker/sync.py still exports extracted functions (backward-compat import aliases remain)"
    - "All test suites pass with no regressions"
    - "paperforge/worker/__init__.py still exports all names for backward compat"
  artifacts:
    - path: paperforge/services/sync_service.py
      provides: "SyncService class wrapping adapters with orchestration methods"
      exports: ["SyncService"]
      min_lines: 40
    - path: tests/unit/services/test_sync_service.py
      provides: "Unit tests for SyncService orchestration"
      min_lines: 80
    - path: paperforge/services/__init__.py
      provides: "Package marker for services module"
  key_links:
    - from: paperforge/services/sync_service.py
      to: paperforge/adapters/zotero_paths.py
      via: "import path functions"
    - from: paperforge/services/sync_service.py
      to: paperforge/adapters/bbt.py
      via: "import BBT functions"
    - from: paperforge/services/sync_service.py
      to: paperforge/adapters/obsidian_frontmatter.py
      via: "import frontmatter functions"
    - from: paperforge/worker/sync.py
      to: paperforge/services/sync_service.py
      via: "delegates run_selection_sync to SyncService"
    - from: paperforge/worker/__init__.py
      to: paperforge/worker/sync.py
      via: "continues re-exporting from sync.py (backward compat)"
---
<objective>
Create the SyncService class that wraps all three adapter modules, thin sync.py to a pure dispatch layer, and update all import paths to ensure backward compatibility.

**Purpose:** This is the capstone — the 3 extracted adapter modules (zotero_paths, bbt, obsidian_frontmatter) are combined into a SyncService class that provides the core sync orchestration. worker/sync.py sheds its business logic and becomes a thin CLI dispatch layer. All existing import paths remain functional.

**Output:** 6 new/modified files. All existing tests pass with zero regressions.
</objective>

<execution_context>
@file:///C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@file:///C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/worker/sync.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/worker/__init__.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/worker/_utils.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/adapters/zotero_paths.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/adapters/bbt.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/adapters/obsidian_frontmatter.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/config.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/commands/sync.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/cli.py

<interfaces>
SyncService public API to design:

```python
class SyncService:
    """Sync orchestration — wraps adapter modules for the sync workflow."""

    def __init__(self, vault: Path, verbose: bool = False):
        """Initialize SyncService with vault path and dependencies."""
        ...

    def run_sync(self) -> dict:
        """Run the complete sync workflow.
        
        Returns dict with counts: {new, updated, skipped, failed, errors}
        Equivalent to the original run_selection_sync() return value.
        """
        ...

    def run_index_refresh(self, rebuild_index: bool = False) -> int:
        """Refresh the canonical asset index.
        
        Equivalent to the original run_index_refresh() return value.
        """
        ...
```

Adapter modules already created:
- `paperforge.adapters.zotero_paths`: obsidian_wikilink_for_pdf, absolutize_vault_path, obsidian_wikilink_for_path
- `paperforge.adapters.bbt`: _normalize_attachment_path, _identify_main_pdf, extract_authors, resolve_item_collection_paths, collection_fields, load_export_rows
- `paperforge.adapters.obsidian_frontmatter`: read_frontmatter_dict, _read_frontmatter_bool_from_text, update_frontmatter_field, load_control_actions, extract_preserved_deep_reading, has_deep_reading_content

Worker modules still used (NOT extracted):
- `paperforge.worker._utils`: pipeline_paths, read_json, write_json, slugify_filename, lookup_impact_factor, yaml_quote
- `paperforge.worker._domain`: load_domain_config, load_domain_collections, build_collection_lookup
- `paperforge.worker.base_views`: ensure_base_views
- `paperforge.worker.ocr`: validate_ocr_meta
- `paperforge.pdf_resolver`: resolve_pdf_path
- `paperforge.config`: load_vault_config
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 1: Create SyncService class + services package</name>
  <files>
    paperforge/services/__init__.py
    paperforge/services/sync_service.py
  </files>
  <action>
    Step 1.1: Create paperforge/services/__init__.py:
    ```python
    """PaperForge service layer — orchestration classes."""
    from paperforge.services.sync_service import SyncService

    __all__ = ["SyncService"]
    ```

    Step 1.2: Create paperforge/services/sync_service.py. The SyncService class wraps the core sync workflow previously embedded inline in `run_selection_sync()`:

    ```python
    from __future__ import annotations

    import logging
    from pathlib import Path

    from paperforge.config import load_vault_config
    from paperforge.pdf_resolver import resolve_pdf_path
    from paperforge.worker._domain import load_domain_config
    from paperforge.worker._utils import (
        lookup_impact_factor,
        pipeline_paths,
        read_json,
        slugify_filename,
        write_json,
    )

    # Adapter imports (extracted in Phase 58)
    from paperforge.adapters.bbt import collection_fields, load_export_rows
    from paperforge.adapters.obsidian_frontmatter import load_control_actions
    from paperforge.adapters.zotero_paths import (
        obsidian_wikilink_for_path,
        obsidian_wikilink_for_pdf,
    )

    logger = logging.getLogger(__name__)


    class SyncService:
        """Sync orchestration — wraps adapter modules for the sync workflow.

        Extracted from paperforge.worker.sync.run_selection_sync() during
        Phase 58 (Service Extraction). Uses adapter modules for data parsing
        and handles cross-cutting orchestration concerns.
        """

        def __init__(self, vault: Path, verbose: bool = False):
            self.vault = vault
            self.verbose = verbose
            self.paths = pipeline_paths(vault)
            self.config = load_domain_config(self.paths)
            self._init_zotero_dir()

        def _init_zotero_dir(self) -> None:
            cfg = load_vault_config(self.vault)
            self.zotero_dir = self.vault / cfg.get("system_dir", "System") / "Zotero"

        def run_sync(self) -> dict:
            """Run the complete sync workflow.

            Orchestrates:
            1. Loads export rows via adapters/bbt
            2. Resolves PDFs via paperforge.pdf_resolver
            3. Generates wikilinks via adapters/zotero_paths
            4. Validates OCR state via ocr.validate_ocr_meta
            5. Writes/updates formal notes

            Returns dict with: {new, updated, skipped, failed, errors}
            """
            from paperforge.worker.base_views import ensure_base_views
            from paperforge.worker.ocr import validate_ocr_meta

            ensure_base_views(self.vault, self.paths, self.config)

            domain_lookup = {
                entry["export_file"]: entry["domain"]
                for entry in self.config["domains"]
            }
            written = 0
            updated = 0

            for export_path in sorted(self.paths["exports"].glob("*.json")):
                domain = domain_lookup.get(export_path.name, export_path.stem)
                for item in load_export_rows(export_path):
                    # --- PDF resolution ---
                    pdf_attachments = [
                        a for a in item.get("attachments", [])
                        if a.get("contentType") == "application/pdf"
                    ]
                    has_pdf = bool(pdf_attachments)
                    raw_pdf_path = pdf_attachments[0].get("path", "") if pdf_attachments else ""
                    resolved_pdf = resolve_pdf_path(
                        raw_pdf_path, has_pdf, self.vault, self.zotero_dir
                    )

                    # --- Collection metadata ---
                    collection_meta = collection_fields(item.get("collections", []))

                    # --- OCR state ---
                    meta_path = self.paths["ocr"] / item["key"] / "meta.json"
                    meta = read_json(meta_path) if meta_path.exists() else {}
                    if meta:
                        validated_ocr_status, validated_error = validate_ocr_meta(
                            self.paths, meta
                        ) if meta else ("pending", "")
                        meta["ocr_status"] = validated_ocr_status
                        if validated_error:
                            meta["error"] = validated_error
                            write_json(meta_path, meta)

                    # --- Path resolution ---
                    fulltext_md_path = obsidian_wikilink_for_path(
                        self.vault,
                        meta.get("fulltext_md_path", "") or meta.get("markdown_path", ""),
                    )
                    ocr_status = meta.get("ocr_status", "pending")
                    record_ocr_status = "nopdf" if not has_pdf or not resolved_pdf else ocr_status

                    # --- Note metadata ---
                    creators = item.get("creators", [])
                    first_author = ""
                    for c in creators:
                        if c.get("creatorType") == "author":
                            first_author = (
                                f"{c.get('firstName', '')} {c.get('lastName', '')}".strip()
                            )
                            break
                    journal = item.get("journal", "")
                    extra = item.get("extra", "")
                    impact_factor = lookup_impact_factor(journal, extra, self.vault)

                    # --- Supplementary wikilinks ---
                    supplementary_wikilinks = []
                    for supp_path in item.get("supplementary", []):
                        if supp_path:
                            wikilink = obsidian_wikilink_for_pdf(
                                supp_path, self.vault, self.zotero_dir
                            )
                            if wikilink:
                                supplementary_wikilinks.append(wikilink)

                    pdf_wikilink = (
                        obsidian_wikilink_for_pdf(resolved_pdf, self.vault, self.zotero_dir)
                        if resolved_pdf else ""
                    )

                    # Phase 37: library-records deprecated — skip creation.
                    # Formal notes carry workflow flags directly.
                    updated += 1

            if self.verbose:
                logger.info(
                    "selection-sync: wrote %d records, updated %d records",
                    written, updated,
                )
            return {"new": written, "updated": updated, "skipped": 0, "failed": 0, "errors": []}
    ```

    Step 1.3: Verify importability:
    ```
    python -c "from paperforge.services import SyncService; print('SyncService import OK')"
    ```

    Step 1.4: Verify working with vault path (no actual run — just construction):
    ```
    python -c "
    from paperforge.services import SyncService
    from pathlib import Path
    # Construction only — no IO
    import inspect
    sig = inspect.signature(SyncService.__init__)
    print(f'SyncService.__init__ params: {list(sig.parameters.keys())}')
    "
    ```
  </action>
  <verify>
    <automated>
      python -c "from paperforge.services.sync_service import SyncService; from paperforge.services import SyncService as S2; print('SyncService import OK')"
    </automated>
  </verify>
  <done>
    paperforge/services/__init__.py and paperforge/services/sync_service.py exist. SyncService importable and constructable.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Thin sync.py to dispatch-only + remove duplicate code</name>
  <files>
    paperforge/worker/sync.py
  </files>
  <behavior>
    - sync.py's run_selection_sync delegates to SyncService.run_sync() and produces identical output
    - sync.py's run_index_refresh stays mostly unchanged (it's already thin)
    - All backward-compat import aliases remain at the top of sync.py
    - Original function bodies that existed in sync.py AND were extracted to adapters are REMOVED to eliminate dead code
    - sync.py total size drops significantly (from 1844 lines to approximately 900-1000 lines)
  </behavior>
  <action>
    Step 2.1: Replace `run_selection_sync()` body in sync.py to delegate to SyncService. The function signature remains the same for backward compatibility.

    At the top of sync.py, add the import:
    ```python
    from paperforge.services.sync_service import SyncService
    ```

    **CRITICAL — Remove extracted function bodies:** After Plans 01-03 added backward-compat import aliases, the ORIGINAL function bodies in sync.py are now dead code. Delete them:

    DELETE the following function bodies from sync.py (the import aliases handle them):
    - `obsidian_wikilink_for_pdf` (lines 195-225) — now from adapters/zotero_paths
    - `absolutize_vault_path` (lines 228-238) — now from adapters/zotero_paths
    - `obsidian_wikilink_for_path` (lines 241-250) — now from adapters/zotero_paths
    - `_normalize_attachment_path` (lines 284-337) — now from adapters/bbt
    - `_identify_main_pdf` (lines 340-381) — now from adapters/bbt
    - `extract_authors` (lines 269-281) — now from adapters/bbt
    - `resolve_item_collection_paths` (lines 183-192) — now from adapters/bbt
    - `collection_fields` (lines 253-266) — now from adapters/bbt
    - `_read_frontmatter_bool_from_text` (lines 36-40) — now from adapters/obsidian_frontmatter
    - `_read_frontmatter_optional_bool_from_text` (lines 43-47) — now from adapters/obsidian_frontmatter
    - `_legacy_control_flags` (lines 50-63) — now from adapters/obsidian_frontmatter
    - `_add_missing_frontmatter_fields` (lines 679-696) — now from adapters/obsidian_frontmatter
    - `update_frontmatter_field` (lines 699-708) — now from adapters/obsidian_frontmatter
    - `load_control_actions` (lines 711-736) — now from adapters/obsidian_frontmatter
    - `extract_preserved_deep_reading` (lines 598-611) — now from adapters/obsidian_frontmatter
    - `has_deep_reading_content` (lines 614-660) — now from adapters/obsidian_frontmatter
    - `_extract_section` (lines 663-674) — now from adapters/obsidian_frontmatter

    **DO NOT DELETE:**
    - `load_export_rows` (lines 384-445) — Wait, this WAS in Plan 02 bbt.py. Delete it too. Actually, the import alias handles it. But `load_export_rows` calls internal functions from bbt... no, the alias import means `paperforge.worker.sync.load_export_rows` is resolved to `paperforge.adapters.bbt.load_export_rows` at import time. So the original body can be safely removed.

    Actually, I need to be very careful here. Let me reconsider: When sync.py has:
    ```python
    from paperforge.adapters.bbt import load_export_rows, ...
    ```
    
    And ORIGINALLY had:
    ```python
    def load_export_rows(path): ...
    ```
    
    After adding the import alias and removing the function body, any code in sync.py that references `load_export_rows` will use the imported one from the adapter. This should work. BUT there's a subtlety: if any function in sync.py was defined AFTER the original function and calls it, it will now call the adapter's version instead. Since the logic is identical (verbatim copy), this is safe.

    **Step 2.2: Replace run_selection_sync body:**
    ```python
    def run_selection_sync(vault: Path, verbose: bool = False, json_output: bool = False) -> int | dict:
        """Run selection sync — delegates to SyncService.run_sync().
        
        Thin dispatch layer: creates SyncService, calls run_sync(), formats output.
        """
        service = SyncService(vault, verbose=verbose)
        result = service.run_sync()
        if json_output:
            return result
        print(f"selection-sync: wrote {result['new']} records, updated {result['updated']} records")
        return 0
    ```

    **Step 2.3: Update run_index_refresh to use SyncService** (optional — only if it makes sense):
    The current `run_index_refresh` is already fairly thin (it delegates to asset_index.build_index). Keep it mostly as-is but update its imports to use the adapter modules directly instead of sync.py's old functions:
    
    The current `run_index_refresh` (lines 1725-1844) calls:
    - `pipeline_paths` (stays in _utils.py — not extracted)
    - `load_domain_config` (stays in _domain.py — not extracted)
    - `ensure_base_views` (stays in base_views.py — not extracted)
    - `load_export_rows` (now from adapter — alias handles it)
    - `collection_fields` (now from adapter — alias handles it)
    - `obsidian_wikilink_for_path` (now from adapter — alias handles it)
    - `obsidian_wikilink_for_pdf` (now from adapter — alias handles it)
    - `update_frontmatter_field` (now from adapter — alias handles it)
    - `_legacy_control_flags` (now from adapter — alias handles it)
    - `migrate_to_workspace` (stays in sync.py — not extracted)

    The alias imports already cover all adapter references. No changes needed to `run_index_refresh` body — just the function removal above.

    **Step 2.4: Clean up unused imports from sync.py.** After removing function bodies, remove imports that were only used by the extracted functions:
    - `urllib.parse` — only used by `search_arxiv`, not extracted functions. KEEP.
    - Do NOT remove `os`, `re`, `Path`, `Optional` — these are still used by remaining functions.
    - Check `html` — used by `search_pubmed`. KEEP.
    - Check `ET` — used by `search_pubmed`, `_pubmed_abstract_and_doi_map`, `search_arxiv`. KEEP.

    Step 2.5: Verify the thinned sync.py:
    ```
    python -c "
    import paperforge.worker.sync
    # Verify key functions still accessible (backward compat)
    from paperforge.worker.sync import (
        load_export_rows, _normalize_attachment_path, obsidian_wikilink_for_pdf,
        update_frontmatter_field, has_deep_reading_content,
        run_selection_sync, run_index_refresh
    )
    print('All sync.py imports OK')
    "
    ```
  </action>
  <verify>
    <automated>
      python -c "import paperforge.worker.sync; from paperforge.worker.sync import run_selection_sync, run_index_refresh, load_export_rows, obsidian_wikilink_for_pdf, update_frontmatter_field, has_deep_reading_content; print('sync.py thinned, all imports OK')"
    </automated>
  </verify>
  <done>
    sync.py reduced in size. run_selection_sync delegates to SyncService. All backward-compat import aliases remain. No dead code from extracted functions.
  </done>
</task>

<task type="auto">
  <name>Task 3: Create SyncService tests + verify full regression suite</name>
  <files>
    tests/unit/services/__init__.py
    tests/unit/services/test_sync_service.py
    tests/unit/adapters/__init__.py
  </files>
  <action>
    Step 3.1: Create tests/unit/services/__init__.py:
    ```python
    ```
    (empty, just package init)

    Step 3.2: Create tests/unit/services/test_sync_service.py:
    ```python
    from __future__ import annotations

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

    import pytest

    from paperforge.services.sync_service import SyncService


    class TestSyncServiceConstruction:
        """SyncService can be constructed and has expected dependencies."""

        def test_construction(self, tmp_path: Path) -> None:
            vault = tmp_path / "vault"
            vault.mkdir()
            (vault / "paperforge.json").write_text(
                '{"vault_config": {"system_dir": "99_System", "resources_dir": "Resources"}}',
                encoding="utf-8",
            )
            (vault / "99_System" / "PaperForge" / "exports").mkdir(parents=True)
            (vault / "99_System" / "PaperForge" / "exports" / "test.json").write_text("[]", encoding="utf-8")
            (vault / "99_System" / "PaperForge" / "config").mkdir(parents=True)
            (vault / "99_System" / "PaperForge" / "config" / "domain-collections.json").write_text(
                '{"domains": []}', encoding="utf-8"
            )

            service = SyncService(vault)
            assert service.vault == vault
            assert service.paths is not None

        def test_verbose_flag(self, tmp_path: Path) -> None:
            vault = tmp_path / "vault"
            vault.mkdir()
            service = SyncService(vault, verbose=True)
            assert service.verbose is True

        def test_zotero_dir_initialized(self, tmp_path: Path) -> None:
            vault = tmp_path / "vault"
            vault.mkdir()
            (vault / "paperforge.json").write_text(
                '{"vault_config": {"system_dir": "99_System"}}', encoding="utf-8"
            )
            service = SyncService(vault)
            assert "Zotero" in str(service.zotero_dir)
            assert "99_System" in str(service.zotero_dir)


    class TestSyncServiceRunSync:
        """SyncService.run_sync() produces correct results."""

        def test_run_sync_empty_vault(self, tmp_path: Path) -> None:
            """Empty exports directory produces zero-count result."""
            vault = tmp_path / "vault"
            vault.mkdir()
            (vault / "paperforge.json").write_text(
                '{"vault_config": {"system_dir": "99_System", "resources_dir": "Resources", "literature_dir": "Literature"}}',
                encoding="utf-8",
            )
            (vault / "99_System" / "PaperForge" / "exports").mkdir(parents=True)
            (vault / "99_System" / "PaperForge" / "config").mkdir(parents=True)
            (vault / "99_System" / "PaperForge" / "config" / "domain-collections.json").write_text(
                '{"domains": []}', encoding="utf-8"
            )
            (vault / "99_System" / "PaperForge" / "indexes").mkdir(parents=True)
            (vault / "99_System" / "PaperForge" / "ocr").mkdir(parents=True)
            (vault / "Resources" / "Literature").mkdir(parents=True)

            service = SyncService(vault)
            result = service.run_sync()
            assert isinstance(result, dict)
            assert result["new"] == 0
            assert result["updated"] == 0
            assert result["errors"] == []

        def test_run_sync_with_export(self, tmp_path: Path) -> None:
            """Export with items produces updated > 0."""
            vault = tmp_path / "vault"
            vault.mkdir()
            (vault / "paperforge.json").write_text(
                '{"vault_config": {"system_dir": "99_System", "resources_dir": "Resources", "literature_dir": "Literature"}}',
                encoding="utf-8",
            )
            exports_dir = vault / "99_System" / "PaperForge" / "exports"
            exports_dir.mkdir(parents=True)
            cfg_dir = vault / "99_System" / "PaperForge" / "config"
            cfg_dir.mkdir(parents=True)
            (cfg_dir / "domain-collections.json").write_text(
                '{"domains": [{"export_file": "test.json", "domain": "Ortho"}]}',
                encoding="utf-8",
            )
            (vault / "99_System" / "PaperForge" / "indexes").mkdir(parents=True)
            (vault / "99_System" / "PaperForge" / "ocr").mkdir(parents=True)
            (vault / "Resources" / "Literature").mkdir(parents=True)

            import json
            export_data = {
                "items": [{
                    "key": "TEST001",
                    "itemKey": "TEST001",
                    "title": "Test Article",
                    "date": "2024",
                    "DOI": "10.1234/test",
                    "attachments": [{
                        "path": "storage:TEST001/article.pdf",
                        "contentType": "application/pdf",
                        "title": "PDF",
                        "size": 1000,
                    }],
                    "collections": {},
                    "creators": [],
                    "publicationTitle": "Test Journal",
                    "extra": "",
                }],
                "collections": {},
            }
            (exports_dir / "test.json").write_text(json.dumps(export_data), encoding="utf-8")

            service = SyncService(vault)
            result = service.run_sync()
            assert result["updated"] >= 1
    ```

    Step 3.3: Run SyncService tests:
    ```
    python -m pytest tests/unit/services/test_sync_service.py -v --tb=short
    ```

    Step 3.4: Run the COMPLETE regression suite. Fix any failures:
    ```
    python -m pytest tests/unit/ -v --tb=short
    ```

    Step 3.5: Run critical downstream test suites:
    ```
    python -m pytest tests/test_path_normalization.py tests/test_repair.py tests/test_selection_sync_pdf.py tests/test_ocr_state_machine.py -v --tb=short 2>&1 | tail -40
    ```

    Step 3.6: Run ALL tests (may be slow) to catch any edge cases:
    ```
    python -m pytest tests/ -v --tb=short -x 2>&1 | tail -60
    ```
    If this fails, fix the failing test. Focus on:
    - Import errors (module not found, name not exported)
    - Patch path errors (tests patch `paperforge.worker.sync.function_name` — ensure the patched name still resolves)

    **CRITICAL: Patch path updates.** Many tests use:
    ```python
    patch("paperforge.worker.sync.load_export_rows", ...)
    ```
    After extraction, this patch applies to the alias import. Python's `unittest.mock` patches replace the name at the module level, so `paperforge.worker.sync.load_export_rows` → after extraction, this is the alias-bound reference to `paperforge.adapters.bbt.load_export_rows`. When patched, it replaces the alias binding, which is fine for tests since they're patching at the `paperforge.worker.sync` level. The patch should still work correctly.

    But there's a subtle issue: if the test does `from paperforge.worker.sync import load_export_rows` at the top of the test file, and then later patches `paperforge.worker.sync.load_export_rows`, the patch affects the module attribute but NOT the already-imported name. Let me check existing tests.

    Looking at `test_path_normalization.py`:
    ```python
    from paperforge.worker.sync import (
        _identify_main_pdf,
        _normalize_attachment_path,
        load_export_rows,
        obsidian_wikilink_for_pdf,
    )
    ```
    These tests do NOT patch these functions — they call them directly. So the import-wired alias works fine.

    Looking at `test_selection_sync_pdf.py`:
    ```python
    patch("paperforge.worker.sync.load_export_rows") as mock_export,
    ```
    This patches at `paperforge.worker.sync.load_export_rows`, which is the alias. When the code under test calls `paperforge.worker.sync.load_export_rows()`, the patch intercepts. If the code under test does `from paperforge.adapters.bbt import load_export_rows` and calls it directly, the patch would NOT catch it. BUT the code under test (run_selection_sync in the original) was calling `sync.load_export_rows` (unqualified). After extraction to SyncService, the SyncService imports directly from the adapter. So if a test patches `paperforge.worker.sync.load_export_rows` but the SyncService uses `paperforge.adapters.bbt.load_export_rows`, the patch won't work.

    This is a critical insight! After the refactoring, tests that patch `paperforge.worker.sync.*` will fail if the code under test now imports directly from the adapter.

    For this phase, the old `run_selection_sync` in sync.py still exists (but thinned). After thinning, it delegates to SyncService which calls adapter functions directly. So:
    - Tests that mock `paperforge.worker.sync.load_export_rows` and then call `run_selection_sync()` will have their mock IGNORED because `run_selection_sync` calls `SyncService.run_sync()` which uses `paperforge.adapters.bbt.load_export_rows`.
    - This WILL break existing tests.

    **SOLUTION: Update all test patches.** Replace `paperforge.worker.sync.X` with `paperforge.adapters.bbt.X` (or the appropriate adapter module) for tests that test `run_selection_sync` or `run_index_refresh`.

    Let me check exactly which tests will be affected. I'll need to:
    1. Find all `patch("paperforge.worker.sync.*")` calls in tests
    2. Determine which ones patch functions that moved to adapters
    3. Update the patch paths

    This is a significant but necessary change. Let me list what needs updating:

    `patch("paperforge.worker.sync.load_export_rows")` → `patch("paperforge.adapters.bbt.load_export_rows")`
    `patch("paperforge.worker.sync.load_domain_config")` → still at worker level (in _domain.py, not extracted)
    `patch("paperforge.worker.sync.run_selection_sync")` → still in sync.py (thin wrapper)
    `patch("paperforge.worker.sync.run_index_refresh")` → still in sync.py
    `patch("paperforge.worker.sync.pipeline_paths")` → still in _utils.py

    Actually wait — `load_domain_config` is imported into sync.py from `_domain.py`. It was never defined in sync.py — it's just imported. So `patch("paperforge.worker.sync.load_domain_config")` patches the import binding in sync.py. That still works because the import alias is still at sync.py level.

    Similarly, `paperforge.worker.sync.load_export_rows` after extraction is an import alias. The patch intercepts that alias. But SyncService imports from `paperforge.adapters.bbt.load_export_rows` directly. So patching `paperforge.worker.sync.load_export_rows` does NOT affect SyncService.

    This means tests that patch `paperforge.worker.sync.load_export_rows` and then call `run_selection_sync` (which now delegates to SyncService) will have the patch bypassed.

    I need to update patch paths. Let me find all affected tests:

    From the grep results:
    - `tests/test_selection_sync_pdf.py`: patches `paperforge.worker.sync.load_export_rows`, `paperforge.worker.sync.load_domain_config` — these test `run_index_refresh` → SyncService now loads export rows directly from bbt adapter
    - `tests/test_pdf_resolver.py`: imports `load_export_rows` from sync.py in function-local imports (not patches) — these will work via the alias

    The safest approach: update `test_selection_sync_pdf.py` patch paths. Also check `test_e2e_pipeline.py`.

    For `test_selection_sync_pdf.py`: the tests construct mock vaults and call functions that use `load_export_rows`. After extraction, `run_index_refresh` still imports `load_export_rows` from `paperforge.worker.sync` (via the alias). But when SyncService calls methods that use `load_export_rows`, it uses the direct adapter import. So tests that patch the sync-level alias and then exercise code through `run_index_refresh` → which calls `asset_index.build_index` → which uses `load_export_rows` from the lazy import...

    Hmm, this is getting complex. Let me trace the actual call path:

    `test_selection_sync_pdf.py` test calls `run_index_refresh(vault)` which calls:
    1. `pipeline_paths(vault)` — in _utils.py
    2. `load_domain_config(paths)` — in _domain.py
    3. `ensure_base_views(vault, paths, config)` — in base_views.py
    4. `load_export_rows(export_path)` — **this is the critical one**

    After extraction, step 4 calls `paperforge.worker.sync.load_export_rows` which is the alias to `paperforge.adapters.bbt.load_export_rows`. So patching `paperforge.worker.sync.load_export_rows` SHOULD work because `run_index_refresh` references `load_export_rows` unqualified (it's a name in the module scope).

    Wait, but after the extraction, within sync.py, `load_export_rows` is the adapter alias. So when `run_index_refresh` calls `load_export_rows(...)`, Python resolves this to the module-level name, which is the alias bound at import time. Patching `paperforge.worker.sync.load_export_rows` replaces this module-level name with the mock. So the patch DOES work!

    Actually the same applies to SyncService. If a test patches `paperforge.worker.sync.load_export_rows` and then calls `run_selection_sync(vault)`, which creates `SyncService` and calls `service.run_sync()`, within `SyncService.run_sync()` the reference to `load_export_rows` is `from paperforge.adapters.bbt import load_export_rows` — this is a module-level import in sync_service.py. Patching `paperforge.worker.sync.load_export_rows` does NOT affect `paperforge.services.sync_service.load_export_rows`.

    So tests that patch `paperforge.worker.sync.load_export_rows` and then exercise code through SyncService will break.

    But tests that patch `paperforge.worker.sync.load_export_rows` and then exercise code through `run_index_refresh` (which still lives in sync.py and uses the unqualified name) will work.

    The affected tests are:
    1. `test_selection_sync_pdf.py` — calls `run_index_refresh` and patches `paperforge.worker.sync.load_export_rows`. This should STILL work because `run_index_refresh` still lives in sync.py.
    2. `tests/test_e2e_pipeline.py` — might call `run_selection_sync` or `run_index_refresh`. Need to check.

    So actually, since `run_index_refresh` still lives in sync.py and calls `load_export_rows` unqualified (which resolves to the module-level alias), patching `paperforge.worker.sync.load_export_rows` still works for `run_index_refresh`.

    And for `run_selection_sync`, after thinning, it delegates to SyncService. If a test calls `run_selection_sync` and patches `paperforge.worker.sync.load_export_rows`, the patch will NOT affect SyncService. But looking at the actual tests:

    - `test_selection_sync_pdf.py` only tests `run_index_refresh`, not `run_selection_sync`
    - Other tests that patch `run_selection_sync` itself (not its internals) will still work because `run_selection_sync` is still at sync.py level

    So the main concern is: are there ANY tests that:
    1. Patch an adapter function at the `paperforge.worker.sync.X` level
    2. AND test code that goes through SyncService (which imports directly from adapter)

    Looking at the callers of `run_selection_sync`:
    - `cli.py`: `from paperforge.worker.sync import run_selection_sync` and calls it. Tests that mock at `cli.run_selection_sync` level are fine.
    - `commands/sync.py`: imports from cli or sync. Tests that mock at `commands.sync` level are fine.
    - `ocr.py`: `from paperforge.worker import sync as _sync` then `_sync.run_selection_sync()`. If a test patches `paperforge.worker.sync.run_selection_sync`, it patches the module alias, and `_sync.run_selection_sync()` (which is `sync.run_selection_sync`) picks up the patch. Still works.

    Actually I think we're OK for now. The `run_selection_sync` function still exists in sync.py (just thinned). The backward-compat aliases ensure old import paths work. The main risk is if a test:
    1. Patches `paperforge.worker.sync.load_export_rows` 
    2. Then calls a function in sync.py that uses `load_export_rows`

    Since `run_index_refresh` in sync.py refers to `load_export_rows` (unqualified, which resolves to the alias), the patch on `paperforge.worker.sync.load_export_rows` works.

    But `run_index_refresh` (lines 1725-1844) calls `load_export_rows` at line 1752. Since the alias is at module level, when run_index_refresh calls `load_export_rows(...)`, Python resolves `load_export_rows` in the module's namespace, which after import-time alias binding is the adapter's function. When a test patches `paperforge.worker.sync.load_export_rows`, it replaces that namespace entry. So the patch IS effective.

    Great, so we're safe. Let me not over-think this and move forward.

    Actually, I realize the e2e tests might be an issue. Let me check `test_e2e_pipeline.py`:
    ```python
    from paperforge.worker.sync import run_index_refresh, run_selection_sync
    ```
    These import the functions at test module level. These will import the thinned `run_selection_sync` and the existing `run_index_refresh`. Should work fine.

    Let me just proceed with writing the tasks.
  </action>
  <verify>
    <automated>
      python -m pytest tests/unit/services/test_sync_service.py -v --tb=short 2>&1
    </automated>
  </verify>
  <done>
    tests/unit/services/test_sync_service.py passes with SyncService construction and run_sync tests. All adapter __init__.py files exist.
  </done>
</task>

</tasks>

<verification>
1. `python -c "from paperforge.services import SyncService; print('SyncService import OK')"` — service importable
2. `python -c "import paperforge.worker.sync; from paperforge.worker.sync import run_selection_sync, load_export_rows, obsidian_wikilink_for_pdf, update_frontmatter_field, has_deep_reading_content; print('backward compat OK')"` — sync.py backwards compat
3. `python -m pytest tests/unit/services/test_sync_service.py -v --tb=short` — SyncService tests pass
4. `python -m pytest tests/unit/adapters/ -v --tb=short` — all adapter tests pass
5. `python -m pytest tests/test_path_normalization.py tests/test_repair.py tests/test_selection_sync_pdf.py -v --tb=short 2>&1 | tail -30` — critical downstream tests pass
6. `python -m pytest tests/test_ocr_state_machine.py tests/test_e2e_pipeline.py -v --tb=short -x 2>&1 | tail -30` — more downstream tests pass
7. All worker modules import without errors
</verification>

<success_criteria>
1. paperforge/services/sync_service.py exists with SyncService class wrapping all 3 adapters
2. paperforge/worker/sync.py reduced to thin dispatch: run_selection_sync delegates to SyncService
3. All original function bodies extracted to adapters are REMOVED from sync.py (no dead code)
4. paperforge/worker/sync.py still exports all previously available names (backward compat via import aliases)
5. All test suites pass (adapter tests + SyncService tests + downstream tests)
6. paperforge/worker/__init__.py still exports all previously available names
7. No behavioral regressions: sync `--json` output unchanged, index refresh unchanged
</success_criteria>

<output>
After completion, create `.planning/phases/058-service-extraction/058-04-SUMMARY.md`
</output>
