---
phase: 59-state-machine-field-registry
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/core/state.py
  - paperforge/worker/asset_state.py
  - tests/test_state_machine.py
autonomous: true
requirements:
  - STAT-01
  - STAT-02

must_haves:
  truths:
    - "PdfStatus, OcrStatus, and Lifecycle enums exist in paperforge/core/state.py with explicit string values matching existing serialized forms"
    - "OcrStatus.PENDING, PROCESSING, DONE, FAILED cover all existing codebase OCR states via from_legacy() mapping"
    - "PdfStatus.HEALTHY, BROKEN, MISSING cover all existing PDF health states"
    - "Lifecycle.INDEXED, PDF_READY, OCR_READY, ANALYZE_READY, DEEP_READ_DONE, ERROR_STATE cover all lifecycle strings"
    - "ALLOWED_TRANSITIONS table defines legal transitions for ocr_status, deep_reading_status, and lifecycle"
    - "validate_transition() rejects illegal transitions with clear error messages"
    - "Existing code that compares state strings still works (enum values match legacy strings)"
    - "compute_lifecycle() returns Lifecycle enum values; existing callers using .value or str() still work"
  artifacts:
    - path: "paperforge/core/state.py"
      provides: "PdfStatus, OcrStatus, Lifecycle enums + ALLOWED_TRANSITIONS + validate_transition"
      exports: ["PdfStatus", "OcrStatus", "Lifecycle", "ALLOWED_TRANSITIONS", "validate_transition"]
      min_lines: 80
    - path: "tests/test_state_machine.py"
      provides: "Tests for enum coverage, backward compatibility, transition validation"
      contains: "test_ocr_status_backward_compat"
    - path: "paperforge/worker/asset_state.py"
      provides: "Updated compute_lifecycle returning Lifecycle enum"
      contains: "Lifecycle"
  key_links:
    - from: "paperforge/worker/asset_state.py"
      to: "paperforge/core/state.py"
      via: "import Lifecycle"
      pattern: "from paperforge.core.state import Lifecycle"
    - from: "tests/test_state_machine.py"
      to: "paperforge/core/state.py"
      via: "import OcrStatus, PdfStatus, Lifecycle"
      pattern: "from paperforge.core.state import"
---

<objective>
Create the formalized state machine core: PdfStatus, OcrStatus, and Lifecycle enums in paperforge/core/state.py, the ALLOWED_TRANSITIONS table with transition validation, and update the existing lifecycle derivation in asset_state.py to use the new enums.

Purpose: Make every state change predictable and auditable. Replace scattered string comparisons with typed enum checks. Reject illegal transitions with clear error messages before writes happen.

Output: paperforge/core/state.py (enums + transitions), updated asset_state.py (Lifecycle-returning version), unit tests.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/059-state-machine-field-registry/059-CONTEXT.md

<interfaces>
From existing paperforge/core/errors.py (Phase 57):
```python
class ErrorCode(str, Enum):
    PYTHON_NOT_FOUND = "PYTHON_NOT_FOUND"
    VERSION_MISMATCH = "VERSION_MISMATCH"
    BBT_EXPORT_NOT_FOUND = "BBT_EXPORT_NOT_FOUND"
    OCR_TOKEN_MISSING = "OCR_TOKEN_MISSING"
    SYNC_FAILED = "SYNC_FAILED"
    VALIDATION_ERROR = "VALIDATION_ERROR"
    INTERNAL_ERROR = "INTERNAL_ERROR"
    UNKNOWN = "UNKNOWN"
    def __str__(self) -> str: return self.value
```

From paperforge/worker/asset_state.py (existing lifecycle):
```python
def compute_lifecycle(entry: dict) -> str:
    """Returns: 'indexed' | 'pdf_ready' | 'fulltext_ready' | 'deep_read_done'"""
    has_pdf = bool(entry.get("has_pdf", False))
    ocr_status = entry.get("ocr_status", "pending")
    deep_reading_status = entry.get("deep_reading_status", "pending")
    if not has_pdf: return "indexed"
    if ocr_status == "done":
        if deep_reading_status == "done": return "deep_read_done"
        return "fulltext_ready"
    return "pdf_ready"
```

