# Phase 1: Foundation - Research

**Researched:** 2026-02-26
**Domain:** Python SDK packaging + Flask endpoint + Pydantic result types
**Confidence:** HIGH

## Summary

Phase 1 has two independent work streams that must both be complete before Phase 2 can run: a new Flask endpoint on the security server, and a pip-installable Python package with the SDK scaffold. The two streams share zero code but must agree on a JSON contract (the request/response shape for `/api/security/scan`).

The server-side work adds one route to an existing Flask blueprint, adds a `process_sdk()` function that receives `guardline_specs` inline rather than fetching from DB, and adds JSON content-type support alongside existing multipart support. The client-side work creates `meshulash-guard` as a pyproject.toml-based Python package with Pydantic v2 models, a `requests.Session`-based HTTP client, a custom exception hierarchy, and the abstract base classes that Phase 2 scanners will implement.

Both work streams are well-established territory with stable APIs. Pydantic 2.12.5 is current, requests 2.32.5 is current, Flask 3.1.x is current, hatchling 1.29.0 is the recommended build backend. All research is verified against official documentation.

**Primary recommendation:** Build the SDK with a flat `src/meshulash_guard/` layout (not a nested `src/src/`) using hatchling as the build backend, Pydantic v2 `BaseModel` for all result types, `requests.Session` with default headers for the HTTP client, and a three-retry strategy for 5xx responses only.

---

## Standard Stack

### Core (SDK Side)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| pydantic | 2.12.5 | Result type validation and serialization | Locked decision; fastest Python data validation, Rust core, used by FastAPI/OpenAI SDK |
| requests | 2.32.5 | HTTP client | Locked decision; mature, sync-only (matches v1 requirement), session-level headers |
| hatchling | 1.29.0 | pyproject.toml build backend | PEP 517/518 compliant, minimal config, PyPA tutorial default for new projects |

### Core (Server Side)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Flask | 3.1.x | Existing web framework | Already used — add endpoint to existing blueprint |

### Supporting (SDK Side)

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| urllib3 | bundled with requests | Retry adapter | Configure `HTTPAdapter(max_retries=Retry(...))` for 5xx retry |
| Python stdlib `abc` | stdlib | Abstract base classes | `BaseScanner`, `BaseNormalizer` contracts |
| Python stdlib `enum` | stdlib | Action and Condition enums | Python 3.11+ `StrEnum` available; use plain `Enum` with string values for 3.9 compat |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| requests | httpx | httpx supports async but async is deferred to v2; requests is simpler for sync-only |
| hatchling | setuptools | Setuptools has more legacy config baggage; hatchling is cleaner for new projects |
| Pydantic v2 BaseModel | dataclasses | Pydantic adds validation, `.model_dump()`, `.model_validate()`, JSON serialization — dataclasses have none of this |
| plain Enum | StrEnum | StrEnum (Python 3.11+) allows direct string comparison; plain Enum is safer for 3.9 compat but requires `.value` for string output |

### Installation

```bash
# SDK dependencies (go into pyproject.toml [project] dependencies)
pydantic>=2.0,<3
requests>=2.28,<3

# Build tooling (never ships in the package)
pip install hatchling build
```

---

## Architecture Patterns

### Recommended Project Structure

```
meshulash-guard/           # repo root
├── pyproject.toml         # package metadata and build config
├── README.md
├── src/
│   └── meshulash_guard/   # importable as meshulash_guard
│       ├── __init__.py    # exports Guard, Action, Condition, ScanResult
│       ├── guard.py       # Guard class (HTTP client + scan_input/scan_output stubs)
│       ├── exceptions.py  # MeshulashError hierarchy
│       ├── models.py      # Pydantic result types (ScanResult, ScannerResult, Detection)
│       ├── enums.py       # Action, Condition enums
│       ├── _http.py       # requests.Session builder, private
│       └── scanners/
│           ├── __init__.py   # re-exports PIIScanner, TopicScanner, etc.
│           └── base.py       # BaseScanner, BaseNormalizer abstract classes
└── tests/
    └── test_guard.py
```

**Why `src/` layout:** The PyPA packaging guide strongly recommends `src/` for installable packages. It prevents accidentally importing the development tree instead of the installed package during tests. The security-server codebase is separate and not affected.

