---
phase: 49-module-hardening
plan: 003
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/worker/asset_state.py
  - paperforge/worker/status.py
  - tests/test_asset_state.py
  - tests/test_status.py
autonomous: true
requirements:
  - HARDEN-06
  - HARDEN-07

must_haves:
  truths:
    - "Workspace integrity checks (note_path, workspace paths) are performed before returning '/pf-deep' — no broken next_step recommendation"
    - "paperforge status --json returns lifecycle_level_counts, health_aggregate, maturity_distribution as empty dicts {} (not null) when no canonical index exists"
  artifacts:
    - path: paperforge/worker/asset_state.py
      provides: "Reordered next_step checks — workspace integrity before /pf-deep"
      min_lines: 1
    - path: paperforge/worker/status.py
      provides: "Empty dict {} instead of None for index-derived aggregates"
      min_lines: 1
    - path: tests/test_asset_state.py
      provides: "Updated tests for new next_step ordering"
      min_lines: 1
    - path: tests/test_status.py
      provides: "Updated test for {} instead of None"
      min_lines: 1
  key_links:
    - from: asset_state.py:compute_next_step()
      to: "integrity checks before /pf-deep return"
      via: "moved note_path/workspace_paths checks"
      pattern: "note_path.*sync.*workspace_paths.*sync.*/pf-deep"
    - from: status.py:687-690
      to: "{} (empty dict) instead of None"
      via: "initialization with {}"
      pattern: "lifecycle_level_counts = \\{\\}"
---

<objective>
Fix two production safety gaps: reorder next-step checks in `asset_state.py` to validate workspace integrity before recommending `/pf-deep` (HARDEN-06), and initialize index-derived aggregates in `status.py` as empty dicts `{}` instead of `None` (HARDEN-07).

Purpose: Prevent recommending deep-read when workspace paths are missing, and prevent null-pointer crashes in downstream JSON consumers.

Output:
  - Modified `paperforge/worker/asset_state.py` — reordered checks
  - Modified `paperforge/worker/status.py` — safe empty dict initialization
  - Modified `tests/test_asset_state.py` — updated test expectations
  - Modified `tests/test_status.py` — updated test expectations
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md

<interfaces>
Current asset_state.py:compute_next_step() structure (lines 187-243):
```python
def compute_next_step(entry: dict) -> str:
    # 1. OCR error state → ocr
    if ocr_status in ("failed", "blocked", "done_incomplete"):
        return "ocr"
    # 2. No PDF → sync
    if not has_pdf:
        return "sync"
    # 3. PDF, OCR not started → ocr
    if ocr_status in ("pending", "nopdf"):
        return "ocr"
    # 4. OCR processing → ready
    if ocr_status == "processing":
        return "ready"
    # 5. OCR done, deep_read pending → /pf-deep   <-- should be AFTER integrity
    if ocr_status == "done" and deep_reading_status == "pending":
        return "/pf-deep"
    # 6. Formal note missing → sync               <-- should be BEFORE /pf-deep
    if not note_path:
        return "sync"
    # 7. Workspace path missing → sync            <-- should be BEFORE /pf-deep
    workspace_paths = [...]
    if not all(workspace_paths):
        return "sync"
    # 8. Ready
    return "ready"
```

