# Phase 2: Scanners - Research

**Researched:** 2026-02-26
**Domain:** Python enum design, SDK scanner API design, guardline spec translation layer
**Confidence:** HIGH

## Summary

Phase 2 builds entirely on the Phase 1 scaffold. The contract is already defined: `BaseScanner.to_guardline_spec()` is the one method each scanner must implement, and `guard.scan_input()` already calls it. Phase 1 is fully verified (4/4 truths, all wiring confirmed). No new dependencies are needed.

The technical work divides into two parts: (1) designing the label enum structure for each of the five scanners so that each label maps to the correct server-side canonical string or TC head entry, and (2) implementing `to_guardline_spec()` to produce a dict that the server's 5-stage pipeline can consume. The guardline spec format is well-documented in the server source (`main.py`, `gating.py`) and confirmed working. The server was built to Phase 1 spec and is already handling inline guardline_specs.

The main design challenge is the "Claude's Discretion" areas: how to implement bundles, how to format the results dict key, and how to represent detection labels. These are SDK-internal choices that affect developer ergonomics but have no wrong answer from the server's perspective. Research below recommends specific choices for each.

**Primary recommendation:** Implement each scanner as a single Python file with a scoped label enum + `to_guardline_spec()`. Use the `_StrValueMixin` enum pattern already established in Phase 1. Keep scanner implementation thin — the server does all detection work.

---

## Standard Stack

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

### Core (Already Installed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| pydantic | 2.12.5 | Result type parsing (ScanResult already defined) | Phase 1 decision, locked |
| requests | 2.32.5 | HTTP client (Guard.scan_input already implemented) | Phase 1 decision, locked |
| Python stdlib `enum` | stdlib | Label enums per scanner | Established in Phase 1 via `_StrValueMixin` |
| Python stdlib `abc` | stdlib | BaseScanner contract | Already in `scanners/base.py` |

### No New Libraries Needed

Phase 2 is pure SDK translation logic. Each scanner converts developer-friendly label enums into a JSON-serializable dict. No NLP, no ML, no additional HTTP clients — the server handles everything.

### Installation

Nothing new to install. The package is already in editable mode:

```bash
# Already done in Phase 1 — nothing to run
.venv/bin/pip install -e .
```

---

## Architecture Patterns

### Recommended Project Structure

```
src/meshulash_guard/
├── scanners/
│   ├── __init__.py       # re-export all scanners and label enums
│   ├── base.py           # BaseScanner (already exists, Phase 1)
│   ├── pii.py            # PIIScanner + PIILabel
│   ├── topic.py          # TopicScanner + TopicLabel
│   ├── toxicity.py       # ToxicityScanner + ToxicityLabel
│   ├── cyber.py          # CyberScanner + CyberLabel
│   └── jailbreak.py      # JailbreakScanner + JailbreakLabel
```

Each scanner file is self-contained: imports only from `..enums` (Action, Condition) and the stdlib. No cross-scanner imports.

### Pattern 1: Scanner Class with Label Enum — Canonical Structure

**What:** Each scanner file defines (a) a scoped label enum using `_StrValueMixin`, and (b) a scanner class that subclasses `BaseScanner`. The scanner `__init__` validates parameters and stores them; `to_guardline_spec()` converts them to the server's guardline format.

**When to use:** For every scanner in Phase 2.

```python
# Source: derived from Phase 1 _StrValueMixin pattern (enums.py) + guardline spec format (main.py)
from __future__ import annotations
from enum import auto
from typing import Sequence
from ..enums import Action, Condition, _StrValueMixin
from .base import BaseScanner


class PIILabel(_StrValueMixin):
    """Labels for PII detection. Values are canonical server label registry strings."""
    EMAIL_ADDRESS = "EMAIL_ADDRESS"
    PHONE_NUMBER = "PHONE_NUMBER"
    # ... all 53 canonical labels ...

    # Bundle shortcuts (enum members whose values are bundle names)
    PII = "PII"
    PHI = "PHI"
    PCI = "PCI"
    SECRETS = "SECRETS"
    TECH = "TECH"

    # Wildcard shortcut
    ALL = "__ALL__"   # sentinel value, expanded in to_guardline_spec()


class PIIScanner(BaseScanner):
    def __init__(
        self,
        labels: Sequence[PIILabel],
        action: Action = Action.REPLACE,
        condition: Condition = Condition.ANY,
        overrides: dict[PIILabel, Action] | None = None,
        threshold: float | None = None,
        allowlist: list[str] | None = None,
    ) -> None:
        if not labels:
            raise ValueError("PIIScanner requires at least one label. Use PIILabel.ALL to scan everything.")
        self._labels = list(labels)
        self._action = action
        self._condition = condition
        self._overrides = overrides or {}
        self._threshold = threshold
        self._allowlist = allowlist or []

    def to_guardline_spec(self) -> dict:
        ...
```