**Security server** (existing repo, add to existing file):
```
aim/security-server/
└── security/
    ├── routes.py            # ADD: /api/security/scan route to existing blueprint
    └── processing.py        # ADD: process_sdk(text, guardline_specs) function
```

### Pattern 1: pyproject.toml for Pip-Installable Package

**What:** PEP 517/518 compliant build configuration — the single source of truth for package metadata and dependencies.
**When to use:** Always for new Python packages. setup.py is deprecated for new projects.

```toml
# Source: https://packaging.python.org/en/latest/tutorials/packaging-projects/
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"

[project]
name = "meshulash-guard"
version = "0.1.0"
description = "Python SDK for the Meshulash AI security engine"
readme = "README.md"
requires-python = ">=3.9"
license = "Proprietary"
authors = [
    { name = "Meshulash", email = "dev@meshulash.ai" },
]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
dependencies = [
    "pydantic>=2.0,<3",
    "requests>=2.28,<3",
]

[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-mock"]

[tool.hatch.build.targets.wheel]
packages = ["src/meshulash_guard"]
```

### Pattern 2: requests.Session with Default Headers

**What:** Create the session once at `Guard.__init__()` and stamp API key and tenant ID headers on it. Every subsequent request inherits those headers automatically.
**When to use:** Any SDK that makes multiple HTTP requests with shared auth.

```python
# Source: https://docs.python-requests.org/en/latest/user/advanced/
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def _build_session(api_key: str, tenant_id: str, timeout: int = 60) -> requests.Session:
    session = requests.Session()
    session.headers.update({
        "X-Api-Key": api_key,
        "X-Tenant-Id": tenant_id,
        "Content-Type": "application/json",
        "Accept": "application/json",
    })
    # Retry on transient server errors, NOT on 4xx (those are client mistakes)
    retry = Retry(
        total=3,
        backoff_factor=0.5,
        status_forcelist=[502, 503, 504],
        allowed_methods={"POST"},   # POST must be explicitly included — not idempotent by default
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session
```

**Critical note on POST + Retry:** urllib3's `Retry` does NOT retry POST by default (POST is not idempotent per HTTP RFC). You must pass `allowed_methods={"POST"}` explicitly. Failure to do so means all scan requests silently never retry.

### Pattern 3: Pydantic v2 Result Types

**What:** Define all result objects as Pydantic `BaseModel` subclasses. Use `model_validate()` to parse server JSON responses. Use `model_dump()` for serialization.
**When to use:** Any structured data returned from the API.

```python
# Source: https://docs.pydantic.dev/latest/concepts/models/
from pydantic import BaseModel, ConfigDict
from typing import Optional
from .enums import Action

class Detection(BaseModel):
    model_config = ConfigDict(frozen=True)  # immutable — results shouldn't be mutated

    label: str
    text: str
    start: int
    end: int
    confidence: float
    action: Action

class ScannerResult(BaseModel):
    model_config = ConfigDict(frozen=True)

    triggered: bool
    score: float
    detections: list[Detection]

class ScanResult(BaseModel):
    model_config = ConfigDict(frozen=True)

    status: str                              # "clean" | "secured" | "blocked"
    processed_text: str
    placeholders: dict[str, str]
    level: int
    scanners: dict[str, ScannerResult]       # keyed by scanner ID
    risk_categories: list[str]

    def model_dump_json_safe(self) -> dict:
        """Serialize with JSON-compatible types (no enum objects)."""
        return self.model_dump(mode="json")
```

**Parsing server response:**
```python
# Source: https://docs.pydantic.dev/latest/concepts/models/
raw: dict = response.json()
result = ScanResult.model_validate(raw)
```

### Pattern 4: Custom Exception Hierarchy

**What:** Derive all SDK exceptions from a single `MeshulashError` base class so callers can catch at any granularity.
**When to use:** Any library with a public API — prevents exception leakage from third-party libs.

