# Phase 3: Vault - Research

**Researched:** 2026-02-26
**Domain:** Client-side placeholder vault, string replacement, Python logging, scan_output() implementation
**Confidence:** HIGH

## Summary

Phase 3 completes the PII protection workflow by finishing the vault scaffolding that already exists in `guard.py`. The codebase already has `self._vault: dict[str, str]`, `deanonymize()`, `clear_cache()`, and vault accumulation in `scan_input()`. The remaining work is small but precise: (1) implement `scan_output()` as a real server call mirroring `scan_input()`, (2) add a debug warning to `deanonymize()` when no placeholders are found in the text, and (3) change the `scan_output()` signature from the current `(prompt, output, scanners)` to match `scan_input()`'s `(text, scanners)` as decided in CONTEXT.md.

No new dependencies are needed. The vault is a plain `dict[str, str]` using `str.replace()` for deanonymization. Python's stdlib `logging` module needs to be added for the debug warning. The server endpoint is the same `/api/security/scan` used by `scan_input()`.

The primary risk area is test coverage: Phase 1 and 2 have no tests directory, and Phase 3's verification criteria (vault accumulation across calls, deanonymize correctness, clear_cache reset) are best validated with unit tests. Since `pytest` and `pytest-mock` are already in `pyproject.toml` dev dependencies, the planner should consider whether tests are in scope.

**Primary recommendation:** This phase is mostly about verifying and completing existing code. The scan_output() implementation is a near-copy of scan_input(). Extract the shared HTTP+response logic into a private method to avoid duplication. Add `logging.getLogger(__name__)` for the deanonymize debug warning.

## Standard Stack

No new dependencies are required for Phase 3. All libraries were installed in Phase 1.

### Core (Already Installed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| pydantic | >=2.0,<3 | ScanResult parsing (already used) | Phase 1 decision, locked |
| requests | >=2.28,<3 | HTTP client (already used) | Phase 1 decision, locked |
| Python stdlib `logging` | stdlib | Debug warning in deanonymize() | Standard library, zero dependency |

### No New Libraries Needed

Phase 3 adds no new concepts to the stack. The vault is a plain dict. Deanonymize is `str.replace()`. scan_output() uses the same HTTP session and endpoint as scan_input().

### Installation

Nothing new to install.

```bash
# Already done in Phase 1 -- nothing to run
.venv/bin/pip install -e ".[dev]"
```

## Architecture Patterns

### Current Project Structure (No New Files Needed)

```
src/meshulash_guard/
    guard.py            # ALL vault changes happen here
    models.py           # ScanResult already correct (no changes)
    _http.py            # No changes
    enums.py            # No changes
    exceptions.py       # No changes
    scanners/           # No changes
```

### Pattern 1: Private Shared Scan Method

**What:** Extract the HTTP call, error handling, and vault accumulation logic from `scan_input()` into a private `_scan()` method. Both `scan_input()` and `scan_output()` delegate to it.

**When to use:** When `scan_output()` is implemented, since it calls the same endpoint with the same payload structure.

```python
# Source: derived from existing guard.py scan_input() implementation
import logging

logger = logging.getLogger(__name__)

def _scan(self, text: str, scanners: list) -> ScanResult:
    """Shared implementation for scan_input() and scan_output()."""
    guardline_specs: dict[str, dict] = {}
    for i, scanner in enumerate(scanners):
        prefix = f"sdk_{scanner.__class__.__name__.lower()}_{i}"
        if hasattr(scanner, "to_guardline_specs"):
            multi_specs = scanner.to_guardline_specs()
            for key, spec in multi_specs.items():
                guardline_specs[f"{prefix}_{key}"] = spec
        else:
            guardline_specs[prefix] = scanner.to_guardline_spec()

    url = f"{self._base_url}/api/security/scan"
    payload = {"text": text, "guardline_specs": guardline_specs}

    try:
        resp = self._session.post(url, json=payload, timeout=self._timeout)
    except _requests.exceptions.ConnectionError as exc:
        raise ConnectionError("Cannot connect to server") from exc
    except _requests.exceptions.Timeout as exc:
        raise ConnectionError(
            f"Request timed out after {self._timeout}s"
        ) from exc

    if resp.status_code == 401:
        raise AuthError("API key rejected by server")
    if resp.status_code == 422:
        raise ValidationError(f"Invalid request: {resp.text}")
    if resp.status_code >= 500:
        raise ServerError(
            f"Server error {resp.status_code}", resp.status_code
        )

    resp.raise_for_status()

    result = ScanResult.model_validate(resp.json())
    self._vault.update(result.placeholders)
    return result

def scan_input(self, text: str, scanners: list) -> ScanResult:
    """Scan input text through the given scanners."""
    return self._scan(text, scanners)

def scan_output(self, text: str, scanners: list) -> ScanResult:
    """Scan LLM output text through the given scanners."""
    return self._scan(text, scanners)
```

