---
phase: 03-vault
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/meshulash_guard/guard.py
autonomous: true

must_haves:
  truths:
    - "guard.deanonymize(text) replaces all known placeholder tokens with original values from the session vault"
    - "Multiple scan_input() calls accumulate placeholders into the same vault (vault.update, not vault.replace)"
    - "guard.clear_cache() empties the vault so no prior placeholders remain"
    - "guard.scan_output(text, scanners) sends the same /api/security/scan request as scan_input and accumulates placeholders"
    - "deanonymize logs a debug-level message when the vault is empty or when no placeholders are found in the provided text"
  artifacts:
    - path: "src/meshulash_guard/guard.py"
      provides: "Complete vault workflow: _scan(), scan_output(), deanonymize with debug logging"
      contains: "def _scan"
    - path: "src/meshulash_guard/guard.py"
      provides: "Module-level logger"
      contains: "logger = logging.getLogger(__name__)"
  key_links:
    - from: "Guard.scan_input()"
      to: "Guard._scan()"
      via: "delegation"
      pattern: "return self\\._scan\\("
    - from: "Guard.scan_output()"
      to: "Guard._scan()"
      via: "delegation"
      pattern: "return self\\._scan\\("
    - from: "Guard._scan()"
      to: "self._vault"
      via: "vault accumulation"
      pattern: "self\\._vault\\.update\\(result\\.placeholders\\)"
    - from: "Guard.deanonymize()"
      to: "logger"
      via: "debug warning on empty vault or no matches"
      pattern: "logger\\.debug"
---

<objective>
Complete the vault workflow in guard.py: extract shared scan logic into _scan(), implement scan_output() as a real server call, and enhance deanonymize() with debug logging.

Purpose: Developers can anonymize PII in prompts, pass redacted text to an LLM, and restore originals from the response -- all client-side, no second server call.
Output: Updated guard.py with working scan_output(), DRY _scan() method, and deanonymize() with debug warnings.
</objective>

<execution_context>
@/Users/shalev/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shalev/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-vault/03-CONTEXT.md
@.planning/phases/03-vault/03-RESEARCH.md
@src/meshulash_guard/guard.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Refactor guard.py -- extract _scan(), implement scan_output(), enhance deanonymize()</name>
  <files>src/meshulash_guard/guard.py</files>
  <action>
Modify guard.py with the following changes (all in one file, ~20-30 lines changed):

1. **Add logging import and module-level logger** at the top of the file, after the existing imports:
   ```python
   import logging
   ```
   And below the imports, before the class:
   ```python
   logger = logging.getLogger(__name__)
   ```

2. **Extract `_scan()` private method** from the body of `scan_input()`. Move the guardline_specs building, HTTP POST, error handling, response parsing, and vault accumulation into `_scan(self, text: str, scanners: list) -> ScanResult`. The method should:
   - Build guardline_specs from scanners (same logic as current scan_input)
   - POST to /api/security/scan
   - Handle all error cases (ConnectionError, Timeout, 401, 422, 5xx)
   - Parse response with ScanResult.model_validate()
   - Accumulate placeholders: `self._vault.update(result.placeholders)`
   - Return the ScanResult
   - Have a brief docstring: "Send text + scanners to server, accumulate placeholders, return result."

3. **Simplify scan_input()** to delegate to _scan():
   ```python
   def scan_input(self, text: str, scanners: list) -> ScanResult:
   ```
   Keep the existing docstring (it is comprehensive and accurate). Add one line at the end of the docstring noting delegation: body becomes `return self._scan(text, scanners)`.

4. **Replace scan_output() stub** with working implementation:
   - Change signature from `(self, prompt: str, output: str, scanners: list)` to `(self, text: str, scanners: list) -> ScanResult`
   - Remove NotImplementedError
   - Body: `return self._scan(text, scanners)`
   - Write a new docstring following scan_input()'s style:
     ```
     Scan LLM output text through the given scanners.

     Calls the same /api/security/scan endpoint as scan_input().
     Placeholders from the result are accumulated into the session vault
     for later deanonymize() calls.

     Args:
         text: The text to scan (e.g., LLM response before returning to user).
         scanners: List of scanner instances (BaseScanner subclasses).

     Returns:
         ScanResult: Parsed scan result with status, processed_text,
             placeholders, per-scanner breakdown, and risk categories.

     Raises:
         AuthError: API key was rejected.
         ValidationError: Request payload failed server-side validation.
         ServerError: Server returned 5xx.
         ConnectionError: Could not reach the server or request timed out.
     ```