```python
# Source: Python docs + boto3 pattern (verified authoritative)
class MeshulashError(Exception):
    """Base exception for all meshulash-guard errors."""
    pass

class AuthError(MeshulashError):
    """API key invalid or missing."""
    pass

class ServerError(MeshulashError):
    """Server returned 5xx response."""

    def __init__(self, message: str, status_code: int):
        super().__init__(message)
        self.status_code = status_code

class ConnectionError(MeshulashError):
    """Could not reach the security server."""
    pass

class ValidationError(MeshulashError):
    """Request payload failed server-side validation."""
    pass
```

**Note:** Do NOT name the class `ConnectionError` using Python builtin — use `from meshulash_guard.exceptions import ConnectionError` which shadows it within the package. This is standard (requests does the same with `requests.exceptions.ConnectionError`).

### Pattern 5: Flask Endpoint — JSON + Multipart Support

**What:** Add a POST route to the existing `security_blueprint` that reads either JSON or multipart. Dispatch to a new `process_sdk()` function that takes `guardline_specs` inline.
**When to use:** Extending an existing Flask blueprint with a new endpoint.

```python
# Source: Flask 3.1.x docs — https://flask.palletsprojects.com/en/stable/blueprints/
from flask import request, jsonify

@security_blueprint.route("/api/security/scan", methods=["POST"])
def scan_sdk():
    # Support both JSON (text scanning) and multipart (future file scanning)
    if request.is_json:
        data = request.get_json(silent=True)
        if not data:
            return jsonify({"error": "Invalid JSON body"}), 400
    elif request.form:
        # Future: multipart for file scanning
        data = request.form.to_dict()
    else:
        return jsonify({"error": "Unsupported content type"}), 415

    text = data.get("text")
    guardline_specs = data.get("guardline_specs")
    api_key = request.headers.get("X-Api-Key")
    tenant_id = request.headers.get("X-Tenant-Id")

    if not text or not guardline_specs:
        return jsonify({"error": "text and guardline_specs required"}), 400

    # Validate API key via backend call (same pattern as existing process())
    if not _validate_api_key(api_key, tenant_id):
        return jsonify({"error": "Invalid or missing API key"}), 401

    result = process_sdk(text=text, guardline_specs=guardline_specs)
    return jsonify(result), 200
```

### Pattern 6: Abstract Base Classes

**What:** `BaseScanner` and `BaseNormalizer` use Python stdlib `abc.ABC` + `@abstractmethod`. Subclasses that don't implement the abstract methods raise `TypeError` at instantiation time (not at call time).
**When to use:** Establishing contracts that Phase 2 scanner implementations must fulfill.

```python
# Source: https://docs.python.org/3/library/abc.html
from abc import ABC, abstractmethod

class BaseScanner(ABC):
    """Base class for all scanners.

    Each scanner translates its configuration into a guardline spec
    the security server understands.
    """

    @abstractmethod
    def to_guardline_spec(self) -> dict:
        """Translate scanner config into a guardline_spec dict.

        Returns:
            dict: A single guardline spec suitable for the guardline_specs
                  payload sent to /api/security/scan.
        """

class BaseNormalizer(ABC):
    """Base class for normalizers. Contract defined in v1; server support in v2."""

    @abstractmethod
    def to_normalizer_spec(self) -> dict:
        """Translate normalizer config into a server-side normalizer spec."""
```

### Pattern 7: Action and Condition Enums

**What:** Use Python stdlib `enum.Enum` with string values for maximum compatibility (Python 3.9+). `StrEnum` is available on 3.11+ but adds fragility if someone runs 3.9.
**When to use:** When enum values need to map exactly to server string values.

```python
# Source: https://docs.python.org/3/library/enum.html
from enum import Enum

class Action(str, Enum):
    """Maps to server PolicyAction values."""
    REPLACE = "replace"
    BLOCK = "block"
    LOG = "log"

class Condition(str, Enum):
    """Maps to server gating conditions."""
    ANY = "any"
    ALL = "all"
    K_OF = "k_of"
    CONTEXTUAL = "contextual_analysis"
```

**Why `(str, Enum)` mixin:** Mixing `str` into the Enum base makes members directly usable as strings (e.g., in JSON serialization), while still being proper enum members for type checking. This is the standard pattern used by OpenAI, Stripe, and Anthropic SDKs.

### Pattern 8: Guard Class Init with API Key Validation