### Pattern 2: Module-Level Logger for Debug Warning

**What:** Use Python's stdlib `logging.getLogger(__name__)` to create a module-level logger. The deanonymize() method logs a debug warning when no placeholders from the vault are found in the input text.

**When to use:** In `guard.py` for the deanonymize() debug warning.

```python
# Source: Python stdlib logging best practices
import logging

logger = logging.getLogger(__name__)

def deanonymize(self, text: str) -> str:
    """Replace placeholders in text with original values from session vault."""
    if not self._vault:
        logger.debug(
            "deanonymize called but vault is empty -- "
            "no placeholders to restore"
        )
        return text

    result = text
    for placeholder, original in self._vault.items():
        result = result.replace(placeholder, original)

    if result == text:
        logger.debug(
            "deanonymize found no placeholders in the provided text -- "
            "returning text unchanged"
        )
    return result
```

**Why `logger.debug()` not `logger.warning()`:** The CONTEXT.md says "log a debug warning." Using `debug` level is correct because in normal operation (e.g., scanning output that has no PII), this is expected behavior, not an error condition. Developers who want to see it can set their logging level to DEBUG. Using `warning` would create noise in production logs for a non-error condition.

### Pattern 3: Longest-First Replacement Order

**What:** When replacing placeholders, sort by length descending to prevent partial matches if one placeholder is a prefix of another.

**When to use:** In `deanonymize()` if there is any possibility of overlapping placeholder strings.

```python
# Defensive: sort by placeholder length (longest first) to prevent
# partial matches. E.g., [EMAIL_ADDRESS-A1B2C] before [EMAIL_ADDRESS-A1B2]
for placeholder, original in sorted(
    self._vault.items(), key=lambda x: len(x[0]), reverse=True
):
    text = text.replace(placeholder, original)
```

**Risk assessment:** Server-generated placeholder hashes should be unique and non-overlapping. The risk is LOW but the cost of the sort is negligible (vault size is typically small, <100 entries). This is a defensive measure.

### Anti-Patterns to Avoid

- **Regex-based replacement for deanonymize():** The placeholders contain special regex characters like `[` and `]`. Using `re.sub()` would require escaping every placeholder with `re.escape()`. `str.replace()` is simpler and correct since we want exact-match replacement (CONTEXT.md decision: "exact match only").
- **Making deanonymize() call the server:** The CONTEXT.md explicitly says "no second server call." The vault is fully client-side.
- **Returning a result object from deanonymize():** CONTEXT.md decision: "returns a plain string."
- **Making the vault accessible to developers:** CONTEXT.md decision: "vault is fully internal." No public property or method to read vault contents.
- **Using `re.sub` with a callback for all-at-once replacement:** While technically more correct for overlapping cases, it adds complexity for a non-issue given the `[LABEL-HASH]` format. `str.replace()` in a loop is the right tool here.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Logging | Custom print-based logging | `logging.getLogger(__name__)` | stdlib handles level filtering, formatting, and handler routing |
| String replacement | Regex-based placeholder substitution | `str.replace()` in a loop | Placeholders are literal strings with regex special chars; `str.replace()` is exact-match by design |
| HTTP request dedup | Copy-paste scan_input() for scan_output() | Extract private `_scan()` method | Same endpoint, same payload, same error handling, same vault accumulation |

**Key insight:** Phase 3 is not about building new infrastructure. It is about completing existing scaffolding and eliminating the `NotImplementedError` stub. The vault pattern (dict + str.replace) is intentionally simple.

## Common Pitfalls

### Pitfall 1: scan_output() Signature Mismatch

**What goes wrong:** The current scaffolded `scan_output(self, prompt: str, output: str, scanners: list)` has a different signature than what CONTEXT.md decided: `scan_output(text, scanners=[...])` (same as `scan_input()`).

**Why it happens:** The Phase 2 stub was a placeholder with a v2-oriented signature that assumed prompt+output context-aware scanning. Phase 3 CONTEXT.md decided on a simpler approach: scan_output scans text the same way scan_input does.