### Pattern 2: Guardline Spec Format (Exact Server Contract)

**What:** The format expected by `/api/security/scan` as confirmed by reading `main.py`, `gating.py`, and `recognizers/regex.py` in the server codebase.

**When to use:** In every scanner's `to_guardline_spec()` implementation.

```python
# Source: /Users/shalev/Desktop/AIM/security-server/src/main.py (process_sdk, _build_tc_policies_from_specs)
#         /Users/shalev/Desktop/AIM/security-server/src/gating.py (_is_triggered, _evaluate_all_mode)
{
    "name": "PIIScanner",           # human-readable name for logs (str, spec.get("name", ""))
    "condition": "any",             # "any" | "all" | "k_of" | "contextual_analysis"
    "action": "replace",            # "replace" | "block" | "log"
    "level": 2,                     # int 0-5 (2 = standard PII severity)
    "types": {
        "regex": True,              # bool — enable regex recognizer for this guardline
        "ner": True,                # bool — enable NER recognizer for this guardline
        "tc": False,                # bool — enable TC head for this guardline
    },
    "required": {
        "regex": ["EMAIL_ADDRESS"],  # list[str] — canonical labels (server matches by label)
        "ner": ["EMAIL_ADDRESS"],    # list[str] — NER canonical labels
        "tc": [["topic", "PHISHING"]]  # list[[head, label]] — TC head+label pairs
    },
    "allowlist": [],                # list[str] — strings to exclude from detection
    "bundles": ["PII"],             # list[str] — bundle names ("PII", "PHI", "PCI", "SECRETS", "TECH")
    "k": 0,                         # int — k value for k_of condition (0 = default threshold of 2)
}
```

**Key facts from server source:**
- `spec.get("types", {}).get("tc")` is the boolean gate for TC processing (`_build_tc_policies_from_specs`)
- `spec.get("required", {}).get("tc")` must be a list of `[head, label]` pairs (2-element lists or tuples)
- `spec.get("condition")` defaults to `"any"` if absent or None
- `spec.get("k")` defaults to `0` if absent; gating uses `max(2, k)` for k_of
- `spec.get("bundles")` — the server's `CustomRegexRecognizer` does NOT read this field; bundles are handled by `build_policies_for_guardline()` in `risk_bundles.py`, which is called from the existing guardline DB path, NOT from `process_sdk()`. See Open Questions #1.

### Pattern 3: TC Head Mapping for Non-PII Scanners

**What:** `TopicScanner`, `ToxicityScanner`, `CyberScanner`, and `JailbreakScanner` map to TC heads. The SDK hides the head name from developers by mapping label enums to `[head, label]` pairs internally.

**When to use:** In `to_guardline_spec()` for any TC-based scanner.

```python
# Source: SDK_DESIGN.md + server _build_tc_policies_from_specs in main.py
# TC guardline spec — labels map to [head, label] pairs in required.tc

# TopicScanner maps to "topic" head
# ToxicityScanner maps to "hate_speech" head
# CyberScanner maps to "cysecbench" head
# JailbreakScanner maps to "jailbreak" head (server: "coming soon")

def to_guardline_spec(self) -> dict:
    tc_labels = [[self._TC_HEAD, label.value] for label in self._labels]
    return {
        "name": self.__class__.__name__,
        "condition": str(self._condition),
        "action": str(self._action),
        "level": 2,
        "types": {"regex": False, "ner": False, "tc": True},
        "required": {"regex": [], "ner": [], "tc": tc_labels},
        "allowlist": self._allowlist,
        "bundles": [],
        "k": 0,
    }
```

### Pattern 4: PIIScanner Label Resolution (PII-Only Complexity)

**What:** PIIScanner must handle (a) 53 canonical labels, (b) bundle shortcuts, (c) the `ALL` shortcut, and (d) per-label action overrides. The label resolution logic in `to_guardline_spec()` expands these before building the spec.

**When to use:** Only for PIIScanner.

```python
# Derived from Phase 1 SDK_DESIGN.md + server label registry understanding
_BUNDLE_NAMES = {"PII", "PHI", "PCI", "SECRETS", "TECH"}
_ALL_SENTINEL = "__ALL__"

def to_guardline_spec(self) -> dict:
    bundles = []
    ner_labels = []
    regex_labels = []

    if any(label.value == _ALL_SENTINEL for label in self._labels):
        # ALL sentinel: scan everything — use all 5 bundles
        bundles = list(_BUNDLE_NAMES)
    else:
        for label in self._labels:
            if label.value in _BUNDLE_NAMES:
                bundles.append(label.value)
            else:
                # Canonical label — goes into both ner and regex required lists
                # (server will only use it based on that label's detection sources)
                ner_labels.append(label.value)
                regex_labels.append(label.value)

    return {
        "name": "PIIScanner",
        "condition": str(self._condition),
        "action": str(self._action),
        "level": 2,
        "types": {
            "regex": bool(regex_labels or bundles),
            "ner": bool(ner_labels or bundles),
            "tc": False,
        },
        "required": {
            "regex": regex_labels,
            "ner": ner_labels,
            "tc": [],
        },
        "allowlist": self._allowlist,
        "bundles": bundles,
        "k": 0,
    }
```