**What:** `Guard.__init__()` builds the session, then makes a lightweight validation call to fail fast if the key is invalid. This matches the user decision to validate on init.
**When to use:** Always for SDK clients — fail loudly at construction, not 10 calls later.

```python
class Guard:
    DEFAULT_BASE_URL = "https://app.meshulash.ai"
    DEFAULT_TIMEOUT = 60

    def __init__(
        self,
        api_key: str,
        tenant_id: str,
        server_url: str | None = None,
        timeout: int = 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] = {}

        # Validate key on init — raises AuthError early
        self._validate_credentials()

    def _validate_credentials(self) -> None:
        """Make a lightweight call to verify the API key is valid."""
        try:
            resp = self._session.get(
                f"{self._base_url}/api/security/health",
                timeout=self._timeout,
            )
        except requests.exceptions.ConnectionError as e:
            raise ConnectionError(f"Cannot reach server: {self._base_url}") from e

        if resp.status_code == 401:
            raise AuthError("Invalid API key or tenant ID")
        if resp.status_code >= 500:
            raise ServerError("Server unavailable during initialization", resp.status_code)

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

    def scan_output(self, prompt: str, output: str, scanners: list[BaseScanner]) -> ScanResult:
        """Scan LLM output. Raises NotImplementedError in v1."""
        raise NotImplementedError("scan_output() is available in v2")

    def deanonymize(self, text: str) -> str:
        """Replace placeholders in text with original values from session vault."""
        for placeholder, original in self._vault.items():
            text = text.replace(placeholder, original)
        return text

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

### Pattern 9: Top-Level `__init__.py` Exports

**What:** Expose the most-used names at the package level so `from meshulash_guard import Guard` works.
**When to use:** The public API surface of an SDK.

```python
# src/meshulash_guard/__init__.py
from .guard import Guard
from .models import ScanResult, ScannerResult, Detection
from .enums import Action, Condition
from .exceptions import MeshulashError, AuthError, ServerError, ConnectionError, ValidationError