**How to avoid:** Change the signature to `scan_output(self, text: str, scanners: list) -> ScanResult` to match `scan_input()`. This is a breaking change to the stub signature, but since the stub always raised `NotImplementedError`, no caller depends on the old signature.

**Warning signs:** Type checkers or tests referencing the old `(prompt, output, scanners)` signature will break.

### Pitfall 2: Cascading Replacement in deanonymize()

**What goes wrong:** If a replacement value happens to contain another placeholder token, the second `str.replace()` pass could corrupt it. Example: vault maps `[NAME-A1B2]` to `John [EMAIL-C3D4]` (unlikely but theoretically possible), then the second pass replaces `[EMAIL-C3D4]` inside the already-replaced text.

**Why it happens:** `str.replace()` in a loop applies each replacement to the *result* of the previous replacement, not the original text.

**How to avoid:** In practice, this cannot happen because original PII values (names, emails, phone numbers) do not contain `[LABEL_NAME-HASH]` patterns. The server generates placeholders with a specific format that real-world PII values cannot match. No defensive code needed, but worth documenting in a code comment as a "why this is safe" note.

**Warning signs:** None expected in practice. If it ever occurs, deanonymize() would produce garbled output.

### Pitfall 3: Debug Warning Fires on Every Non-PII scan_output() Call

**What goes wrong:** Developer uses `scan_output()` with `TopicScanner` (no REPLACE action, so no placeholders). Then calls `deanonymize()` on the result. The debug warning fires even though this is normal usage.

**Why it happens:** The debug warning checks if any vault placeholders are present in the text, but for non-PII scans the vault may be empty or the text may not contain PII placeholders.

**How to avoid:** The debug warning should check two conditions: (1) vault is empty, or (2) vault has entries but none were found in the text. The message should distinguish these cases. Since it is at DEBUG level, it is informational only and should not alarm developers.

**Warning signs:** Developers enabling DEBUG logging see warnings that look like errors for normal non-PII flows.

### Pitfall 4: Not Updating __init__.py Exports

**What goes wrong:** If any new public API surface is added (unlikely for Phase 3 since `deanonymize`, `clear_cache`, and `scan_output` are already methods on `Guard`), it might not be importable.

**Why it happens:** Forgetting to update `__init__.py` re-exports.

**How to avoid:** Phase 3 changes are all on the `Guard` class, which is already exported. No `__init__.py` changes needed.

**Warning signs:** `from meshulash_guard import X` fails for new names.

### Pitfall 5: scan_output() Docstring Still Says "v2"

**What goes wrong:** Developer reads the docstring or IDE tooltip for `scan_output()` and sees "coming in v2" or "Not available in v1." But it is now fully implemented.

**Why it happens:** Forgetting to update the docstring when replacing the `NotImplementedError` stub.

**How to avoid:** Write a complete docstring for `scan_output()` that matches `scan_input()`'s style. Remove all "v2" references.

**Warning signs:** Documentation inconsistency.

## Code Examples

### Complete Refactored guard.py Structure

