Architecture
Components and ports, the check pipeline, the policy model, the detector registry, the evidence ledger, identity and licence, the gateway and the non-functional requirements.
Updated: October 2026Components and ports
A Thulpar installation has six components: the api (control plane and check API), the gateway (data plane), the detectors (CPU and GPU), the console, Postgres and Valkey. All run in one Compose project or one Helm release; only the api and the gateway sit on a network that routes out, the rest on internal networks.
| Component | Role | Port |
|---|---|---|
| thulpar-web | Console: findings, ledger, detectors, policies, approvals, gateway models, MCP servers, API keys, evidence packs, health, installation settings; TR default, EN | 8050 |
| thulpar-api | The check API, policy resolution, the rail engine, Cedar, labels, approvals, the ledger, exports, the licence, the console’s control plane | 8051 |
| thulpar-gateway | The OpenAI-compatible proxy and the MCP proxy; the engine in-process, no HTTP hop to the api | 8053 |
| thulpar-detectors | Detector API sidecars: the CPU tier (ONNX, llama.cpp, Presidio, the file analyzer) and the GPU adapter (vLLM endpoints) | not published |
| Postgres | Policies, ledger, approvals, detectors, API keys, installation settings | not published |
| Valkey | Sessions, caches, rate limits, hold signals, stream tickets (the Redis protocol) | not published |
| Prometheus /metrics | The api’s and the gateway’s own listeners; never on 8051/8053 | 9051 / 9053, loopback only |
The check pipeline
The caller sends the stage, the content and the context. The api binds the key to the tenant, applies the rate limit and admission control; the policy resolver returns the compiled rail graph for (tenant, app, agent, version); the engine runs the graph, aggregates the findings, derives the decision and writes the ledger event.
- A stage’s rails are levelled by dependency: independent rails run in parallel, a rail consumes another’s findings with after (for example PII redaction after injection detection).
- Every rail has its own deadline, capped by the caller’s options.deadlineMs. A rail that runs out of time is recorded with its posture.
- Outcomes fold in graph order; the answer never depends on which detector finished first. Precedence: deny > hold > rewrite > reask > redact > escalate > allow_with_findings > allow.
- async rails run after the answer went out and append a follow-up event linked to the parent; an escalate finding calls the tenant webhook.
- Every admitted check writes exactly one ledger event, on every exit path: even an engine exception under a closed rail is a recorded deny, not a 500 an adapter might fail open on.
| Stage | p50 budget (design) | Note |
|---|---|---|
| input | 150 ms | sync; a speculative ticket in parallel with generation when the caller supports it |
| tool_call | 50 ms | Cedar plus deterministic validators |
| output | 250 ms | sync or async with a retraction frame |
| output_chunk | 40 ms | per sentence |
| retrieval | 100 ms | per batch |
The policy model
A policy pack is JSON validated against a published schema. Packs layer in org → tenant → app → agent order: a child inherits everything and may override rail parameters, add rails or switch a rail off. What a parent locks a descendant can only tighten, and the merge enforces it, not the author’s good intent.
- Versions: semver plus a content hash; every version is Ed25519-signed (client-side with a registered key or with the installation’s own signing key). The console shows the rail-level diff between versions.
- Activation gates: schema and Cedar validity; an enforcing closed rail nothing implements is refused; every named approval profile must exist in the chain; the template’s minimum tier is checked against the install.
- policy.requireApproval makes activation a two-person act; policy.requireBenchmark blocks activation on a failing red-team benchmark.
- Five signed-ready sector templates: tr-banking-bddk, tr-insurance-seddk, tr-public-sector, tr-defence, eu-ai-act-high-risk; each with a -baslangic variant for the CPU tier.
{
"id": "acme-banking", "version": "1.2.0", "extends": "tr-banking-bddk",
"scope": { "level": "tenant", "tenantId": "tnt_acme" },
"rails": [
{ "id": "pii.redact", "stages": ["input", "output"], "mode": "enforce",
"failPosture": "closed", "onFinding": "redact", "locked": true },
{ "id": "groundedness", "stages": ["output"], "mode": "shadow",
"params": { "minSupport": 0.6 } }
],
"approvals": { "four-eyes": { "approvers": ["TENANT_ADMIN"], "fourEyes": true,
"slaMinutes": 60, "fallback": "deny" } }
} The detector registry
Every detector runs behind the same HTTP Detector API and introduces itself: id, version, class, grade, languages, the rails it implements, capabilities. A rail declares the class and minimum grade it needs; the registry resolves the best detector on the installed tier that implements that class, meets the grade and supports the detected language.
- When none resolves, the rail’s posture applies, activation reports the gap and the health page shows it; there is no silent allow.
- The language pass: every sentence’s language is detected and the segment routed to that language’s detector; an English instruction inside a Turkish document reaches the English rules.
- Which detectors run is chosen in the console; the memory estimate is shown against the container’s limit and a change that does not fit is not applied.
- Built-in rails (secrets, system_prompt.leak, tool.args, tool.authz, labels.sink, budget, conversation, schema, exfil) run in-process and need no detector.
- The organisation’s own detector registers too: any HTTP service implementing the Detector API takes the same place in the rail graph.
The evidence ledger
Every event joins the tenant’s chain with hash = SHA-256(previous hash ‖ canonical JSON); canonicalisation is RFC 8785. The hash of the content is stored, never the content; when a PII rail ran over it, a redacted sample of at most 2 KB sits beside it and is deleted at the end of retention without touching the chain.
- Chain
- One chain per tenant; installation-wide changes on the platform chain. Every configuration change (policy, key, detector, licence, setting) is a config_change event.
- Anchors
- A daily job closes everything since the last anchor with a root hash and appends an anchor event; the list can be handed to the organisation’s own notary or SIEM.
- Nightly verification
- Every night the recent anchors’ root hashes are recomputed from the stored events and the tail verified hash by hash; a broken chain is a critical alert.
- Event content
- Stage, mode, app, agent, pack version, rails run and skipped, detector versions, decision, findings, labels, latency, content hash, approval id.
- Evidence packs
- A signed ZIP per tenant, period and profile; seven regulator profiles; verified offline with the installation’s public key.
Identity, tenancy, licence
Thulpar stores no end users. People’s identities come from the organisation’s OIDC provider (or, in the Aginies-embedded profile, from the Platform); local admin accounts + TOTP serve installs without an IdP and break-glass. Callers hold thp_live_… API keys; a key is bound to one tenant, or minted by a SUPER_ADMIN as an installation key for an integration serving several tenants.
- Roles: SUPER_ADMIN (install, tiers, detectors, licence), TENANT_ADMIN (policy, findings, approvals), approver (the inbox only). The console is an admin console.
- Every table carries the tenant id; a foreign row is 404. A disabled tenant’s keys and sessions get 403; nothing is deleted.
- Optional SPIFFE identity for workloads: X.509 or JWT SVIDs verified from trust-bundle files, the pack’s identity section binds the SPIFFE id to an app/agent and the identity gate runs before every rail.
- The licence is an Ed25519-signed JSON document; verification, enforcement and metering run inside the installation. No tenant is licensed by omission.
The gateway and scaling
The gateway is Thulpar’s own data plane: any OpenAI-compatible client and any MCP client put it in front of their model endpoints and tool servers. It runs the engine in-process; there is no HTTP hop to the api, no console control plane, no scheduled job. It is stateless apart from Postgres and Valkey and scales horizontally.
- Models are tenant aliases: alias, upstream URL, model name and a sealed credential are defined in the console; the client changes only the base URL and the key.
- Streaming: the answer is split into sentences and each checked at output_chunk; content that must be masked or stopped sends the client a retraction frame and the masked text.
- MCP proxy: tools/list schemas are hashed and pinned, a changed tool is quarantined, description hygiene applied; tools/call arguments pass the tool rails, results the indirect-injection and PII rails.
- Egress rule: the gateway reaches only configured upstreams, MCP servers and named authorization servers; private, loopback and metadata addresses are refused at registration and on every call.
- Under load: a token bucket per key (429), a concurrency cap per replica; above the soft cap quality rails are shed, above the hard cap a 503 and a shed ledger event; tenant fair share.
- No raw content in logs: pino redacts, metric labels are bounded vocabularies, never a tenant id or content. OTel traces only when an endpoint is given, and content-free.