__version__ = "0.1.0"
__all__ = [
    "Guard",
    "ScanResult",
    "ScannerResult",
    "Detection",
    "Action",
    "Condition",
    "MeshulashError",
    "AuthError",
    "ServerError",
    "ConnectionError",
    "ValidationError",
]
```

**Scanners NOT exported at top level** — they live in `meshulash_guard.scanners` (consistent with the decision that each scanner is importable from its own module). This keeps the top-level namespace clean and matches the `openai` SDK pattern.

### Anti-Patterns to Avoid

- **Using `requests.get()`/`requests.post()` directly instead of a session:** Session-level headers must be set per-call, and no retry adapter can be mounted. Always use `requests.Session`.
- **Not setting timeout:** `requests` has no default timeout. A hung scan call will block forever. Always pass `timeout=self._timeout`.
- **Catching `Exception` broadly in error translation:** Catch specific `requests` exceptions (`requests.exceptions.Timeout`, `requests.exceptions.ConnectionError`) and re-raise as SDK exceptions. Catching `Exception` swallows bugs.
- **Using `setup.py` for a new package:** setup.py is deprecated for new projects as of PEP 517/518. Use pyproject.toml with hatchling.
- **Defining enums with integer values:** The server expects string values ("replace", "block", etc.). Integer enums require `.value` on every use. Use `(str, Enum)` mixin.
- **Validating API key with `/api/security/scan` on init:** The scan endpoint runs the full pipeline. Use a lightweight health/validation endpoint instead.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| HTTP retry logic | Custom retry loop with `time.sleep()` | `urllib3.util.Retry` + `HTTPAdapter` | urllib3 handles exponential backoff, status filtering, method allowlisting correctly |
| JSON response parsing with type safety | `response.json()` + manual dict access | Pydantic `model_validate()` | Pydantic validates field types, raises clear errors on schema mismatch, handles optional/missing fields |
| Exception hierarchy from scratch | Single `Exception` subclass with type field | Proper class hierarchy | Callers can `except AuthError` vs `except MeshulashError` at the right granularity |
| Package metadata in `setup.py` | `setup.py` with `install_requires` | `pyproject.toml` `[project]` table | setup.py is deprecated; pyproject.toml is the standard that pip and build tools read natively |
| Session-level auth header injection | Set header in each `requests.post()` call | `session.headers.update()` once at init | Session headers persist across all calls; manual injection is error-prone and repetitive |

**Key insight:** The SDK's job is to be a thin, reliable wrapper. Every problem in this list has a well-tested library solution. Custom implementations introduce bugs that are hard to trace (e.g., a DIY retry that retries POST unconditionally, causing duplicate scans).

---

## Common Pitfalls

### Pitfall 1: POST Retry Silently Disabled

**What goes wrong:** Developer configures `Retry(total=3, status_forcelist=[502, 503, 504])` but scans never retry on 5xx.
**Why it happens:** urllib3's `Retry` only retries idempotent methods by default (GET, PUT, DELETE). POST is excluded because retrying a non-idempotent request can have side effects.
**How to avoid:** Always pass `allowed_methods={"POST"}` when configuring retry for scan requests. POST scan calls are effectively idempotent (scanning the same text twice produces the same result), so retrying is safe.
**Warning signs:** 502 errors from load balancer during Cloud Run cold starts silently fail rather than retrying.

### Pitfall 2: Flat Layout Causes Test Import Confusion

**What goes wrong:** Running `pytest` from the repo root imports the local `meshulash_guard/` directory (development tree) even when the installed package differs. Tests pass locally but fail after `pip install`.
**Why it happens:** Python adds the current working directory to `sys.path`. A flat layout means the local directory shadow-imports the package without installation.
**How to avoid:** Use `src/meshulash_guard/` layout. Tests must run against the installed package (via `pip install -e .`), not the raw source directory.
**Warning signs:** `import meshulash_guard` works without `pip install -e .` — that's the flat layout import trap.

### Pitfall 3: requests.Session.headers Content-Type Conflicts with Multipart

**What goes wrong:** Setting `"Content-Type": "application/json"` as a session-level default header, then trying to send a multipart request later — the hard-coded JSON content type overrides the multipart boundary.
**Why it happens:** Session headers merge with per-request headers, but the session-level value wins for the same key unless overridden per-call.
**How to avoid:** Do NOT set `Content-Type` as a session-level header for the SDK's HTTP client. Set it per-request (or let requests set it automatically for multipart). For Phase 1, the SDK only sends JSON, so this pitfall surfaces in Phase 2 if file scanning is ever added.
**Warning signs:** Server returns 400 "Unexpected content type" for multipart uploads.

### Pitfall 4: Flask `request.get_json()` Returns None Without force=True

**What goes wrong:** Client sends JSON but server endpoint receives `None` from `request.get_json()`.
**Why it happens:** Flask checks `Content-Type: application/json` before parsing. If the client omits or misspecifies the header, `get_json()` returns `None` silently (with `silent=True`).
**How to avoid:** Use `request.is_json` to check first, then `request.get_json(silent=True)`. Alternatively use `request.get_json(force=True, silent=True)` to always attempt JSON parsing regardless of content type — but this conflicts with multipart support. The correct approach for this endpoint: check `request.is_json` first (handles JSON), then `request.form` (handles multipart).
**Warning signs:** Server logs `None` from `get_json()` for requests that visually look like JSON.

### Pitfall 5: Pydantic v1 vs v2 API Mismatch

**What goes wrong:** Code uses `.dict()` or `.parse_obj()` (Pydantic v1 API) but the installed package is Pydantic v2.
**Why it happens:** Pydantic v2 deprecated the v1 API. `.dict()` still works in v2 (compatibility shim) but emits deprecation warnings and may be removed.
**How to avoid:** Always use v2 API: `.model_dump()`, `.model_validate()`, `.model_dump_json()`. Specify `pydantic>=2.0,<3` in dependencies to prevent accidental v1 installation.
**Warning signs:** `PydanticDeprecatedSince20` warnings in test output.

### Pitfall 6: Exception Shadowing Python Builtins

**What goes wrong:** `from meshulash_guard.exceptions import ConnectionError` silently shadows the Python builtin `ConnectionError` in any file that does this import.
**Why it happens:** Python's `requests` library uses the same pattern (it has `requests.exceptions.ConnectionError`), so this is an accepted convention — but it must be intentional.
**How to avoid:** Always import SDK exceptions via full module path or explicit alias: `from meshulash_guard import exceptions as mg_exc`. Document the shadowing in the `exceptions.py` module docstring.
**Warning signs:** `except ConnectionError` in calling code catches the SDK exception when it was intended to catch the builtin.

### Pitfall 7: `__init__.py` Missing `__all__`

**What goes wrong:** `from meshulash_guard import *` exports every private name, internal helper, and transitive import.
**Why it happens:** Without `__all__`, Python exports everything not prefixed with `_`.
**How to avoid:** Define `__all__` explicitly in `__init__.py`. Use leading underscores for internal modules (`_http.py`).
**Warning signs:** `dir(meshulash_guard)` shows internal names like `_build_session`.

---

## Code Examples

### Complete pyproject.toml

```toml
# Source: https://packaging.python.org/en/latest/tutorials/packaging-projects/
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"