```python
# Source: derived from existing guard.py + CONTEXT.md Phase 3 decisions
"""Guard class -- the primary entry point for the meshulash-guard SDK."""

from __future__ import annotations

import logging

import requests as _requests

from ._http import _build_session
from .exceptions import AuthError, ConnectionError, ServerError, ValidationError
from .models import ScanResult

logger = logging.getLogger(__name__)


class Guard:
    """HTTP client wrapper for the Meshulash security engine."""

    DEFAULT_BASE_URL = "https://app.meshulash.ai"
    DEFAULT_TIMEOUT = 60

    def __init__(self, api_key, tenant_id, server_url=None, timeout=DEFAULT_TIMEOUT):
        self._base_url = (server_url or self.DEFAULT_BASE_URL).rstrip("/")
        self._timeout = timeout
        self._session = _build_session(api_key=api_key, tenant_id=tenant_id)
        self._vault: dict[str, str] = {}
        self._validate_credentials()

    # ... _validate_credentials unchanged ...

    def _scan(self, text: str, scanners: list) -> ScanResult:
        """Send text + scanners to server, accumulate placeholders, return result."""
        guardline_specs: dict[str, dict] = {}
        for i, scanner in enumerate(scanners):
            prefix = f"sdk_{scanner.__class__.__name__.lower()}_{i}"
            if hasattr(scanner, "to_guardline_specs"):
                multi_specs = scanner.to_guardline_specs()
                for key, spec in multi_specs.items():
                    guardline_specs[f"{prefix}_{key}"] = spec
            else:
                guardline_specs[prefix] = scanner.to_guardline_spec()

        url = f"{self._base_url}/api/security/scan"
        payload = {"text": text, "guardline_specs": guardline_specs}

        try:
            resp = self._session.post(url, json=payload, timeout=self._timeout)
        except _requests.exceptions.ConnectionError as exc:
            raise ConnectionError("Cannot connect to server") from exc
        except _requests.exceptions.Timeout as exc:
            raise ConnectionError(f"Request timed out after {self._timeout}s") from exc

        if resp.status_code == 401:
            raise AuthError("API key rejected by server")
        if resp.status_code == 422:
            raise ValidationError(f"Invalid request: {resp.text}")
        if resp.status_code >= 500:
            raise ServerError(f"Server error {resp.status_code}", resp.status_code)

        resp.raise_for_status()

        result = ScanResult.model_validate(resp.json())
        self._vault.update(result.placeholders)
        return result

    def scan_input(self, text: str, scanners: list) -> ScanResult:
        """Scan input text through the given scanners.
        [existing docstring, updated to mention _scan delegation]
        """
        return self._scan(text, scanners)

    def scan_output(self, text: str, scanners: list) -> ScanResult:
        """Scan LLM output text through the given scanners.

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

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

        Returns:
            ScanResult: Parsed scan result.
        """
        return self._scan(text, scanners)

    def deanonymize(self, text: str) -> str:
        """Replace placeholders in text with original values from session vault.

        Uses exact string matching only. If the LLM modified a placeholder
        (e.g., removed characters), that placeholder will not be restored.
        All occurrences of each known placeholder are replaced.

        Args:
            text: Text containing placeholder tokens.

        Returns:
            str: Text with all known placeholders replaced by originals.
        """
        if not self._vault:
            logger.debug(
                "deanonymize called but vault is empty -- "
                "no placeholders to restore"
            )
            return text

        result = text
        for placeholder, original in self._vault.items():
            result = result.replace(placeholder, original)

        if result == text:
            logger.debug(
                "deanonymize found no known placeholders in the provided text"
            )
        return result

    def clear_cache(self) -> None:
        """Reset the session placeholder vault."""
        self._vault.clear()
```

### Vault Accumulation Verification Pattern