### Pattern 5: Scanner Label Enum Values

**What:** The exact values each label enum member should carry, and how they map to server-side constructs.

**PII scanner labels:** Canonical label registry strings (uppercase with underscores), e.g., `"EMAIL_ADDRESS"`, `"PHONE_NUMBER"`, `"CREDIT_CARD"`. These 53 strings are the exact keys from the server's `LABEL_REGISTRY`. Bundle shortcuts use the bundle name string: `"PII"`, `"PHI"`, `"PCI"`, `"SECRETS"`, `"TECH"`.

**TC scanner labels:** Labels specific to each TC head. The server's TC classifier head names and their available labels need to be pinned. From `SDK_DESIGN.md`:
- `topic` head: `TopicLabel` (labels like `PHISHING`, `MALWARE` — exact label strings accepted by the TC head)
- `hate_speech` head: `ToxicityLabel` (labels like `HATE_SPEECH`, `TOXIC`)
- `cysecbench` head: `CyberLabel` (labels like `MALWARE`, `EXPLOIT`)
- `jailbreak` head: `JailbreakLabel` (labels like `JAILBREAK`, `PROMPT_INJECTION`)

**Important:** The exact TC label strings accepted by each head must be verified against the server model configuration. The SDK_DESIGN.md does not enumerate specific TC labels. This is flagged in Open Questions.

### Pattern 6: Scanner `__init__.py` Re-exports

**What:** After Phase 2, `scanners/__init__.py` should re-export all scanners and label enums for ergonomic imports.

**When to use:** At the end of Phase 2 (after all scanner files exist).

```python
# src/meshulash_guard/scanners/__init__.py
from .pii import PIIScanner, PIILabel
from .topic import TopicScanner, TopicLabel
from .toxicity import ToxicityScanner, ToxicityLabel
from .cyber import CyberScanner, CyberLabel
from .jailbreak import JailbreakScanner, JailbreakLabel

__all__ = [
    "PIIScanner", "PIILabel",
    "TopicScanner", "TopicLabel",
    "ToxicityScanner", "ToxicityLabel",
    "CyberScanner", "CyberLabel",
    "JailbreakScanner", "JailbreakLabel",
]
```

### Anti-Patterns to Avoid

- **Including per-label action overrides in the guardline spec:** The server's guardline spec has a single `action` field — it does not support per-label action overrides. Per-label `overrides` must be handled SDK-side (either pre-split into multiple guardline specs or the override logic is post-scan). This is a real mismatch between the CONTEXT.md design and the server's actual spec. See Open Questions #2.
- **Setting `types.regex=True` for TC-only scanners:** The gating logic uses `types` to control which recognizers participate. TC scanners must set `types.regex=False` and `types.ner=False`, otherwise the gating may count regex/NER hits toward the condition.
- **Omitting `name` from guardline spec:** The server uses `spec.get("name", "")` for logging. Always include it for debuggability.
- **Using `str(label)` on enum values without `_StrValueMixin`:** Python 3.12+ returns `'PIILabel.EMAIL_ADDRESS'` instead of `'EMAIL_ADDRESS'` without the mixin. All label enums must use `_StrValueMixin` from `enums.py`.
- **Exporting `_StrValueMixin` as part of public API:** It's an internal implementation detail. Export only `PIILabel`, `TopicLabel`, etc. The mixin stays private.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| String-to-enum mapping | Custom dict lookup | Enum with `_StrValueMixin` values | Enum membership gives isinstance checks, IDE completion, and string serialization for free |
| Label set expansion for `ALL` | Custom flag class | Sentinel enum value + expansion in `to_guardline_spec()` | Keeps the public API as "just an enum" without introducing a second concept |
| Multiple-action-per-label logic | New guardline spec field | Multiple scanner instances | The server already supports multiple guardline IDs; `guard.scan_input(scanners=[pii_strict, pii_lenient])` produces two specs; per-label overrides become two PIIScanners with different label sets |
| TC head lookup table | External config file | Class-level constant `_TC_HEAD = "topic"` | The mapping is static and per-scanner-class; a class constant is the right place |
| Label validation at construction | Runtime string checks | Enum type annotation | If the caller passes a `PIILabel` member, it's valid by construction. The enum does the validation. |

**Key insight:** Phase 2 scanners are translation objects, not detection objects. Keep `to_guardline_spec()` as a pure dict-building function — no HTTP, no validation beyond "did you pass any labels."

---

## Common Pitfalls

### Pitfall 1: `_StrValueMixin` Not Applied to Label Enums

