---
phase: 49-module-hardening
plan: 001
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/worker/discussion.py
  - tests/test_discussion.py
autonomous: true
requirements:
  - HARDEN-01
  - HARDEN-02
  - HARDEN-03

must_haves:
  truths:
    - "Three timestamps in discussion.json use UTC (no CST/UTC+8 hardcoded offset)"
    - "Markdown special characters (*, #, [, _, `) in QA question/answer fields are escaped before writing to discussion.md"
    - "Two concurrent /pf-paper calls for the same paper do not corrupt discussion.json or discussion.md"
  artifacts:
    - path: paperforge/worker/discussion.py
      provides: "UTC timestamps, markdown escaping, file locking"
      min_lines: 1
    - path: tests/test_discussion.py
      provides: "Tests for UTC timestamp, markdown escaping, locking"
      min_lines: 1
  key_links:
    - from: discussion.py:_now_iso()
      to: "timezone.utc"
      via: "datetime.now(timezone.utc)"
      pattern: "datetime\\.now\\(timezone\\.utc\\)"
    - from: discussion.py:_build_md_session()
      to: "_escape_md()"
      via: "escape call on question/answer"
      pattern: "_escape_md\\(qa\\["
    - from: discussion.py:record_session()
      to: "filelock.FileLock"
      via: "lock around JSON+MD read-modify-write"
      pattern: "filelock\\.FileLock"
---

<objective>
Add production-grade safety guards to `discussion.py`: UTC timestamps (HARDEN-03), markdown escaping (HARDEN-02), and file-level locking (HARDEN-01).

Purpose: Eliminate timezone inconsistency in discussion.json, prevent broken Obsidian formatting from unescaped markdown characters, and prevent concurrent process write corruption.

Output:
  - Modified `paperforge/worker/discussion.py` with all three hardening measures
  - Modified `tests/test_discussion.py` with tests for the new behavior
</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>
From paperforge/worker/asset_index.py (established filelock pattern):
```python
import filelock
LOCK_TIMEOUT = 10

lock_path = path.with_suffix(".json.lock")
lock = filelock.FileLock(lock_path, timeout=LOCK_TIMEOUT)
with lock:
    # read-modify-write
```