Existing OCR meta.json state strings: pending, queued, running, processing, done, error, failed, blocked, nopdf, done_incomplete. All must map via from_legacy().
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create PdfStatus, OcrStatus, Lifecycle enums and ALLOWED_TRANSITIONS in paperforge/core/state.py</name>
  <files>
    paperforge/core/state.py
  </files>
  <action>
    Create paperforge/core/state.py with three enums using `from __future__ import annotations` and `from enum import Enum`. Each is a `str, Enum` so `.value` equals the string (transparent backward compat with existing serialized state values).

    **PdfStatus**: HEALTHY="healthy", BROKEN="broken", MISSING="missing"
    Each member gets a short docstring describing when it applies.

    **OcrStatus**: PENDING="pending", PROCESSING="processing", DONE="done", FAILED="failed"
    Each member gets a short docstring.

    OcrStatus.from_legacy(value: str) -> OcrStatus classmethod:
    - "pending", "done_incomplete" -> PENDING (done_incomplete resets for re-run)
    - "queued", "running", "processing" -> PROCESSING (all in-progress states)
    - "done" -> DONE
    - "error", "failed", "blocked", "nopdf" -> FAILED (all terminal error states)
    - Unknown -> PENDING (safe default)

    **Lifecycle**: INDEXED="indexed", PDF_READY="pdf_ready", OCR_READY="ocr_ready", ANALYZE_READY="analyze_ready", DEEP_READ_DONE="deep_read_done", ERROR_STATE="error_state"
    Each member gets a short docstring.

    Lifecycle.from_legacy(value: str) -> Lifecycle classmethod:
    - "indexed" -> INDEXED
    - "pdf_ready" -> PDF_READY
    - "fulltext_ready" -> ANALYZE_READY (the old name meant "ready for next step" = analyze_ready)
    - "deep_read_done" -> DEEP_READ_DONE
    - "error_state" -> ERROR_STATE
    - Unknown -> INDEXED (safe default)

    **ALLOWED_TRANSITIONS** dict:
    ```python
    ALLOWED_TRANSITIONS: dict[str, list[tuple]] = {
        "ocr_status": [
            (OcrStatus.PENDING, OcrStatus.PROCESSING),
            (OcrStatus.PROCESSING, OcrStatus.DONE),
            (OcrStatus.PROCESSING, OcrStatus.FAILED),
            (OcrStatus.DONE, OcrStatus.PENDING),       # re-run
            (OcrStatus.FAILED, OcrStatus.PENDING),     # retry after failure
        ],
        "deep_reading_status": [
            ("pending", "done"),
            ("done", "pending"),      # --force re-run
        ],
        "lifecycle": [
            (Lifecycle.INDEXED, Lifecycle.PDF_READY),
            (Lifecycle.PDF_READY, Lifecycle.OCR_READY),
            (Lifecycle.OCR_READY, Lifecycle.ANALYZE_READY),
            (Lifecycle.ANALYZE_READY, Lifecycle.DEEP_READ_DONE),
            (Lifecycle.PDF_READY, Lifecycle.ERROR_STATE),
            (Lifecycle.OCR_READY, Lifecycle.ERROR_STATE),
            (Lifecycle.ANALYZE_READY, Lifecycle.ERROR_STATE),
            (Lifecycle.DEEP_READ_DONE, Lifecycle.ERROR_STATE),
            (Lifecycle.ERROR_STATE, Lifecycle.PDF_READY),
        ],
    }
    ```

    **validate_transition(field: str, from_state: str | Enum, to_state: str | Enum) -> None**:
    - If field not in ALLOWED_TRANSITIONS, return None (no validation for unknown fields — future-proofing)
    - For each (allowed_from, allowed_to) in ALLOWED_TRANSITIONS[field]: if from_state matches allowed_from AND to_state matches allowed_to, return None
    - Comparison: use `str(x)` on both states for uniform string comparison (handles both enum members and raw strings)
    - If no match found, raise ValueError with message:
      `"Invalid {field} transition: '{from_str}' -> '{to_str}'. Allowed transitions: {format_allowed}"`
      where format_allowed lists all allowed pairs as "from->to, from->to, ..."

    Docstring the module with a clear summary of the state machine design.
    Module-level __all__ = ["PdfStatus", "OcrStatus", "Lifecycle", "ALLOWED_TRANSITIONS", "validate_transition"].
  </action>
  <verify>
    <automated>python -c "
from paperforge.core.state import PdfStatus, OcrStatus, Lifecycle, ALLOWED_TRANSITIONS, validate_transition
# Enum values match legacy strings
assert OcrStatus.PENDING.value == 'pending'
assert OcrStatus.DONE.value == 'done'
assert OcrStatus.FAILED.value == 'failed'
assert PdfStatus.HEALTHY.value == 'healthy'
assert PdfStatus.BROKEN.value == 'broken'
assert Lifecycle.PDF_READY.value == 'pdf_ready'
assert Lifecycle.DEEP_READ_DONE.value == 'deep_read_done'
# Backward compat
assert OcrStatus.from_legacy('queued') == OcrStatus.PROCESSING
assert OcrStatus.from_legacy('error') == OcrStatus.FAILED
assert OcrStatus.from_legacy('nopdf') == OcrStatus.FAILED
assert OcrStatus.from_legacy('done_incomplete') == OcrStatus.PENDING
assert Lifecycle.from_legacy('fulltext_ready') == Lifecycle.ANALYZE_READY
assert Lifecycle.from_legacy('indexed') == Lifecycle.INDEXED
# str comparison works (str enums)
assert OcrStatus.PENDING == 'pending'
# validate_transition - valid
validate_transition('ocr_status', OcrStatus.PENDING, OcrStatus.PROCESSING)
validate_transition('ocr_status', 'done', 'pending')
validate_transition('lifecycle', Lifecycle.PDF_READY, Lifecycle.OCR_READY)
validate_transition('deep_reading_status', 'pending', 'done')
# validate_transition - invalid
import traceback
try:
    validate_transition('ocr_status', OcrStatus.DONE, OcrStatus.PROCESSING)
    print('UNEXPECTED: should have raised')