**What goes wrong:** `f"required labels: {PIILabel.EMAIL_ADDRESS}"` produces `"required labels: PIILabel.EMAIL_ADDRESS"` instead of `"required labels: EMAIL_ADDRESS"` on Python 3.12+. JSON serialization via Pydantic also produces the wrong string.

**Why it happens:** Python 3.12 changed `(str, Enum).__str__` to return the enum name, not the value. This is a known regression (from SDK perspective) documented in Phase 1 and fixed via `_StrValueMixin`.

**How to avoid:** All label enums (`PIILabel`, `TopicLabel`, etc.) must inherit from `_StrValueMixin`, not from plain `(str, Enum)`.

**Warning signs:** `str(PIILabel.EMAIL_ADDRESS) != "EMAIL_ADDRESS"` in Python 3.12+.

### Pitfall 2: `ALL` Label Sentinel Leaking into Spec

**What goes wrong:** `to_guardline_spec()` for PIIScanner doesn't detect the `ALL` sentinel and includes `"__ALL__"` as a label in the `required.ner` or `required.regex` list. The server does not know `__ALL__` — it would either error or produce no matches.

**Why it happens:** The `ALL` sentinel is a SDK concept, not a server concept. It must be expanded to bundles BEFORE building the spec.

**How to avoid:** In `to_guardline_spec()`, check `any(label.value == "__ALL__" for label in self._labels)` before iterating labels. If true, set all bundles and skip individual labels.

**Warning signs:** Server returns 422 or unexpected results when `PIILabel.ALL` is used.

### Pitfall 3: Per-Label Overrides Not Supported by Single Guardline Spec

**What goes wrong:** Developer configures `PIIScanner(labels=[PIILabel.EMAIL, PIILabel.SSN], action=Action.REPLACE, overrides={PIILabel.SSN: Action.BLOCK})`. The implementation tries to put both `action="replace"` and per-label `action="block"` into a single guardline spec — but the server spec only has one `action` field.

**Why it happens:** The server's guardline spec is flat: one action per guardline. Per-label action overrides require multiple guardline specs (one per distinct action + label combination).

**How to avoid:** In `to_guardline_spec()`, if `overrides` is non-empty, the PIIScanner must split into multiple specs. BUT — `to_guardline_spec()` returns a single dict per the `BaseScanner` contract. This is a design mismatch. Resolution options:
1. Change `BaseScanner.to_guardline_spec()` to return `dict` but interpret overrides in `Guard.scan_input()` by calling a new method `to_guardline_specs()` (plural) on scanners that have overrides.
2. Make `to_guardline_spec()` raise `NotImplementedError` and add `to_guardline_specs() -> dict[str, dict]` as the new contract.
3. Expand overrides by having PIIScanner produce multiple guardlines under a single `to_guardline_spec()` call by returning a combined spec that uses the base action, and register override labels as separate guardlines in `Guard.scan_input()`.

See Open Questions #2 for the recommended resolution.

**Warning signs:** Per-label overrides silently do nothing (all labels use the default action).

### Pitfall 4: Bundles in Spec May Not Be Consumed by `process_sdk()`

**What goes wrong:** PIIScanner puts `"bundles": ["PII"]` in the guardline spec, expecting the server to expand the bundle into its constituent patterns and NER labels. But `process_sdk()` does NOT call `build_policies_for_guardline()` from `risk_bundles.py` — that function is only called in the existing DB-based guardline flow.

**Why it happens:** `process_sdk()` was built to handle the 5-stage pipeline with inline specs, but bundle expansion is a separate concept that was part of the original DB-fetched guardline flow. The `bundles` field in the spec is not read anywhere inside `process_sdk()`.

**How to avoid:** When implementing `to_guardline_spec()` for PIIScanner with bundles, either:
1. Expand bundles SDK-side into explicit labels, or
2. Confirm with the developer that the server's `process_sdk()` was updated to handle the `bundles` field.

This is the most important open question for Phase 2. See Open Questions #1.

**Warning signs:** `PIIScanner(labels=[PIILabel.PII])` sends correctly, but no PII is detected — because the server never expanded the bundle.

### Pitfall 5: JailbreakScanner TC Head Not Ready

**What goes wrong:** `JailbreakScanner.to_guardline_spec()` sends `{"tc": [["jailbreak", "JAILBREAK"]]}` — the server tries to call the `jailbreak` TC head, but the head is "coming soon" (SDK_DESIGN.md). The server may raise an error or return empty results silently.

**Why it happens:** The jailbreak TC head exists on the roadmap but is not yet deployed. The SDK should not crash if the head is absent.

**How to avoid:** Implement `JailbreakScanner` with a clear `# NOTE: jailbreak TC head is coming soon` comment. Do not raise an error in the SDK if the head is missing — the server should handle this gracefully. Add a docstring note to `JailbreakScanner` warning that results may be empty until the server deploys the head.