```python
# How vault accumulation works across multiple scan_input() calls:

guard = Guard(api_key="sk-xxx", tenant_id="meshulash-3lqgn")

# First scan -- vault gets email placeholder
result1 = guard.scan_input("My email is john@example.com", scanners=[pii])
# result1.placeholders == {"[EMAIL_ADDRESS-A1B2]": "john@example.com"}
# guard._vault == {"[EMAIL_ADDRESS-A1B2]": "john@example.com"}

# Second scan -- vault accumulates phone placeholder
result2 = guard.scan_input("Call me at 555-0123", scanners=[pii])
# result2.placeholders == {"[PHONE_NUMBER-C3D4]": "555-0123"}
# guard._vault == {
#     "[EMAIL_ADDRESS-A1B2]": "john@example.com",
#     "[PHONE_NUMBER-C3D4]": "555-0123",
# }

# Deanonymize a response that contains both placeholders
llm_response = "I noted your email [EMAIL_ADDRESS-A1B2] and phone [PHONE_NUMBER-C3D4]"
restored = guard.deanonymize(llm_response)
# restored == "I noted your email john@example.com and phone 555-0123"

# Clear and start fresh
guard.clear_cache()
# guard._vault == {}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| scan_output() raises NotImplementedError | scan_output() calls same endpoint as scan_input() | Phase 3 (this phase) | Developers can scan LLM responses using the same pattern as input scanning |
| scan_output(prompt, output, scanners) signature | scan_output(text, scanners) signature | Phase 3 (CONTEXT.md decision) | Simpler API; prompt+output context-aware scanning is deferred to v2 |
| No logging in SDK | Module-level logger via `logging.getLogger(__name__)` | Phase 3 | Debug-level observability for deanonymize flows |

**Deprecated/outdated in this phase:**
- The `NotImplementedError("scan_output() is coming in v2")` stub is removed and replaced with a working implementation.
- The old `scan_output(self, prompt: str, output: str, scanners: list)` signature is replaced with `(self, text: str, scanners: list)`.

## Inventory of Existing Code vs. Changes Needed

This is the most important section for Phase 3 planning. The vault is largely scaffolded already.

### Already Complete (No Changes Needed)
| Feature | Location | Lines | Status |
|---------|----------|-------|--------|
| `self._vault: dict[str, str] = {}` | guard.py | L57 | Working |
| `self._vault.update(result.placeholders)` in scan_input() | guard.py | L153 | Working |
| `clear_cache()` method | guard.py | L181-187 | Working |
| `ScanResult.placeholders: dict[str, str]` | models.py | L56 | Working |

### Needs Changes
| Feature | Location | What Changes | Why |
|---------|----------|-------------|-----|
| `scan_output()` | guard.py L156-162 | Replace NotImplementedError stub with real implementation | CONTEXT.md: "now fully implemented" |
| `scan_output()` signature | guard.py L156 | Change from `(prompt, output, scanners)` to `(text, scanners)` | CONTEXT.md: "Same signature as scan_input()" |
| `deanonymize()` | guard.py L164-179 | Add debug warning when no placeholders found | CONTEXT.md: "log a debug warning" |
| Module imports | guard.py top | Add `import logging` + module-level `logger` | Needed for debug warning |
| DRY refactor | guard.py | Extract `_scan()` private method | scan_output() and scan_input() share identical logic |

### Scope Assessment
The actual code changes are small: approximately 20-30 lines modified/added in a single file (`guard.py`). The bulk of Phase 3 work is verification (confirming the vault workflow end-to-end) and writing clear docstrings.

## Open Questions

### Open Question 1: Tests in Scope?

- **What we know:** `pyproject.toml` includes `pytest>=7.0` and `pytest-mock>=3.0` in dev dependencies. No `tests/` directory exists yet. Phase 3's vault logic is highly testable without a live server (mock the HTTP layer, verify vault accumulation and deanonymize behavior).
- **What's unclear:** Whether the planner should include test creation as part of Phase 3, or defer it.
- **Recommendation:** The planner can include unit tests for the vault workflow as part of Phase 3 since the verification criteria (accumulation, deanonymize, clear_cache) map directly to test cases. However, since no test infrastructure exists yet and phases 1-2 were verified without tests, the planner may treat tests as optional. If included, use `pytest-mock` to mock `self._session.post()` and verify vault state changes.

### Open Question 2: scan_output() Future Divergence

- **What we know:** CONTEXT.md decides scan_output() has the same signature and behavior as scan_input() right now. In v2, scan_output() may need context-aware scanning (prompt + output together).
- **What's unclear:** Whether extracting a shared `_scan()` method now will complicate future v2 changes when scan_output() needs a different endpoint or payload.
- **Recommendation:** Extract `_scan()` now. When v2 changes the behavior, scan_output() can simply stop delegating to `_scan()` and implement its own logic. The refactor cost is near-zero either way.

## Sources

### Primary (HIGH confidence)
- `/Users/shalev/Desktop/meshulash_guard/src/meshulash_guard/guard.py` -- direct inspection of existing vault scaffolding (lines 56-57, 152-153, 164-187)
- `/Users/shalev/Desktop/meshulash_guard/src/meshulash_guard/models.py` -- ScanResult.placeholders field type confirmed as `dict[str, str]`
- `/Users/shalev/Desktop/meshulash_guard/.planning/phases/03-vault/03-CONTEXT.md` -- locked decisions on deanonymize behavior, scan_output signature, session lifecycle
- `/Users/shalev/Desktop/meshulash_guard/.planning/phases/02-scanners/02-VERIFICATION.md` -- Phase 2 verification confirms scan_input() and scan_output() stub are working
- [Python Logging HOWTO](https://docs.python.org/3/howto/logging.html) -- stdlib logging patterns
- [Python Logging Best Practices - SigNoz](https://signoz.io/guides/python-logging-best-practices/) -- module-level logger pattern
- [Python str.replace documentation](https://docs.python.org/3/library/stdtypes.html#str.replace) -- exact-match semantics confirmed

### Secondary (MEDIUM confidence)
- [Python Multiple String Replacements](https://sqlpey.com/python/python-multiple-string-replacements/) -- cascading replacement risk analysis for chained str.replace()

### Tertiary (LOW confidence)
- None -- all findings verified from primary sources

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- no new dependencies; existing code inspection confirms everything
- Architecture (scan refactor): HIGH -- straightforward extraction of existing working code
- Architecture (deanonymize debug warning): HIGH -- stdlib logging, well-documented pattern
- Pitfalls: HIGH -- identified from direct code inspection and CONTEXT.md constraint analysis
- scan_output signature change: HIGH -- CONTEXT.md explicitly locks this decision

**Research date:** 2026-02-26
**Valid until:** 2026-03-28 (30 days -- stable domain, all decisions locked in CONTEXT.md)