Current status.py initialization (lines 683-690):
```python
lifecycle_level_counts = None       # should be {}
health_aggregate = None             # should be {}
maturity_distribution = None        # should be {}
if summary is not None:
    lifecycle_level_counts = summary["lifecycle_level_counts"]
    health_aggregate = summary["health_aggregate"]
    maturity_distribution = summary["maturity_distribution"]
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Reorder workspace integrity checks before /pf-deep in asset_state.py (HARDEN-06)</name>
  <files>paperforge/worker/asset_state.py, tests/test_asset_state.py</files>

  <behavior>
    - Test 1 (existing, must still pass): OCR error → "ocr", no PDF → "sync", OCR pending → "ocr", OCR processing → "ready", all done → "ready"
    - Test 2 (existing, must update): OCR done, deep_read pending, WITH note_path AND workspace paths → "/pf-deep"
    - Test 3 (new): OCR done, deep_read pending, BUT empty note_path → "sync" (not "/pf-deep")
    - Test 4 (new): OCR done, deep_read pending, note_path present BUT missing workspace path → "sync" (not "/pf-deep")
  </behavior>

  <action>
    === asset_state.py changes (HARDEN-06) ===
    
    Move the "note_path" check (currently step 6, lines 228-230) and "workspace_paths" check (currently step 7, lines 232-240) BEFORE the "/pf-deep" check (currently step 5, lines 224-226).
    
    Old order (lines 208-243):
    ```python
    # ... steps 1-4 (ocr error → ocr, no pdf → sync, ocr not started → ocr, processing → ready) ...
    
    # 5. OCR done, deep reading pending → /pf-deep
    if ocr_status == "done" and deep_reading_status == "pending":
        return "/pf-deep"
    
    # 6. Formal note missing → sync
    if not note_path:
        return "sync"
    
    # 7. Any workspace path missing → sync
    workspace_paths = [ ... ]
    if not all(workspace_paths):
        return "sync"
    
    # 8. Everything ready
    return "ready"
    ```
    
    New order:
    ```python
    # ... steps 1-4 (unchanged) ...
    
    # 5. Formal note missing → sync  (moved up: check integrity before /pf-deep)
    if not note_path:
        return "sync"
    
    # 6. Any workspace path missing → sync  (moved up: check integrity before /pf-deep)
    workspace_paths = [
        entry.get("fulltext_path", ""),
        entry.get("deep_reading_path", ""),
        entry.get("main_note_path", ""),
        entry.get("ai_path", ""),
    ]
    if not all(workspace_paths):
        return "sync"
    
    # 7. OCR done, deep reading pending → /pf-deep
    if ocr_status == "done" and deep_reading_status == "pending":
        return "/pf-deep"
    
    # 8. Everything ready
    return "ready"
    ```
    
    Update the docstring (lines 187-201) to reflect the new order:
    Old:
    ```python
    Priority-ordered decision:
    1. ocr_status in (failed, blocked, done_incomplete) → "ocr" (retry OCR)
    2. has_pdf=False → "sync" (add PDF in Zotero)
    3. has_pdf=True AND ocr_status in (pending, nopdf) → "ocr"
    4. ocr_status="processing" → "ready" (OCR already running)
    5. ocr_status="done" AND deep_reading_status="pending" → "/pf-deep"
    6. note_path empty → "sync"
    7. Any workspace path empty → "sync"
    8. Otherwise → "ready"
    ```
    
    New:
    ```python
    Priority-ordered decision:
    1. ocr_status in (failed, blocked, done_incomplete) → "ocr" (retry OCR)
    2. has_pdf=False → "sync" (add PDF in Zotero)
    3. has_pdf=True AND ocr_status in (pending, nopdf) → "ocr"
    4. ocr_status="processing" → "ready" (OCR already running)
    5. note_path empty → "sync" (workspace integrity before /pf-deep)
    6. Any workspace path empty → "sync" (workspace integrity before /pf-deep)
    7. ocr_status="done" AND deep_reading_status="pending" → "/pf-deep"
    8. Otherwise → "ready"
    ```
    
    === test_asset_state.py changes ===
    
    In `TestComputeNextStep`, update `test_ready_for_deep_read_returns_pf_deep()` to include note_path and workspace paths (since integrity checks now precede /pf-deep):
    
    ```python
    def test_ready_for_deep_read_returns_pf_deep(self) -> None:
        """OCR done, deep_reading pending, all integrity checks pass → '/pf-deep'."""
        entry = {
            "has_pdf": True,
            "ocr_status": "done",
            "deep_reading_status": "pending",
            "note_path": "Literature/骨科/KEY - Title.md",
            "fulltext_path": "Literature/骨科/KEY/fulltext.md",
            "deep_reading_path": "Literature/骨科/KEY/deep-reading.md",
            "main_note_path": "Literature/骨科/KEY/KEY - Title.md",
            "ai_path": "Literature/骨科/KEY/ai/",
        }
        result = compute_next_step(entry)
        assert result == "/pf-deep"
    ```
    
    Add new test for missing note_path blocking /pf-deep:
    ```python
    def test_missing_note_blocks_pf_deep(self) -> None:
        """OCR done, deep_read pending, note_path empty → 'sync', not '/pf-deep'."""
        entry = {
            "has_pdf": True,
            "ocr_status": "done",
            "deep_reading_status": "pending",
            "note_path": "",
            "fulltext_path": "Literature/骨科/KEY/fulltext.md",
            "deep_reading_path": "Literature/骨科/KEY/deep-reading.md",
            "main_note_path": "Literature/骨科/KEY/KEY - Title.md",
            "ai_path": "Literature/骨科/KEY/ai/",
        }
        result = compute_next_step(entry)
        assert result == "sync"
    ```
    
    Add new test for missing workspace path blocking /pf-deep:
    ```python
    def test_missing_workspace_path_blocks_pf_deep(self) -> None:
        """OCR done, deep_read pending, note_path present but ai_path empty → 'sync'."""
        entry = {
            "has_pdf": True,
            "ocr_status": "done",
            "deep_reading_status": "pending",
            "note_path": "Literature/骨科/KEY - Title.md",
            "fulltext_path": "Literature/骨科/KEY/fulltext.md",
            "deep_reading_path": "Literature/骨科/KEY/deep-reading.md",
            "main_note_path": "Literature/骨科/KEY/KEY - Title.md",
            "ai_path": "",
        }
        result = compute_next_step(entry)
        assert result == "sync"
    ```
  </action>

  <verify>
    <automated>cd /D "D:\L\Med\Research\99_System\LiteraturePipeline\github-release" && python -m pytest tests/test_asset_state.py -v -x --tb=short 2>&1</automated>
  </verify>

  <done>
    - All tests in test_asset_state.py pass (existing 18 tests + 3 new = 21)
    - `test_missing_note_blocks_pf_deep` returns "sync" not "/pf-deep"
    - `test_missing_workspace_path_blocks_pf_deep` returns "sync" not "/pf-deep"
    - Docstring in compute_next_step reflects new ordering
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Return {} instead of None for empty index-derived dicts in status.py (HARDEN-07)</name>
  <files>paperforge/worker/status.py, tests/test_status.py</files>

  <behavior>
    - Test 1 (updated): When no canonical index, `paperforge status --json` returns `lifecycle_level_counts`, `health_aggregate`, `maturity_distribution` as `{}` (not `None`)
    - Test 2 (existing unchanged): When index exists, aggregates contain correct data
    - Test 3 (existing unchanged): Text output lifecycle section still correct
  </behavior>

  <action>
    === status.py changes (HARDEN-07) ===
    
    Lines 683-690: Change initialization from `None` to `{}`:
    
    Old:
    ```python
    # Phase 25: index-derived aggregates (None when index unavailable)
    lifecycle_level_counts = None
    health_aggregate = None
    maturity_distribution = None
    if summary is not None:
        lifecycle_level_counts = summary["lifecycle_level_counts"]
        health_aggregate = summary["health_aggregate"]
        maturity_distribution = summary["maturity_distribution"]
    ```
    
    New:
    ```python
    # Phase 25: index-derived aggregates (empty dict when index unavailable)
    lifecycle_level_counts = {}
    health_aggregate = {}
    maturity_distribution = {}
    if summary is not None:
        lifecycle_level_counts = summary["lifecycle_level_counts"]
        health_aggregate = summary["health_aggregate"]
        maturity_distribution = summary["maturity_distribution"]
    ```
    
    Also update the comment on line 712:
    Old: `# Phase 25: lifecycle/health/maturity from canonical index (None when unavailable)`
    New: `# Phase 25: lifecycle/health/maturity from canonical index ({} when unavailable)`
    
    === test_status.py changes ===
    
    Update `test_status_json_fallback_when_index_missing` (lines 139-153) to assert `{}` instead of `None`:
    
    Old assertions (lines 151-153):
    ```python
    assert data["lifecycle_level_counts"] is None
    assert data["health_aggregate"] is None
    assert data["maturity_distribution"] is None
    ```
    
    New:
    ```python
    assert data["lifecycle_level_counts"] == {}
    assert data["health_aggregate"] == {}
    assert data["maturity_distribution"] == {}
    ```
  </action>

  <verify>
    <automated>cd /D "D:\L\Med\Research\99_System\LiteraturePipeline\github-release" && python -m pytest tests/test_status.py -v -x --tb=short 2>&1</automated>
  </verify>

  <done>
    - All tests in test_status.py pass (6 existing tests with updated assertion)
    - `paperforge status --json` with no canonical index returns `{}` for all three aggregate fields
    - Downstream JSON parsers no longer crash on `null` field access
  </done>