**Warning signs:** Server returns 500 or empty TC results for JailbreakScanner calls.

### Pitfall 6: Missing `labels` Argument Not Validated

**What goes wrong:** Developer calls `PIIScanner(action=Action.REPLACE)` (omitting `labels=`), gets a confusing `TypeError: __init__() missing 1 required positional argument: 'labels'` from Python instead of a clear SDK error.

**Why it happens:** `labels` is positional-required in the constructor, but the CONTEXT.md decision says "no labels raises an error" — implying it should be explicit.

**How to avoid:** Make `labels` a required positional argument and add an explicit check:

```python
if not labels:
    raise ValueError(
        "PIIScanner requires at least one label. "
        "Pass labels=[PIILabel.EMAIL_ADDRESS, ...] or labels=[PIILabel.ALL]."
    )
```

**Warning signs:** Empty list `labels=[]` passes the type check but produces a spec with no detection types enabled.

---

## Code Examples

### Complete PIIScanner `to_guardline_spec()` Implementation

```python
# Source: guardline spec format from main.py + gating.py in server codebase
# Derived from SDK_DESIGN.md architecture patterns

_BUNDLE_NAMES = frozenset({"PII", "PHI", "PCI", "SECRETS", "TECH"})
_ALL_SENTINEL = "__ALL__"

def to_guardline_spec(self) -> dict:
    """Translate PIIScanner config into a single guardline spec for the server."""
    bundles: list[str] = []
    ner_labels: list[str] = []
    regex_labels: list[str] = []

    # Check for ALL sentinel first
    if any(lbl.value == _ALL_SENTINEL for lbl in self._labels):
        bundles = sorted(_BUNDLE_NAMES)
    else:
        for lbl in self._labels:
            if lbl.value in _BUNDLE_NAMES:
                if lbl.value not in bundles:
                    bundles.append(lbl.value)
            else:
                # Canonical label — list in both; server decides which recognizers fire
                if lbl.value not in ner_labels:
                    ner_labels.append(lbl.value)
                if lbl.value not in regex_labels:
                    regex_labels.append(lbl.value)

    return {
        "name": "PIIScanner",
        "condition": str(self._condition),
        "action": str(self._action),
        "level": 2,
        "types": {
            "regex": bool(regex_labels or bundles),
            "ner": bool(ner_labels or bundles),
            "tc": False,
        },
        "required": {
            "regex": regex_labels,
            "ner": ner_labels,
            "tc": [],
        },
        "allowlist": list(self._allowlist),
        "bundles": bundles,
        "k": 0,
    }
```

### Complete TopicScanner `to_guardline_spec()` Implementation

```python
# Source: guardline spec TC format from _build_tc_policies_from_specs in main.py

class TopicScanner(BaseScanner):
    _TC_HEAD = "topic"  # server TC head name — hidden from developer

    def to_guardline_spec(self) -> dict:
        tc_pairs = [[self._TC_HEAD, lbl.value] for lbl in self._labels]
        return {
            "name": "TopicScanner",
            "condition": str(self._condition),
            "action": str(self._action),
            "level": 2,
            "types": {"regex": False, "ner": False, "tc": True},
            "required": {"regex": [], "ner": [], "tc": tc_pairs},
            "allowlist": list(self._allowlist),
            "bundles": [],
            "k": 0,
        }
```

### Label Enum with `_StrValueMixin` (Canonical Pattern)

