Architecture¶
This page explains how Patronus Ark is put together — the major components, how a scan flows through them, and why the design looks the way it does. For the moment-to-moment escalation rules, see Layered scanning.
The big picture¶
Patronus Ark is a Rust core with a thin Python binding. The core owns all scanning logic, model execution, asset management, and the async worker; the Python layer is a typed convenience wrapper over the same gateway object.
flowchart TB
subgraph client["Your application"]
Py["Python<br/>patronus_ark.SecurityGateway"]
Rs["Rust<br/>patronus_ark::SecurityGateway"]
end
Py -->|PyO3| GW
Rs --> GW
subgraph core["Rust core (patronus-ark)"]
GW["SecurityGateway<br/>(orchestrator + request registry)"]
GW --> PIPE["Per-category Pipelines"]
PIPE --> L1["L1 · native detectors<br/>(injection / dlp / pii / mcp)"]
PIPE --> L2["L2 · NTDB executor<br/>(shared static encoder + ONNX heads)"]
PIPE -.promote.-> WORKER["L3 background worker<br/>(cost-scheduled queue)"]
WORKER --> L3["L3 · full ONNX transformers<br/>(RAM-resident, TTL-evicted)"]
WORKER -.check.-> CACHE["Model-output cache<br/>(logits · similarity · PII)"]
GW --> ASSETS["Asset manager<br/>(download · verify · cache)"]
L2 --> ASSETS
L3 --> ASSETS
end
ASSETS -->|first use only| HF["Hugging Face<br/>Patronus model repos"]
WORKER -->|results| QUEUE[["Shared result queue"]]
L1 --> QUEUE
L2 --> QUEUE
QUEUE --> Py
QUEUE --> Rs
Components¶
SecurityGateway¶
The orchestrator and the only public entry point. It:
- holds the configuration (categories,
max_level, L3 strategy, execution gates, backend); - builds one pipeline per category;
- owns the request registry that tracks which scanners and promoted L3 jobs may still publish an event for a given request ID;
- exposes the primary asynchronous API (
enqueue+consume_next_event) and, as a convenience for one-off scripts, blocking helpers (scan_all,scan_category).
Construction is layered so you can separate a network-capable asset-sync phase from a strictly-local runtime-start phase — see Asset & runtime lifecycle.
Pipelines¶
Each category owns a pipeline that knows which of L1/L2/L3 it supports and how to combine
their outputs into a single result. Some categories are
L1-only (pii), some are L3-only (dynamic-pii), most run L1→L2 with optional L3 promotion.
See Categories for the per-category layer map.
The three layers¶
| Layer | Implementation | Loaded | Latency class |
|---|---|---|---|
| L1 | Native Rust detectors in detectors/ and threat/ |
always | microseconds |
| L2 | NTDB model packages executed by a shared static-embedding encoder | on warmup (if cached) | milliseconds |
| L3 | Full ONNX transformer sessions | RAM-resident per config, idle-TTL evicted | tens of milliseconds |
L1 and L2 run on a pool of gateway workers (up to 4 threads, sized to available cores). L3 runs in a single separate background worker so a heavy transformer never blocks a fast L1/L2 answer — and because that worker processes one L3 job at a time, its fair scheduler (below) decides ordering across categories. Detailed escalation logic lives in Layered scanning; the model formats live in Models & the NTDB format.
The L3 background worker¶
L3 is expensive, so it is decoupled from the request path:
- When L2 promotes a scan, the pipeline first publishes the L2 fallback result to the shared queue, then enqueues an L3 job.
- The worker schedules by estimated and observed compute cost (an exponentially weighted moving average of real execution time) and applies a max-wait guard against starvation.
- Long texts split into tokenizer-bounded windows. With clustering enabled the worker groups near-duplicate windows by similarity, infers only cluster representatives, and propagates their verdict to the rest — so most windows never reach the model. Early exit stops a head once its aggregate can no longer change. See the L3 worker policy (clustering is off by default).
- With progress reporting enabled the worker streams non-terminal
progress/provisionalevents as chunks resolve (dedicated strategy only). - L3 errors and timeouts degrade back to the L2 result where a fallback exists.
- Sessions are evicted only after a long idle TTL (
PATRONUS_L3_TTL_SECS, default 300 s); the worker never hot-swaps models per request.
The worker can run one dedicated model per category or one coalesced multi-head model
for all categories — see the l3_strategy knob
and Performance.
The shared result queue¶
All results — L1/L2 or L3 — are published to one shared queue keyed by request_id. This is
what lets a ready L2 result for request B overtake request A that is still waiting for L3. You
drain this queue with consume_next_event(), which you should run on its own thread so it
never blocks your producer from calling enqueue. (The blocking scan_* helpers drain the
queue for you internally, which is why they only suit single-shot scripts.)
The model-output cache¶
Separate from the asset download cache, an optional model-output cache sits in front of L3
and the dynamic-pii GLiNER pipeline. It stores raw per-head logits — not final decisions,
so policy and thresholds are re-applied on every hit — keyed by the immutable model SHA plus the
exact chunk bytes, so an identical chunk skips inference entirely. A historical similarity
tier can propagate a non-safe verdict to a close L2-embedding match without running L3, and a
dynamic-PII tier remembers normalized entity spans across texts. A memory tier is always on;
an optional redb-backed file adds persistence across restarts.
Because the PII tier stores cleartext spans, protect that file like other sensitive local data.
See Configure caching.
The asset manager¶
Native L1 needs no assets. L2/L3 scanners download Patronus-owned model bundles from the
Hugging Face repositories listed in rust/src/assets/specs.rs.
The manager downloads on first use, verifies integrity by content/source hash, caches under
the platform cache directory (or a custom model_dir), and converts tokenizers into a compact
on-disk form once. See Manage model assets and
Assets.
How a scan flows¶
An enqueue(text) for a category that supports all three layers. The app enqueues on one
thread and consumes on another:
sequenceDiagram
participant App
participant GW as SecurityGateway
participant L1
participant L2
participant W as L3 worker
participant Q as Result queue
App->>GW: enqueue(text) → request_id
GW->>L1: native detectors
L1-->>GW: L1 verdict
GW->>L2: NTDB classifiers (if assets ready)
L2-->>GW: L2 verdict (+ promote?)
alt L2 promotes to L3
GW->>Q: publish L2 fallback (result event)
GW->>W: enqueue L3 job
W->>W: schedule by cost, window long text
W-->>Q: publish final L3 result event
else no promotion
GW->>Q: publish L2 result event
end
Q-->>App: consume_next_event() → result / finished
Asset and runtime lifecycle¶
For deployments that must not download during a delivery window, the lifecycle splits into two phases:
- Asset-sync (network allowed).
prepare_assets()downloads and verifies everything the configured categories need. - Runtime-start (strictly local).
warmup_from_local_assets()initializes pipelines from the local cache only, never touching the network.
warmup() remains a combined convenience call that does both. asset_readiness() /
runtime_readiness() inspect the local cache and initialized state without downloading or
loading models into memory. This is the recommended pattern for air-gapped and installer-based
deployments — see Offline & air-gapped scanning.
Design principles¶
- On-device first. No scan content leaves the machine. The only network activity is the optional, one-time asset download from Hugging Face.
- Pay for detection only when needed. L1 resolves most traffic in microseconds; the transformer runs only for the uncertain minority that L2 promotes.
- Rust core, thin bindings. All logic lives once in Rust; Python (and any future binding) is a wrapper, so behavior cannot drift between languages.
- Graceful degradation. A missing asset, an L3 timeout, or an inference error degrades to the best available lower-layer result instead of failing the scan.
- Reproducible assets. Content/source hashes and converter versions invalidate stale generated files; local model overrides are never rewritten.