</task>

</tasks>

<verification>
1. `python -m pytest tests/test_asset_state.py -v --tb=short` — ALL tests pass (21 tests)
2. `python -m pytest tests/test_status.py -v --tb=short` — ALL tests pass (6 tests)
3. `python -m pytest tests/ -q --tb=short` — full suite no regressions
4. `python -c "from paperforge.worker.asset_state import compute_next_step; assert compute_next_step({'ocr_status':'done','deep_reading_status':'pending','note_path':'','has_pdf':True,'fulltext_path':'a','deep_reading_path':'b','main_note_path':'c','ai_path':'d'}) == 'sync'"` — verify ordering
5. `python -c "from paperforge.worker.asset_state import compute_next_step; assert compute_next_step({'ocr_status':'done','deep_reading_status':'pending','note_path':'a','has_pdf':True,'fulltext_path':'b','deep_reading_path':'c','main_note_path':'d','ai_path':''}) == 'sync'"` — verify path check
</verification>

<success_criteria>
- [ ] `asset_state.py:compute_next_step()` checks note_path and workspace_paths before returning "/pf-deep"
- [ ] `status.py` initializes `lifecycle_level_counts`, `health_aggregate`, `maturity_distribution` as `{}`
- [ ] All tests in `test_asset_state.py` and `test_status.py` pass
- [ ] Full test suite passes with zero regressions
</success_criteria>

<output>
After completion, create `.planning/phases/49-module-hardening/49-003-SUMMARY.md`
</output>