```python
# Source: Phase 1 enums.py pattern (_StrValueMixin) — verified working Python 3.9-3.13
from ..enums import _StrValueMixin

class PIILabel(_StrValueMixin):
    """Labels for PII detection. Values are canonical server label registry strings.

    Bundle shortcuts (PII, PHI, PCI, SECRETS, TECH) are meta-labels that expand to
    predefined groups of canonical labels. See docs for bundle contents.
    Use PIILabel.ALL to scan for all PII using all bundles.
    """

    # Identity (7)
    PERSON_NAME = "PERSON_NAME"
    NATIONAL_ID = "NATIONAL_ID"
    DRIVER_LICENSE = "DRIVER_LICENSE"
    USERNAME = "USERNAME"
    TAX_NUMBER = "TAX_NUMBER"
    ACCOUNT_ID = "ACCOUNT_ID"
    BUSINESS_ID = "BUSINESS_ID"

    # Contact (11)
    EMAIL_ADDRESS = "EMAIL_ADDRESS"
    PHONE_NUMBER = "PHONE_NUMBER"
    STREET_ADDRESS = "STREET_ADDRESS"
    CITY = "CITY"
    COUNTY = "COUNTY"
    STATE = "STATE"
    COUNTRY = "COUNTRY"
    POSTAL_CODE = "POSTAL_CODE"
    IP_ADDRESS = "IP_ADDRESS"
    MAC_ADDRESS = "MAC_ADDRESS"
    URL = "URL"

    # Financial (5)
    CREDIT_CARD = "CREDIT_CARD"
    IBAN = "IBAN"
    BANK_ACCOUNT = "BANK_ACCOUNT"
    ROUTING_NUMBER = "ROUTING_NUMBER"
    CRYPTO_ADDRESS = "CRYPTO_ADDRESS"

    # Credentials (20)
    PASSWORD = "PASSWORD"
    SECRET_KEY = "SECRET_KEY"
    ACCESS_TOKEN = "ACCESS_TOKEN"
    PRIVATE_KEY = "PRIVATE_KEY"
    CONNECTION_STRING = "CONNECTION_STRING"
    AWS_ACCESS_KEY = "AWS_ACCESS_KEY"
    AWS_SECRET_KEY = "AWS_SECRET_KEY"
    GCP_API_KEY = "GCP_API_KEY"
    GCP_SERVICE_ACCOUNT = "GCP_SERVICE_ACCOUNT"
    OPENAI_API_KEY = "OPENAI_API_KEY"
    AZURE_API_KEY = "AZURE_API_KEY"
    STRIPE_SECRET_KEY = "STRIPE_SECRET_KEY"
    STRIPE_PUBLISHABLE_KEY = "STRIPE_PUBLISHABLE_KEY"
    SLACK_TOKEN = "SLACK_TOKEN"
    GITHUB_TOKEN = "GITHUB_TOKEN"
    TWILIO_API_KEY = "TWILIO_API_KEY"
    SENDGRID_API_KEY = "SENDGRID_API_KEY"
    HEROKU_API_KEY = "HEROKU_API_KEY"
    API_KEY = "API_KEY"
    TOKEN = "TOKEN"

    # Technical (2)
    USER_AGENT = "USER_AGENT"
    AWS_ARN = "AWS_ARN"

    # Temporal (1)
    DATE_TIME = "DATE_TIME"

    # Organizational (1)
    ORGANIZATION = "ORGANIZATION"

    # Bundle shortcuts (PIIScanner-only feature)
    PII = "PII"
    PHI = "PHI"
    PCI = "PCI"
    SECRETS = "SECRETS"
    TECH = "TECH"

    # Wildcard
    ALL = "__ALL__"
```

### Guard.scan_input Key Format Reference

```python
# Source: Phase 1 guard.py — confirmed working pattern
# Scanner key in results dict: "sdk_{classname_lower}_{index}"
# Example: PIIScanner at index 0 → "sdk_piiscanner_0"
# Example: TopicScanner at index 1 → "sdk_topicscanner_1"
# Example: Two PIIScanners → "sdk_piiscanner_0", "sdk_piiscanner_1"

guardline_specs = {
    f"sdk_{scanner.__class__.__name__.lower()}_{i}": scanner.to_guardline_spec()
    for i, scanner in enumerate(scanners)
}
```

The `scanners` dict in `ScanResult` is keyed by these same strings. Developers access per-scanner results as `result.scanners["sdk_piiscanner_0"]`.

### Per-Label Override: Split Into Multiple Guardlines