except ValueError as e:
    msg = str(e)
    assert 'Invalid' in msg
    assert 'ocr_status' in msg
    print(f'OK rejection: {msg[:120]}')
# validate_transition - unknown field (pass-through)
validate_transition('nonexistent_field', 'a', 'b')  # should not raise
print('ALL STATE ENUM CHECKS PASS')
"</automated>
  </verify>
  <done>PdfStatus, OcrStatus, Lifecycle enums created with from_legacy() mapping all existing state strings. ALLOWED_TRANSITIONS table and validate_transition() reject illegal transitions with clear messages.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Write comprehensive unit tests for state enums and transitions</name>
  <files>
    tests/test_state_machine.py
  </files>
  <behavior>
    - Test 1: All enum member values match their legacy strings
    - Test 2: OcrStatus.from_legacy maps every known legacy string correctly
    - Test 3: Lifecycle.from_legacy maps every known legacy string correctly
    - Test 4: str(enum_member) returns the value string
    - Test 5: enum_member == "legacy_string" works (str enum property)
    - Test 6: ALLOWED_TRANSITIONS has entries for ocr_status, deep_reading_status, lifecycle
    - Test 7: validate_transition allows all defined transitions
    - Test 8: validate_transition rejects undefined transitions with ValueError containing field name and allowed list
    - Test 9: validate_transition passes through unknown fields without error
    - Test 10: Lifecycle enum has all 6 members
    - Test 11: OcrStatus enum has all 4 members
    - Test 12: PdfStatus enum has all 3 members
  </behavior>
  <action>
    Write RED test file tests/test_state_machine.py (will fail initially if state.py doesn't exist — run after Task 1).

    Test classes:
    - TestPdfStatus: member count (3), value checks, str comparison
    - TestOcrStatus: member count (4), value checks, str comparison, from_legacy full mapping
    - TestLifecycle: member count (6), value checks, str comparison, from_legacy full mapping
    - TestAllowedTransitions: dict structure, all 3 keys present, ocr_status has 5 pairs, lifecycle has 9 pairs
    - TestValidateTransition: valid paths, invalid paths (verify ValueError message format), unknown field passthrough
    - TestAssetStateBackwardCompat: compute_lifecycle returns enum but str()/== comparators still work

    After writing tests, GREEN: ensure state.py exists and tests pass. REFACTOR: none needed.
  </action>
  <verify>
    <automated>python -m pytest tests/test_state_machine.py -v --tb=short</automated>
  </verify>
  <done>All state machine tests pass: enum coverage, backward compatibility, transition validation, lifecycle backward compat.</done>
</task>

<task type="auto">
  <name>Task 3: Update compute_lifecycle in asset_state.py to return Lifecycle enum</name>
  <files>
    paperforge/worker/asset_state.py
  </files>
  <action>
    Update paperforge/worker/asset_state.py:

    1. Add import: `from paperforge.core.state import Lifecycle`

    2. Update `compute_lifecycle()` function signature and return type:
    ```python
    def compute_lifecycle(entry: dict) -> Lifecycle:
    ```

    3. Update the logic:
    ```python
    def compute_lifecycle(entry: dict) -> Lifecycle:
        """Derive the current lifecycle state. Returns Lifecycle enum member.
        
        Six progressive states:
        - Lifecycle.INDEXED: has_pdf=False (entry exists, no PDF)
        - Lifecycle.PDF_READY: has_pdf=True, OCR not validated done
        - Lifecycle.OCR_READY: ocr_status=="done", analyze=False
        - Lifecycle.ANALYZE_READY: ocr_status=="done", analyze=True, deep_reading pending
        - Lifecycle.DEEP_READ_DONE: OCR done AND deep-reading done
        - Lifecycle.ERROR_STATE: error condition (entry has path_error)
        """
        has_pdf = bool(entry.get("has_pdf", False))
        ocr_status = entry.get("ocr_status", "pending")
        deep_reading_status = entry.get("deep_reading_status", "pending")
        path_error = bool(entry.get("path_error", ""))
        
        # indexed: no PDF available
        if not has_pdf:
            return Lifecycle.INDEXED
        
        # error_state: path_error set
        if path_error:
            return Lifecycle.ERROR_STATE
        
        # OCR validated done
        if ocr_status == "done":
            if deep_reading_status == "done":
                return Lifecycle.DEEP_READ_DONE
            # Check analyze flag: if analyze=true and deep reading not done -> ANALYZE_READY
            analyze = bool(entry.get("analyze", False))
            return Lifecycle.ANALYZE_READY if analyze else Lifecycle.OCR_READY
        
        return Lifecycle.PDF_READY
    ```

    4. Update docstrings of compute_health, compute_maturity, compute_next_step to reference Lifecycle enum where applicable. No logic changes needed — str enums keep existing string comparisons working.

    5. Update compute_maturity docstring to reference Lifecycle members.

    **Why no logic changes in compute_health/maturity/next_step**: Since Lifecycle is a `str, Enum`, comparisons like `ocr_status == "done"` and `lifecycle == "deep_read_done"` still work because str enums compare equal to their string values. Old code continues to function.

    After update, run the existing test_asset_state.py to confirm no regressions.
  </action>
  <verify>
    <automated>python -c "
from paperforge.worker.asset_state import compute_lifecycle
from paperforge.core.state import Lifecycle

# Test indexed
assert compute_lifecycle({'has_pdf': False}) == Lifecycle.INDEXED

# Test pdf_ready
assert compute_lifecycle({'has_pdf': True, 'ocr_status': 'pending'}) == Lifecycle.PDF_READY

# Test ocr_ready (done, not analyze)
result = compute_lifecycle({'has_pdf': True, 'ocr_status': 'done', 'deep_reading_status': 'pending', 'analyze': False})
assert result == Lifecycle.OCR_READY, f'Expected OCR_READY, got {result}'

# Test analyze_ready (done, analyze=true)
result = compute_lifecycle({'has_pdf': True, 'ocr_status': 'done', 'deep_reading_status': 'pending', 'analyze': True})
assert result == Lifecycle.ANALYZE_READY, f'Expected ANALYZE_READY, got {result}'

# Test deep_read_done
result = compute_lifecycle({'has_pdf': True, 'ocr_status': 'done', 'deep_reading_status': 'done'})
assert result == Lifecycle.DEEP_READ_DONE

# Test error_state
result = compute_lifecycle({'has_pdf': True, 'ocr_status': 'pending', 'path_error': 'not_found'})
assert result == Lifecycle.ERROR_STATE, f'Expected ERROR_STATE, got {result}'

# Test backward compat: str() works
assert str(compute_lifecycle({'has_pdf': False})) == 'indexed'

import subprocess, sys
r = subprocess.run([sys.executable, '-m', 'pytest', 'tests/test_asset_state.py', '-v', '--tb=short'], capture_output=True, text=True)
print(r.stdout[-300:] if len(r.stdout) > 300 else r.stdout)
assert r.returncode == 0, f'asset_state regressions: {r.stderr[:300]}'
print('OLD ASSET_STATE TESTS STILL PASS')
" && python -m pytest tests/test_state_machine.py -v --tb=short</automated>
  </verify>
  <done>compute_lifecycle returns Lifecycle enum. compute_lifecycle now distinguishes OCR_READY vs ANALYZE_READY based on analyze flag. All existing tests pass.</done>
</task>

</tasks>

<verification>
- python -c "from paperforge.core.state import PdfStatus, OcrStatus, Lifecycle; print('OcrStatus:', list(OcrStatus)); print('Lifecycle:', list(Lifecycle))" shows all members
- python -c "from paperforge.worker.asset_state import compute_lifecycle; print(compute_lifecycle({'has_pdf': True, 'ocr_status': 'done'}))" prints Lifecycle.OCR_READY or ANALYZE_READY
- pytest tests/test_state_machine.py -v passes
- pytest tests/test_asset_state.py -v passes (no regressions)
</verification>

<success_criteria>
- [ ] paperforge/core/state.py exists with PdfStatus (3), OcrStatus (4), Lifecycle (6) enums
- [ ] ALL legacy state strings map via from_legacy() — queued, running, error, blocked, nopfd, done_incomplete all mapped
- [ ] ALLOWED_TRANSITIONS table with 5 ocr_status, 2 deep_reading_status, 9 lifecycle transitions
- [ ] validate_transition() raises ValueError with field name and allowed transitions on illegal transitions
- [ ] compute_lifecycle() returns Lifecycle enum (not str), all existing code still works
- [ ] compute_lifecycle distinguishes OCR_READY vs ANALYZE_READY based on analyze flag
</success_criteria>

<output>
After completion, create .planning/phases/059-state-machine-field-registry/059-01-SUMMARY.md
</output>