[project]
name = "meshulash-guard"
version = "0.1.0"
description = "Python SDK for the Meshulash AI security engine"
readme = "README.md"
requires-python = ">=3.9"
license = "Proprietary"
authors = [
    { name = "Meshulash", email = "dev@meshulash.ai" },
]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
    "Intended Audience :: Developers",
    "Topic :: Security",
]
dependencies = [
    "pydantic>=2.0,<3",
    "requests>=2.28,<3",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-mock>=3.0",
    "responses>=0.25",   # mock requests in tests
]

[project.urls]
Homepage = "https://docs.meshulash.ai"

[tool.hatch.build.targets.wheel]
packages = ["src/meshulash_guard"]
```

### Building and Installing

```bash
# Install in editable mode for development (required with src/ layout)
pip install -e .

# Build distribution
pip install build
python -m build

# Verify package installs cleanly
pip install dist/meshulash_guard-0.1.0-py3-none-any.whl
python -c "from meshulash_guard import Guard; print('OK')"
```

### Flask Endpoint — process_sdk() Function

```python
# Source: Flask 3.1.x docs
def process_sdk(text: str, guardline_specs: dict) -> dict:
    """
    Run the 5-stage pipeline with inline guardline_specs.
    Unlike process(), this does NOT fetch guardlines from DB.

    Args:
        text: Input text to scan.
        guardline_specs: Guardline configuration dict from the SDK.

    Returns:
        dict: Per-scanner result breakdown serializable to JSON.
    """
    # Reuse existing pipeline components with guardline_specs injected directly
    # ... pipeline stages: recognize, validate, score, gate, act ...
    return {
        "status": "clean",           # "clean" | "secured" | "blocked"
        "processed_text": text,
        "placeholders": {},
        "level": 0,
        "scanners": {
            "scanner_id": {
                "triggered": False,
                "score": 0.0,
                "detections": [],
            }
        },
        "risk_categories": [],
    }
```

### Pydantic Model Validation from Server Response

```python
# Source: https://docs.pydantic.dev/latest/concepts/models/
import requests
from .models import ScanResult
from .exceptions import ServerError, ValidationError

def _post_scan(self, payload: dict) -> ScanResult:
    try:
        resp = self._session.post(
            f"{self._base_url}/api/security/scan",
            json=payload,
            timeout=self._timeout,
        )
    except requests.exceptions.Timeout:
        raise ConnectionError(f"Request timed out after {self._timeout}s")
    except requests.exceptions.ConnectionError as e:
        raise ConnectionError(f"Cannot connect to server") from e

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

    resp.raise_for_status()
    return ScanResult.model_validate(resp.json())
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `setup.py` with `install_requires` | `pyproject.toml` `[project]` table | PEP 517/518 (2016-2017), widely adopted by 2023 | setup.py is deprecated for new projects; hatchling replaces setuptools for new packages |
| Pydantic v1 `.dict()` / `.parse_obj()` | Pydantic v2 `.model_dump()` / `.model_validate()` | Pydantic v2 released June 2023 | Rust-core rewrite, 5-10x faster, breaking API changes |
| `from enum import Enum` with arbitrary values | `(str, Enum)` mixin or `StrEnum` (Python 3.11+) | `StrEnum` added Python 3.11 (2022) | Members directly usable as strings without `.value` |
| `urllib3.util.Retry(method_whitelist=...)` | `urllib3.util.Retry(allowed_methods=...)` | urllib3 2.x | `method_whitelist` deprecated; use `allowed_methods` |