5. **Enhance deanonymize()** with debug warnings:
   - First: if `not self._vault`, log debug message "deanonymize called but vault is empty -- no placeholders to restore" and return text early.
   - Then: perform replacements using sorted vault items (longest placeholder first -- defensive against prefix overlap).
   - After replacements: if result == text (nothing was replaced), log debug message "deanonymize found no known placeholders in the provided text".
   - Return result.
   - Update docstring to mention exact-match semantics and that all occurrences of each placeholder are replaced.
   - Add a code comment explaining why cascading replacement is safe: "# Safe: original PII values cannot contain [LABEL-HASH] patterns"

6. **Do NOT change**: _validate_credentials(), clear_cache(), __init__(), class docstring, module docstring (except updating it to mention scan_output if needed). Do NOT change __init__.py, models.py, or any other file.

IMPORTANT: Preserve all existing imports. Do not remove `from .exceptions import ...` or `from .models import ...`. The `import requests as _requests` alias must remain.
  </action>
  <verify>
Run these checks:
```bash
cd /Users/shalev/Desktop/meshulash_guard
.venv/bin/python -c "
from meshulash_guard import Guard
import inspect

# Verify scan_output signature matches scan_input
si = inspect.signature(Guard.scan_output)
expected_params = ['self', 'text', 'scanners']
actual_params = list(si.parameters.keys())
assert actual_params == expected_params, f'scan_output params: {actual_params}'

# Verify _scan exists
assert hasattr(Guard, '_scan'), '_scan method missing'

# Verify deanonymize still exists
assert hasattr(Guard, 'deanonymize'), 'deanonymize method missing'

# Verify clear_cache still exists
assert hasattr(Guard, 'clear_cache'), 'clear_cache method missing'

# Verify no NotImplementedError in scan_output source
src = inspect.getsource(Guard.scan_output)
assert 'NotImplementedError' not in src, 'scan_output still raises NotImplementedError'
assert 'v2' not in src.lower(), 'scan_output still references v2'

# Verify _scan is called by both
si_src = inspect.getsource(Guard.scan_input)
assert '_scan' in si_src, 'scan_input does not delegate to _scan'
so_src = inspect.getsource(Guard.scan_output)
assert '_scan' in so_src, 'scan_output does not delegate to _scan'

print('All signature and structure checks passed')
"
```
  </verify>
  <done>guard.py contains _scan() private method, scan_input() and scan_output() both delegate to it, scan_output() has correct (text, scanners) signature with no NotImplementedError, deanonymize() has debug logging with empty vault and no-match cases, module-level logger is configured</done>
</task>

<task type="auto">
  <name>Task 2: Verify vault workflow end-to-end without live server</name>
  <files>src/meshulash_guard/guard.py</files>
  <action>
Run a comprehensive in-process verification of the vault workflow by constructing a Guard instance with mocked credentials and manually exercising the vault methods. This does NOT require a live server -- it tests the client-side vault logic directly.

Execute a Python script that:

1. **Verify vault accumulation**: Directly set `guard._vault` (bypassing server) and confirm deanonymize works:
   ```python
   # Create guard with mocked init (skip _validate_credentials)
   Guard._validate_credentials = lambda self: None
   guard = Guard(api_key="test", tenant_id="test")

   # Simulate scan_input vault accumulation
   guard._vault.update({"[EMAIL_ADDRESS-A1B2]": "john@example.com"})
   guard._vault.update({"[PHONE_NUMBER-C3D4]": "555-0123"})

   # Verify accumulation (both entries present)
   assert len(guard._vault) == 2

   # Verify deanonymize replaces all placeholders
   text = "Email: [EMAIL_ADDRESS-A1B2], Phone: [PHONE_NUMBER-C3D4]"
   result = guard.deanonymize(text)
   assert result == "Email: john@example.com, Phone: 555-0123"
   ```

2. **Verify repeated placeholder replacement**: If LLM repeats a placeholder, all occurrences get replaced:
   ```python
   text = "[EMAIL_ADDRESS-A1B2] is [EMAIL_ADDRESS-A1B2]"
   result = guard.deanonymize(text)
   assert result == "john@example.com is john@example.com"
   ```

3. **Verify clear_cache resets vault**:
   ```python
   guard.clear_cache()
   assert len(guard._vault) == 0
   ```

4. **Verify deanonymize on empty vault returns text unchanged**:
   ```python
   result = guard.deanonymize("Hello world")
   assert result == "Hello world"
   ```

