Architecture

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 2026

Components 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.

ORGANISATION NETWORK · CLOSED CIRCUIT Application / agentOpenAI SDK · thp_live_… MCP clienttools/list · tools/call Aginies Platformexecutor hooks THULPAR thulpar-gateway/v1/* · /mcp/:server · :8053 thulpar-api/v1/check · policy · ledger thulpar-detectorsCPU: ONNX, llama.cpp · GPU: vLLM console:8050 · OIDC Postgres · Valkeyledger · state Model endpointsvLLM · your own hosts MCP serversschemas pinned OIDC · SPIREusers · workloads evidence ledger · daily anchor · signed evidence packs · verify offlineKVKK · BDDK · SEDDK · public · EU AI Act · ISO 42001 · DORA INTERNET telemetry licence server vendor API remote generation × no connection at run time install only: ghcr.io · by digest or air-gap media ×
ComponentRolePort
thulpar-webConsole: findings, ledger, detectors, policies, approvals, gateway models, MCP servers, API keys, evidence packs, health, installation settings; TR default, EN8050
thulpar-apiThe check API, policy resolution, the rail engine, Cedar, labels, approvals, the ledger, exports, the licence, the console’s control plane8051
thulpar-gatewayThe OpenAI-compatible proxy and the MCP proxy; the engine in-process, no HTTP hop to the api8053
thulpar-detectorsDetector API sidecars: the CPU tier (ONNX, llama.cpp, Presidio, the file analyzer) and the GPU adapter (vLLM endpoints)not published
PostgresPolicies, ledger, approvals, detectors, API keys, installation settingsnot published
ValkeySessions, caches, rate limits, hold signals, stream tickets (the Redis protocol)not published
Prometheus /metricsThe api’s and the gateway’s own listeners; never on 8051/80539051 / 9053, loopback only
The detectors, Postgres and Valkey are not published to the host and sit on Compose-internal networks with no route out: a stray connection is dropped before it reaches the host firewall.

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.
Stagep50 budget (design)Note
input150 mssync; a speculative ticket in parallel with generation when the caller supports it
tool_call50 msCedar plus deterministic validators
output250 mssync or async with a retraction frame
output_chunk40 msper sentence
retrieval100 msper 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.