**Deprecated/outdated:**
- `setup.py` for new projects: replaced by pyproject.toml
- `pydantic.validator` decorator: replaced by `@field_validator` in Pydantic v2
- `urllib3.util.Retry(method_whitelist=...)`: renamed to `allowed_methods` in urllib3 2.x

---

## Open Questions

1. **Health/validation endpoint on the server**
   - What we know: The `Guard.__init__()` must call a lightweight endpoint to validate the API key on construction (user decision).
   - What's unclear: Does a `/api/security/health` endpoint already exist on the security server? If not, it needs to be created (or `/api/security/scan` used with a minimal no-op payload, though that runs the full pipeline).
   - Recommendation: Check the existing security server routes. If no health endpoint exists, add `/api/security/health` returning `200 OK` with API key validation as part of Phase 1 server work. Do NOT use `/api/security/scan` as a health check — it's expensive.

2. **`scan_input()` response shape agreement**
   - What we know: The server returns per-scanner results; the SDK parses them into `ScanResult`.
   - What's unclear: The exact JSON key names from `process_sdk()` must match what `ScanResult.model_validate()` expects. These must be agreed on before implementation.
   - Recommendation: Define the JSON contract explicitly in the plan (field names, types, nesting). Treat this as a cross-team API contract, not an implementation detail.

3. **`pyproject.toml` version strategy**
   - What we know: v1 ships as 0.1.0.
   - What's unclear: Whether to use dynamic versioning (reading from a `__version__` variable or git tag) or static versioning in pyproject.toml.
   - Recommendation: Use static versioning (`version = "0.1.0"` in pyproject.toml) for Phase 1 simplicity. Hatchling supports `dynamic = ["version"]` pointing to `src/meshulash_guard/__init__.py:__version__` if dynamic versioning is needed later.

---

## Sources

### Primary (HIGH confidence)

- https://docs.pydantic.dev/latest/concepts/models/ — Pydantic v2 BaseModel, model_dump, model_validate, ConfigDict (version 2.12.5 verified)
- https://docs.pydantic.dev/latest/concepts/serialization/ — model_dump parameters, by_alias, exclude_none, mode='json'
- https://pypi.org/project/pydantic/ — version 2.12.5 confirmed (released Nov 2025)
- https://packaging.python.org/en/latest/tutorials/packaging-projects/ — pyproject.toml structure, hatchling build backend
- https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/ — src vs flat layout tradeoffs
- https://docs.python-requests.org/en/latest/user/advanced/ — Session, headers, Retry with HTTPAdapter (version 2.32.5 confirmed)
- https://pypi.org/project/requests/ — version 2.32.5 confirmed (released Aug 2025)
- https://pypi.org/project/hatchling/ — version 1.29.0 confirmed (released Feb 2026)
- https://flask.palletsprojects.com/en/stable/blueprints/ — Flask 3.1.x Blueprint routing
- https://docs.python.org/3/library/enum.html — StrEnum (Python 3.11+), (str, Enum) mixin pattern
- https://docs.python.org/3/library/abc.html — ABC, abstractmethod

### Secondary (MEDIUM confidence)

- https://github.com/anthropics/anthropic-sdk-python — Session/headers init pattern, src/ layout convention (verified as authoritative reference SDK)
- https://github.com/stripe/stripe-python — StripeClient init pattern, error hierarchy (verified as authoritative reference SDK)
- urllib3.readthedocs.io — `allowed_methods` parameter replacing `method_whitelist` in urllib3 2.x

### Tertiary (LOW confidence)

- WebSearch: "Flask request.get_json force=True content-type" — `request.is_json` + `request.form` dual-content-type pattern (single community sources, consistent with Flask docs behavior)

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all library versions verified via official PyPI and documentation
- Architecture: HIGH — src/ layout, pyproject.toml, Pydantic patterns verified from official PyPA and Pydantic docs
- Pitfalls: HIGH for POST retry and Pydantic v1/v2 (verified); MEDIUM for Flask content-type behavior (consistent with docs)

**Research date:** 2026-02-26
**Valid until:** 2026-03-28 (30 days — all libraries are stable)