5. **Verify deanonymize when no placeholders match**:
   ```python
   guard._vault.update({"[NAME-XXXX]": "Alice"})
   result = guard.deanonymize("No placeholders here")
   assert result == "No placeholders here"
   ```

6. **Verify debug logging fires** (capture log output):
   ```python
   import logging
   # Set up a handler to capture debug messages
   handler = logging.handlers.MemoryHandler(capacity=100)
   logger = logging.getLogger("meshulash_guard.guard")
   logger.addHandler(handler)
   logger.setLevel(logging.DEBUG)

   guard.clear_cache()
   guard.deanonymize("test")  # empty vault -- should log

   guard._vault.update({"[X-1234]": "val"})
   guard.deanonymize("no match here")  # vault has entries but no match -- should log
   ```

If any assertion fails, the script exits non-zero and prints the failure. If all pass, print success summary.
  </action>
  <verify>
The verification script itself IS the verify step. Run:
```bash
cd /Users/shalev/Desktop/meshulash_guard
.venv/bin/python -c "
from meshulash_guard.guard import Guard
import logging

# Bypass server validation for unit testing
Guard._validate_credentials = lambda self: None
guard = Guard(api_key='test', tenant_id='test')

# 1. Vault accumulation
guard._vault.update({'[EMAIL_ADDRESS-A1B2]': 'john@example.com'})
guard._vault.update({'[PHONE_NUMBER-C3D4]': '555-0123'})
assert len(guard._vault) == 2, 'Vault should have 2 entries'

# 2. Deanonymize replaces all
text = 'Email: [EMAIL_ADDRESS-A1B2], Phone: [PHONE_NUMBER-C3D4]'
assert guard.deanonymize(text) == 'Email: john@example.com, Phone: 555-0123'

# 3. Repeated placeholders
text2 = '[EMAIL_ADDRESS-A1B2] and [EMAIL_ADDRESS-A1B2]'
assert guard.deanonymize(text2) == 'john@example.com and john@example.com'

# 4. Clear cache
guard.clear_cache()
assert len(guard._vault) == 0, 'Vault should be empty after clear_cache'

# 5. Deanonymize on empty vault
assert guard.deanonymize('Hello') == 'Hello'

# 6. Deanonymize with no matching placeholders
guard._vault.update({'[NAME-XXXX]': 'Alice'})
assert guard.deanonymize('No match') == 'No match'

# 7. scan_output signature check
import inspect
sig = inspect.signature(Guard.scan_output)
params = list(sig.parameters.keys())
assert params == ['self', 'text', 'scanners'], f'Wrong params: {params}'

# 8. Verify _scan exists and is used
assert hasattr(Guard, '_scan')
assert '_scan' in inspect.getsource(Guard.scan_output)
assert 'NotImplementedError' not in inspect.getsource(Guard.scan_output)

print('ALL CHECKS PASSED: vault accumulation, deanonymize, clear_cache, scan_output signature')
"
```
  </verify>
  <done>All 8 verification checks pass: vault accumulates across multiple updates, deanonymize replaces all occurrences of all known placeholders, repeated placeholders are replaced, clear_cache empties the vault, empty-vault deanonymize returns text unchanged, no-match deanonymize returns text unchanged, scan_output has correct (text, scanners) signature, _scan private method exists and is used</done>
</task>

</tasks>

<verification>
Phase 3 is complete when:
1. `guard.scan_output(text, scanners)` works with same signature as `scan_input()` -- no NotImplementedError
2. `guard.deanonymize(text)` restores all known placeholders from the session vault
3. Multiple vault accumulations (simulating multiple scan_input calls) are all restorable
4. `guard.clear_cache()` empties the vault completely
5. Debug logging fires on empty vault and no-match cases
6. No regressions: scan_input docstring preserved, clear_cache unchanged, __init__.py unchanged
</verification>

<success_criteria>
- `python -c "from meshulash_guard import Guard; import inspect; assert 'NotImplementedError' not in inspect.getsource(Guard.scan_output)"` passes
- All 8 verification checks from Task 2 pass
- guard.py contains `_scan()`, `scan_input()` delegates to it, `scan_output()` delegates to it
- `logging.getLogger(__name__)` module-level logger present
- deanonymize has two debug log paths (empty vault, no matches found)
</success_criteria>

<output>
After completion, create `.planning/phases/03-vault/03-01-SUMMARY.md`
</output>