From tests/test_discussion.py (existing test helpers):
```python
def _sample_qa_pairs() -> list[dict]:
    return [
        {"question": "What is the primary outcome?", "answer": "The primary outcome is biomechanical strength.",
         "source": "user_question", "timestamp": "2026-05-06T12:00:00+08:00"},
    ]
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Fix UTC timestamps (HARDEN-03) + add markdown escaping (HARDEN-02)</name>
  <files>paperforge/worker/discussion.py, tests/test_discussion.py</files>

  <behavior>
    - Test 1 (UTC): `record_session()` returns `started` timestamp ending with `+00:00` in discussion.json
    - Test 2 (escape): `record_session()` with QA containing `*` `#` `[` `_` `` ` `` produces escaped sequences in discussion.md (no bare `*text*` bold rendering)
    - Test 3 (escape CJK): CJK characters in QA fields are preserved alongside escaped markdown chars
    - Test 4 (idempotent escape): Already-escaped characters like `\*` are not double-escaped
  </behavior>

  <action>
    === discussion.py changes (HARDEN-03) ===
    Line 40: Replace `_CST = timezone(timedelta(hours=8))` with `_CST = timezone.utc`
    
    Line 48-50: Update `_now_iso()` docstring:
    Old: `"""Return current time in ISO 8601 with timezone (CST, UTC+8)."""`
    New: `"""Return current time in ISO 8601 with UTC timezone."""`
    
    === discussion.py changes (HARDEN-02) ===
    After `_MD_SEPARATOR` constant (around line 38-39), add:
    ```python
    _MD_SPECIAL_CHARS = re.compile(r'([*#\[\]_`])')
    """Regex for markdown special characters that must be escaped in QA text fields."""
    ```
    
    Between `_MD_SEPARATOR` and `_now_iso()`, add helper function:
    ```python
    def _escape_md(text: str) -> str:
        """Escape markdown special characters in QA text fields.
        
        Escapes: *, #, [, ], _, ` — prevents broken Obsidian formatting
        when user questions or AI answers contain these characters.
        Does not double-escape already-escaped characters.
        """
        return _MD_SPECIAL_CHARS.sub(r'\\\1', text)
    ```
    
    In `_build_md_session()` (lines 169-171), wrap question and answer with `_escape_md()`:
    ```python
    lines.append(f"**问题:** {_escape_md(qa['question'])}")
    lines.append(f"**解答:** {_escape_md(qa['answer'])}\n")
    ```
    
    === test_discussion.py changes ===
    Add import for `re` at top if not present.
    
    In `TestRecordSession`, add:
    
    1. `test_utc_timestamp()` — Verify `started` field ends with `+00:00`
    ```python
    def test_utc_timestamp(self, tmp_path: Path) -> None:
        """HARDEN-03: started timestamp uses UTC (not CST/UTC+8)."""
        vault = _create_minimal_vault(tmp_path)
        result = record_session(
            vault_path=vault, zotero_key="TSTONE001",
            agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
        )
        assert result["status"] == "ok"
        data = json.loads(Path(result["json_path"]).read_text(encoding="utf-8"))
        started = data["sessions"][0]["started"]
        # UTC offset is either +00:00 or Z
        assert started.endswith("+00:00") or started.endswith("Z"), f"Expected UTC, got: {started}"
    ```
    
    2. `test_markdown_escaping()` — Verify special chars escaped in MD output
    ```python
    def test_markdown_escaping(self, tmp_path: Path) -> None:
        """HARDEN-02: Markdown special chars in QA are escaped in discussion.md."""
        vault = _create_minimal_vault(tmp_path)
        qa = [
            {
                "question": "What does *italic* and **bold** mean?",
                "answer": "Use # for headings and [link](url) syntax _underscore_ `code`",
                "source": "user_question",
                "timestamp": "2026-05-06T12:00:00+00:00",
            },
        ]
        result = record_session(
            vault_path=vault, zotero_key="TSTONE001",
            agent="pf-paper", model="gpt-4", qa_pairs=qa,
        )
        assert result["status"] == "ok"
        md = Path(result["md_path"]).read_text(encoding="utf-8")
        # Verify escaped forms exist (not bare markdown)
        assert "\\*italic\\*" in md, f"Expected escaped asterisks in: {md}"
        assert "\\*\\*bold\\*\\*" in md
        assert "\\# " in md or "\\#" in md
        assert "\\_" in md
        assert "\\`" in md
    ```
    
    3. `test_markdown_escaping_cjk()` — CJK preserved alongside escaped chars
    ```python
    def test_markdown_escaping_cjk(self, tmp_path: Path) -> None:
        """HARDEN-02: CJK characters preserved alongside escaped markdown chars."""
        vault = _create_minimal_vault(tmp_path, title="中文测试")
        qa = [
            {
                "question": "什么是*p值*和#显著性？",
                "answer": "_效应量_为0.5`[95% CI]",
                "source": "user_question",
                "timestamp": "2026-05-06T12:00:00+00:00",
            },
        ]
        result = record_session(
            vault_path=vault, zotero_key="TSTONE001",
            agent="pf-paper", model="gpt-4", qa_pairs=qa,
        )
        assert result["status"] == "ok"
        md = Path(result["md_path"]).read_text(encoding="utf-8")
        # CJK preserved
        assert "什么是" in md
        assert "效应量" in md
        # Special chars escaped
        assert "\\*p值\\*" in md or "\\*p\\u5024\\*" in md
        assert "\\_" in md
        assert "\\`" in md
    ```
  </action>

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

  <done>
    - All 4 new tests pass (UTC timestamp, markdown escape, CJK escape, idempotent)
    - All existing tests in test_discussion.py still pass (9 existing tests)
    - Total: 13+ tests passing
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add file-level locking to discussion.py record_session() (HARDEN-01)</name>
  <files>paperforge/worker/discussion.py, tests/test_discussion.py</files>

  <behavior>
    - Test 1: `filelock.FileLock` is called with the correct lock path (`.json.lock` suffix) during `record_session()`
    - Test 2: `record_session()` correctly handles `filelock.Timeout` by returning error status
    - Test 3: Existing atomic-write tests continue to pass (no regression)
  </behavior>

  <action>
    === discussion.py changes (HARDEN-01) ===
    
    Add `import filelock` to imports (after `import tempfile`, before `from datetime`):
    
    Add `LOCK_TIMEOUT` constant after _MD_SEPARATOR / _MD_SPECIAL_CHARS:
    ```python
    LOCK_TIMEOUT = 10
    """Seconds to wait for cross-process file lock before raising filelock.Timeout."""
    ```
    
    Replace the two separate try blocks for JSON write (lines 281-299) and MD write (lines 302-322) with a single locked block:
    
    Old code (lines 280-322):
    ```python
    # --- Write discussion.json (atomic, append) ---
    try:
        existing_sessions = []
        if json_path.exists():
            ...
        envelope = { ... }
        _atomic_write_json(json_path, envelope)
    except Exception as exc:
        logger.warning("Failed to write discussion.json: %s", exc)
        return {"status": "error", "message": f"Failed to write discussion.json: {exc}"}

    # --- Write discussion.md (atomic, append) ---
    try:
        existing_md = ""
        if md_path.exists():
            ...
        header = _build_md_header(paper_title)
        session_md = _build_md_session(session)
        content = _md_content(existing_md, header, session_md)
        _atomic_write_md(md_path, content)
    except Exception as exc:
        logger.warning("Failed to write discussion.md: %s", exc)
        return {"status": "error", "message": f"Failed to write discussion.md: {exc}",
                "json_path": str(json_path)}
    ```
    
    New code:
    ```python
    # --- Write discussion.json + discussion.md (atomic, locked) ---
    # HARDEN-01: Cross-process file lock prevents concurrent write corruption.
    # Uses same filelock pattern as asset_index.py: lock on .json.lock suffix,
    # timeout after LOCK_TIMEOUT seconds.
    lock_path = json_path.with_suffix(".json.lock")
    lock = filelock.FileLock(lock_path, timeout=LOCK_TIMEOUT)
    try:
        with lock:
            # Write discussion.json
            existing_sessions = []
            if json_path.exists():
                try:
                    existing_data = json.loads(json_path.read_text(encoding="utf-8"))
                    existing_sessions = existing_data.get("sessions", [])
                except (json.JSONDecodeError, OSError) as exc:
                    logger.warning("Corrupted discussion.json, starting fresh: %s", exc)
                    existing_sessions = []

            envelope = {
                "schema_version": "1",
                "paper_key": zotero_key,
                "sessions": [*existing_sessions, session],
            }
            _atomic_write_json(json_path, envelope)

            # Write discussion.md
            existing_md = ""
            if md_path.exists():
                try:
                    existing_md = md_path.read_text(encoding="utf-8")
                except (OSError, UnicodeDecodeError) as exc:
                    logger.warning("Could not read existing discussion.md: %s", exc)
                    existing_md = ""

            header = _build_md_header(paper_title)
            session_md = _build_md_session(session)
            content = _md_content(existing_md, header, session_md)
            _atomic_write_md(md_path, content)
    except filelock.Timeout:
        logger.warning("Could not acquire lock for discussion files: %s", lock_path)
        return {"status": "error",
                "message": "Concurrent access conflict. Please try again."}
    except Exception as exc:
        logger.warning("Failed to write discussion files: %s", exc)
        return {"status": "error",
                "message": f"Failed to write discussion files: {exc}"}
    ```
    
    === test_discussion.py changes ===
    
    Add import at top:
    ```python
    import filelock
    ```
    
    Add test for lock file creation:
    ```python
    def test_file_lock_prevents_concurrent_write(self, tmp_path: Path) -> None:
        """HARDEN-01: File lock (.json.lock) is created during record_session()."""
        vault = _create_minimal_vault(tmp_path)
        result = record_session(
            vault_path=vault, zotero_key="TSTONE001",
            agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
        )
        assert result["status"] == "ok"
        json_path = Path(result["json_path"])
        # Lock file should NOT remain after successful write (released + cleaned up)
        lock_path = json_path.with_suffix(".json.lock")
        assert not lock_path.exists(), "Lock file should be released after write"
    ```
    
    Add test for concurrent safety (simulate by holding a lock):
    ```python
    def test_lock_timeout_returns_error(self, tmp_path: Path) -> None:
        """HARDEN-01: When lock cannot be acquired, returns error status."""
        vault = _create_minimal_vault(tmp_path)
        # First call to create the json file
        r1 = record_session(
            vault_path=vault, zotero_key="TSTONE001",
            agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
        )
        assert r1["status"] == "ok"
        json_path = Path(r1["json_path"])
        
        # Hold an exclusive lock to simulate concurrent access
        lock_path = json_path.with_suffix(".json.lock")
        external_lock = filelock.FileLock(lock_path, timeout=1)
        with external_lock:
            # This should fail because lock is held
            r2 = record_session(
                vault_path=vault, zotero_key="TSTONE001",
                agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
            )
            assert r2["status"] == "error"
            assert "Concurrent access" in r2.get("message", "")
    ```
  </action>

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

  <done>
    - All new locking tests pass (lock creation, timeout handling)
    - All previous discussion tests still pass (UTC, escape, atomic, append, CJK, CLI)
    - Total: 15+ tests passing
  </done>
</task>

</tasks>

<verification>
1. `python -m pytest tests/test_discussion.py -v --tb=short` — ALL tests pass (15+)
2. `python -m pytest tests/ -q --tb=short` — full suite no regressions
3. Verify no `timedelta(hours=8)` or `+08:00` remains in discussion.py
4. Verify `import filelock` present in discussion.py
5. Verify `_escape_md` is called in `_build_md_session()` for both question and answer
</verification>

<success_criteria>
- [ ] `paperforge/worker/discussion.py` has `import filelock` and uses `filelock.FileLock` around JSON+MD read-modify-write
- [ ] `paperforge/worker/discussion.py` has `_escape_md()` helper called in `_build_md_session()`
- [ ] `paperforge/worker/discussion.py` uses `timezone.utc` (no `timedelta(hours=8)`)
- [ ] All tests in `tests/test_discussion.py` pass (existing + new)
- [ ] Full test suite passes with zero regressions
</success_criteria>

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