```python
# Recommended resolution for per-label action overrides (Open Question #2)
# PIIScanner with overrides should produce multiple specs, one per distinct action group.
# This is how Guard.scan_input() should call PIIScanner.to_guardline_spec() variants:

# Developer writes:
pii = PIIScanner(
    labels=[PIILabel.EMAIL_ADDRESS, PIILabel.NATIONAL_ID],
    action=Action.REPLACE,
    overrides={PIILabel.NATIONAL_ID: Action.BLOCK},
)

# to_guardline_spec() for this case should return a single spec for the default labels,
# AND the scanner should expose to_override_specs() for override handling.
# OR: Guard.scan_input() checks for overrides and calls a helper on PIIScanner.
# Recommended: add `to_guardline_specs() -> dict[str, dict]` to PIIScanner.
# BaseScanner.to_guardline_spec() stays as-is for simple scanners.
# Guard.scan_input() checks if scanner has to_guardline_specs(); uses it if present.
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Single `PIIScanner` with all labels global | Scoped label enums per scanner | Phase 2 design | `PIILabel.EMAIL_ADDRESS` vs ambiguous `"EMAIL_ADDRESS"` string — scoping prevents cross-scanner label confusion |
| Per-request DB guardline fetch | Inline guardline_specs in request body | Phase 1 server work | SDK controls detection config without server-side storage |
| `(str, Enum)` without `__str__` override | `_StrValueMixin` with explicit `__str__`/`__format__` | Python 3.12 (Phase 1 fix) | Enum values serialize correctly in f-strings and JSON on all Python 3.9-3.13 |

**Deprecated/outdated in this phase:**
- Using bare string labels instead of enum members in scanner constructors: The enum is the public API; strings are an implementation detail.

---

## Decisions for Claude's Discretion

The CONTEXT.md marks these as Claude's responsibility to decide:

### Decision 1: Bundle Implementation (Special Enum Values vs Separate Parameter)

**Recommendation: Special enum values in PIILabel.**

`PIILabel.PII`, `PIILabel.PHI`, `PIILabel.PCI`, `PIILabel.SECRETS`, `PIILabel.TECH` as enum members in the same enum. This keeps the scanner constructor uniform: `labels=[PIILabel.PII, PIILabel.EMAIL_ADDRESS]` mixes bundles and individual labels without needing a separate `bundles=` parameter.

`PIILabel.ALL` as an additional sentinel that expands to all five bundles.

**Why not a separate `bundles=` parameter:** Mixing `labels=[PIILabel.EMAIL]` with `bundles=[PIIBundle.PII]` requires two parameters where one suffices. The enum approach is more consistent with how developers think about "what to scan."

**Tradeoff:** Bundle members in `PIILabel` have values like `"PII"` which look different from canonical labels like `"EMAIL_ADDRESS"`. This is intentional — `to_guardline_spec()` distinguishes them by checking against `_BUNDLE_NAMES`.

### Decision 2: Scanner Key Format in Results Dict

**Recommendation: Keep `sdk_{classname_lower}_{index}` format (Phase 1 choice).**

The key format `sdk_piiscanner_0` is already established by `Guard.scan_input()` in Phase 1. The result `ScanResult.scanners` dict already uses these keys. No remapping is needed. Document the key format in the scanner's docstring so developers know how to look up their scanner's result.

**Why not remap:** The guardline ID is the server's key for the response. Remapping would require maintaining a reverse-lookup table in `Guard.scan_input()`. The current approach is simpler and already working.

### Decision 3: Detection Label Format in Results

**Recommendation: Keep as raw string (as returned by server).**

`ScannerResult.detections[i].label` is already typed as `str` (Phase 1 `models.py`). The server returns the canonical label string (e.g., `"EMAIL_ADDRESS"`). Do not map to enum values — that would require importing `PIILabel` from `models.py`, creating a circular dependency, and making `models.py` scanner-aware.

Developers can compare against enum values: `detection.label == PIILabel.EMAIL_ADDRESS.value` or `detection.label == "EMAIL_ADDRESS"`.

### Decision 4: Risk Categories Structure

**Recommendation: Pass-through list of strings (already implemented).**

`ScanResult.risk_categories: list[str]` is already in Phase 1's `models.py`. The server returns `["PII", "SECRETS"]` etc. No wrapping needed. The Pydantic model already handles it.

### Decision 5: Scanner Ordering Semantics

**Recommendation: No ordering guarantee; document this explicitly.**

The guardline_specs dict in `Guard.scan_input()` is built by dict comprehension. Python 3.7+ dicts are insertion-ordered, so scanners appear in the order passed to `scan_input()`. However, the SERVER processes all guardlines independently (not sequentially), so ordering does not affect detection results. Document: "Scanner order in the response dict matches the order passed to `scan_input()`, but scanners do not chain — each sees the original text."

---

## Open Questions

### Open Question 1: Bundle Expansion in `process_sdk()`

**Critical — blocks PIIScanner bundle implementation.**

- **What we know:** The guardline spec has a `bundles` field. The server's `build_policies_for_guardline()` in `risk_bundles.py` expands bundles into Policy objects. BUT `process_sdk()` does NOT call `build_policies_for_guardline()` — it only calls `CustomRegexRecognizer(guardline_specs).detect(text_normalized)`, which reads `required.regex`, not `bundles`.
- **What's unclear:** Does the server's `process_sdk()` handle the `bundles` field? Looking at the server code, `CustomRegexRecognizer` reads `spec["required"]["regex"]` for custom patterns, and `RegexRecognizer` (the static one seeded from `LABEL_REGISTRY`) fires on all labels it knows about regardless of the spec. The gating stage then checks if triggered detections match the guardline's required labels.
- **Consequence if ignored:** `PIIScanner(labels=[PIILabel.PII])` would put `"bundles": ["PII"]` in the spec, but `process_sdk()` may not expand it — the gating would find no required labels and may never trigger, OR the static `RegexRecognizer` fires on everything and gating passes it through because there are no required items specified.
- **Recommendation:** Before implementing PIIScanner bundles, inspect `process_sdk()` at runtime OR check the gating logic for how it handles a spec with `bundles=["PII"]` but empty `required.regex` and `required.ner`. If the server does not expand bundles, the SDK must expand them itself by including the canonical label strings in `required.regex` and `required.ner` instead of using the `bundles` field. This may mean the `bundles` field in the spec is vestigial for the SDK use case.

### Open Question 2: Per-Label Action Overrides vs Single `to_guardline_spec()`

**Design ambiguity — must be resolved before implementing PIIScanner.**

- **What we know:** CONTEXT.md decision: `PIIScanner(labels=[...], action=Action.REPLACE, overrides={PIILabel.SSN: Action.BLOCK})`. The server spec supports only one `action` per guardline ID.
- **What's unclear:** How should `BaseScanner.to_guardline_spec()` (returns a single dict) be reconciled with PIIScanner needing to produce multiple guardlines for overrides?
- **Recommendation:** Add a new method `to_guardline_specs() -> dict[str, dict]` to PIIScanner that returns one spec per distinct action group. `Guard.scan_input()` checks `hasattr(scanner, 'to_guardline_specs')` and calls it if present; falls back to `to_guardline_spec()` otherwise. This avoids changing the `BaseScanner` contract for simple scanners while supporting PIIScanner's full feature set. Keys for override specs: `sdk_piiscanner_{i}_override_{j}`.

### Open Question 3: Exact TC Label Strings per Head

**LOW priority for Phase 2 planning — can be discovered at test time.**

- **What we know:** The TC heads are `topic`, `hate_speech`, `cysecbench`, `jailbreak`. The SDK hides head names; developers use `TopicLabel.PHISHING` etc.
- **What's unclear:** The exact label strings accepted by each TC head (e.g., does the `topic` head accept `"PHISHING"` or `"phishing"`? Case-sensitive?). SDK_DESIGN.md does not enumerate TC labels.
- **Recommendation:** Read the server's TC model configuration to get the exact label strings, then use them as enum values. If they are lowercase, the label enums use lowercase values. Alternatively, inspect the `_build_tc_policies_from_specs()` path — the `labels` list is passed directly to the TC recognizer, which is responsible for matching them to the model's output.

---

## Sources

### Primary (HIGH confidence)

- `/Users/shalev/Desktop/AIM/security-server/src/main.py` — `process_sdk()`, `_build_tc_policies_from_specs()`, `/api/security/scan` route — direct server code inspection
- `/Users/shalev/Desktop/AIM/security-server/src/gating.py` — `_is_triggered()`, `_evaluate_all_mode()`, `_build_label_guardline_index()` — confirms spec field names: `condition`, `types`, `required`, `k`, `action`, `level`, `allowlist`
- `/Users/shalev/Desktop/AIM/security-server/src/recognizers/regex.py` — `CustomRegexRecognizer` confirms `required.regex` field reading pattern
- `/Users/shalev/Desktop/meshulash_guard/src/meshulash_guard/enums.py` — `_StrValueMixin` pattern (Phase 1 output, verified working Python 3.13)
- `/Users/shalev/Desktop/meshulash_guard/src/meshulash_guard/guard.py` — `scan_input()` guardline_specs key format: `sdk_{classname_lower}_{i}`
- `/Users/shalev/Desktop/meshulash_guard/src/meshulash_guard/models.py` — `ScanResult`, `ScannerResult`, `Detection` field types (Phase 1 output)
- `/Users/shalev/Desktop/meshulash_guard/src/meshulash_guard/scanners/base.py` — `BaseScanner.to_guardline_spec()` contract (Phase 1 output)
- `/Users/shalev/Desktop/meshulash_guard/SDK_DESIGN.md` — all 53 canonical label names, 5 TC heads, 5 bundle names, scanner API design
- `.planning/phases/01-foundation/01-01-SUMMARY.md` — Phase 1 server work confirmed complete
- `.planning/phases/01-foundation/01-02-SUMMARY.md` — Phase 1 SDK scaffold confirmed complete
- `.planning/phases/01-foundation/01-VERIFICATION.md` — Phase 1 4/4 verified, all wiring confirmed

### Secondary (MEDIUM confidence)

- `/Users/shalev/Desktop/AIM/security-server/src/risk_bundles.py` — `build_policies_for_guardline()` confirms bundle expansion happens server-side, but only in DB path (raises Open Question #1 about `process_sdk()` bundle handling)

### Tertiary (LOW confidence)

- SDK_DESIGN.md TC head label names (e.g., `PHISHING`, `HATE_SPEECH`) — the document lists heads but does not enumerate every TC label string; exact strings need server-side verification

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies; Phase 1 stack confirmed working
- Architecture: HIGH — guardline spec format verified from server source; scanner pattern derived from Phase 1 conventions
- Pitfall #4 (bundle expansion): MEDIUM — identified from server code inspection but exact behavior of `process_sdk()` with bundles field not runtime-verified
- TC label strings: LOW — exact label strings per TC head not verified from server model config
- Per-label overrides design: HIGH (identified problem) / MEDIUM (proposed solution) — the mismatch with `to_guardline_spec()` single-dict contract is confirmed; the `to_guardline_specs()` extension approach is untested but follows established SDK patterns

**Research date:** 2026-02-26
**Valid until:** 2026-03-28 (30 days — stable domain; key open questions should be resolved before or during Phase 2 planning)
