Detection
Quayside’s detection model is thin-shim. The core ships a small adapter interface; the actual detection logic lives in adapters — some bundled, most provided by you or by external services your adapter calls.
Adapter Protocol
Every detector implements this Protocol:
class DetectorAdapter(Protocol): name: str
async def inspect( self, content: str, *, direction: Direction, context: RequestContext, ) -> Verdict: ...
def inspect_stream( self, chunks: AsyncIterator[str], *, context: RequestContext, ) -> AsyncIterator[Verdict]: ...name— what a policy refers to instages[].detectors[]anddetectors[]inspect()— single-shot. Used for request-side full-prompt and response-side final-pass.inspect_stream()— per-chunk. InheritWindowedStreamingDetectorto get a default that accumulates and re-inspects every N characters.
A Verdict carries (detector, effect, score?, reason?, matched?).
Effect lattice
Verdicts combine via most-restrictive-wins:
Allow < Flag < Modify < Approve < Blockcombine([verdicts]) returns the max. An empty list is Allow.
| Effect | Meaning |
|---|---|
Allow | Pass through |
Flag | Pass through, recorded as a Flag step |
Modify | (v1: log only; substitution is later) |
Approve | (v1: not yet routed; HITL surface lands later) |
Block | Refuse — 403 pre-flight, SSE event: error mid-stream |
Three classes of detector
| Class | Lives where | Examples | Round-trip cost |
|---|---|---|---|
| In-process | Inline in the quayside service | RegexDetector, NullDetector, custom LLMJudgeDetector | <1 ms |
| Self-hosted service | Inside your VPC | PresidioDetector against a Presidio Analyzer sidecar | 5–30 ms |
| Hosted SaaS | Vendor’s infrastructure | (stub) LakeraDetector, AnthropicModerationDetector | 50–200 ms |
The Protocol is the same. The trade-offs differ — latency, cost-per-call, data residency.
Bundled adapters
NullDetector
Always returns Allow. Useful in tests and as a baseline.
RegexDetector
In-process pattern matcher.
RegexDetector( patterns=[ {"name": "ssn", "pattern": r"\d{3}-\d{2}-\d{4}", "score": 0.95}, {"name": "email", "pattern": r"[\w.+-]+@[\w.-]+", "score": 0.5, "category": "EMAIL"}, ], flag_threshold=0.5, block_threshold=0.85, case_insensitive=True,)- Finds all matching patterns; takes
max(score) - Maps the max to
Effectvia the thresholds reasonlists matched pattern names;matchedcarries categories
PresidioDetector
HTTP call to a Microsoft Presidio Analyzer.
PresidioDetector( endpoint="http://presidio.internal:5002", entities=["EMAIL_ADDRESS", "US_SSN", "PHONE_NUMBER"], language="en", score_threshold=0.4, # forwarded to Presidio for server-side filtering flag_threshold=0.5, block_threshold=0.85,)- POSTs to
<endpoint>/analyze - Takes the max score across findings
matchedcarries the sorted list of entity types found
HTTPStatusError propagates so the proxy’s fail_mode / on_failure machinery does the translation — don’t translate failures inside the adapter.
How they fit together
In one policy you can mix all three:
stages: - name: cheap-inline # in-process — fast direction: both detectors: [regex_pii] timeout_ms: 100
- name: full-pii # self-hosted — slower, more accurate direction: both detectors: [presidio] timeout_ms: 2000
- name: prompt-injection # hosted SaaS direction: request detectors: [lakera_guard] timeout_ms: 500The cascade runs them in order. A Block at any stage halts.