PCI DSS 4.0 reference architecture for enterprise LLM deployment at scale — Reference Architecture

Reference architecture · published 2026-10-16 · regulated enterprise · PCI DSS 4.0 · layout walkthrough · implementation executed in CI before publish

The control that resolves this is a Tenant-Fair Inference Tier, evaluated before the request reaches a model provider rather than audited afterwards. In regulated enterprise the binding constraint is PCI DSS 4.0, and the question it actually asks is is cardholder data in scope, and can you prove it is not. The shape below holds p95 ≤ 450ms over REST over HTTPS, and ships with a standard-library implementation that runs as written.

Why this blocks production

A shared inference tier holds under average load and breaches its p95 under a neighbour's batch job. The SLA is written on a percentile, the dashboards show a mean, and admission control is a single global rate limit that protects the provider rather than any individual tenant.

What PCI DSS 4.0 specifically requires

PCI scope is contagious: any system that stores, processes or transmits cardholder data — or can affect the security of one that does — is in scope. The cheapest control is to keep the model tier out of scope entirely.

The artifact that satisfies it is a segmentation test proving the model tier is out of scope. The way teams most often fail it: a PAN reaching a prompt log, dragging the whole inference tier into PCI scope.

Why this blocks a rollout rather than slowing it: Noisy-neighbour latency cannot be fixed downstream of admission. By the time a request is queued, the only remaining options are to shed it or to breach someone's contract. In regulated enterprise the cost of getting it wrong is concrete — a failed Type II observation window restarts the clock.

Implementation components

  1. Per-tenant token bucket — Sustained rate plus burst, so one tenant cannot spend another's budget.
  2. Reservation ledger — Guaranteed in-flight slots per tenant; reservations must sum below the ceiling.
  3. Global concurrency ceiling — Bounds the shared tier so it degrades predictably instead of collapsing.
  4. Percentile window — Streaming p95/p99 per tenant — the number the contract is written on.
  5. Queue with deadline — Requests carry a deadline and are shed rather than served late.
  6. Shed path — 429 with an accurate Retry-After so clients back off instead of hot-looping.

Reference implementation — Per-tenant admission control and percentile tracking

Standard-library Python, no external dependencies, runs as written. Executed by the publish gate: if it raises, this page does not ship. Wire it behind REST over HTTPS; the failure mode it guards against is a retried POST creating a duplicate record. The output you hand the assessor is the segmentation test report.

"""Per-tenant admission control for a shared inference tier.

Why this shape: a global rate limit protects the provider and nobody else. The
failure enterprises actually hit is the noisy-neighbour one — tenant A's batch
job saturates the queue and tenant B breaches the p95 latency SLA that is
written into their contract. You cannot fix that downstream of admission.

Two controls, both required:
  * a per-tenant token bucket, so one tenant cannot consume another's budget
  * a global concurrency ceiling with per-tenant reservations, so the shared
    tier degrades predictably instead of collapsing
"""
import heapq
import time
from dataclasses import dataclass, field


@dataclass
class TokenBucket:
    rate: float            # sustained requests per second
    burst: float           # instantaneous allowance
    tokens: float = None
    updated: float = field(default_factory=time.monotonic)

    def __post_init__(self):
        if self.rate <= 0 or self.burst <= 0:
            raise ValueError("rate and burst must be positive")
        if self.tokens is None:
            self.tokens = self.burst

    def take(self, n: float = 1.0, now: float = None) -> bool:
        now = time.monotonic() if now is None else now
        elapsed = max(0.0, now - self.updated)
        self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
        self.updated = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

    def retry_after(self, n: float = 1.0) -> float:
        """Seconds until n tokens exist. Return this in a 429 Retry-After so
        clients back off correctly instead of hot-looping."""
        deficit = max(0.0, n - self.tokens)
        return deficit / self.rate


class Admission:
    """Per-tenant buckets plus a global concurrency ceiling with reservations.

    reserved: tenant -> guaranteed in-flight slots. The sum of reservations
    must stay below `ceiling`, or the guarantee is fiction.
    """

    def __init__(self, ceiling: int, reserved: dict = None):
        self.ceiling = ceiling
        self.reserved = dict(reserved or {})
        if sum(self.reserved.values()) > ceiling:
            raise ValueError(
                f"reservations {sum(self.reserved.values())} exceed ceiling "
                f"{ceiling}: at least one tenant's SLA cannot be honoured")
        self.buckets: dict = {}
        self.inflight: dict = {}

    def configure(self, tenant: str, rate: float, burst: float):
        self.buckets[tenant] = TokenBucket(rate=rate, burst=burst)

    def admit(self, tenant: str, cost: float = 1.0) -> tuple:
        """-> (admitted, reason, retry_after_seconds)"""
        bucket = self.buckets.get(tenant)
        if bucket is None:
            return False, "tenant_not_provisioned", None

        used = sum(self.inflight.values())
        mine = self.inflight.get(tenant, 0)
        my_reservation = self.reserved.get(tenant, 0)

        # A tenant inside its reservation is admitted even when the shared pool
        # is full — that is what makes the reservation worth signing.
        if used >= self.ceiling and mine >= my_reservation:
            return False, "global_ceiling", 0.05

        if not bucket.take(cost):
            return False, "tenant_rate_limit", round(bucket.retry_after(cost), 3)

        self.inflight[tenant] = mine + 1
        return True, "ok", None

    def release(self, tenant: str):
        self.inflight[tenant] = max(0, self.inflight.get(tenant, 0) - 1)


class LatencyWindow:
    """Streaming p95/p99 over a bounded window.

    An average hides exactly the breach you are contractually exposed to: an
    SLA is written on a percentile, so the number you alert on must be one.
    """

    def __init__(self, size: int = 2048):
        self.size = size
        self.samples: list = []

    def observe(self, ms: float):
        self.samples.append(ms)
        if len(self.samples) > self.size:
            self.samples = self.samples[-self.size:]

    def quantile(self, q: float) -> float:
        if not self.samples:
            return 0.0
        ordered = sorted(self.samples)
        # Nearest-rank: the smallest sample at or above the q-th position.
        idx = min(len(ordered) - 1, max(0, int(round(q * len(ordered) + 0.5)) - 1))
        return ordered[idx]

    def breach(self, slo_ms: float, q: float = 0.95) -> bool:
        return self.quantile(q) > slo_ms


if __name__ == "__main__":
    adm = Admission(ceiling=10, reserved={"bank-eu": 4})
    adm.configure("bank-eu", rate=5, burst=5)
    adm.configure("retail-us", rate=50, burst=50)
    for _ in range(9):
        adm.admit("retail-us")
    print("noisy neighbour holds", adm.inflight)
    print("reserved tenant still admitted:", adm.admit("bank-eu"))
    print("burst exhausted ->", [adm.admit("bank-eu") for _ in range(5)][-1])

    win = LatencyWindow()
    for ms in [120, 140, 135, 128, 900, 131, 139, 142, 150, 2200]:
        win.observe(ms)
    print(f"p95={win.quantile(0.95):.0f}ms breach(400ms)={win.breach(400)}")

The PCI DSS 4.0 control, as code

PAN tokenisation and a scope-reduction proof. This is the artifact an assessor asks for under PCI DSS 4.0 specifically — it is not the same control as the one above, and a system that implements one does not thereby implement the other. It executes under the same publish gate.

"""PAN detection with tokenisation, and a scope-reduction proof.

PCI DSS 4.0 scope is everything that stores, processes or transmits account
data, plus anything connected to it. The cheapest compliance move is not better
controls — it is removing systems from scope. A model gateway that never sees a
PAN is out of scope; one that sees it once is in, along with everything it talks
to.
"""
import hashlib
import re

PAN_RE = re.compile(r"\b(?:\d[ -]?){12,18}\d\b")


def luhn_valid(digits: str) -> bool:
    total, parity = 0, len(digits) % 2
    for i, ch in enumerate(digits):
        d = int(ch)
        if i % 2 == parity:
            d *= 2
            if d > 9:
                d -= 9
        total += d
    return total % 10 == 0


class TokenVault:
    """Format-preserving surrogate. The vault is in scope; nothing it feeds is,
    provided the surrogate is not reversible outside it."""

    def __init__(self, pepper: str):
        self._pepper = pepper
        self._map: dict[str, str] = {}

    def tokenise(self, pan: str) -> str:
        digest = hashlib.sha256(f"{self._pepper}:{pan}".encode()).hexdigest()
        surrogate = "4" + str(int(digest[:15], 16)).zfill(15)[:15]
        self._map[surrogate] = pan
        return surrogate

    def last_four(self, pan: str) -> str:
        return pan[-4:]


class ScopeBoundary:
    def __init__(self):
        self.systems: dict[str, bool] = {}
        self.flows: list[tuple[str, str]] = []

    def add(self, system: str, sees_pan: bool) -> None:
        self.systems[system] = sees_pan

    def flow(self, src: str, dst: str) -> None:
        self.flows.append((src, dst))

    def in_scope(self) -> set[str]:
        scope = {s for s, sees in self.systems.items() if sees}
        changed = True
        while changed:                      # connected-to is also in scope
            changed = False
            for src, dst in self.flows:
                if src in scope and dst not in scope:
                    scope.add(dst); changed = True
                elif dst in scope and src not in scope:
                    scope.add(src); changed = True
        return scope


def redact(text: str, vault: TokenVault) -> tuple[str, int]:
    found = 0
    def _sub(m):
        nonlocal found
        raw = re.sub(r"[ -]", "", m.group(0))
        if not luhn_valid(raw):
            return m.group(0)
        found += 1
        return f"tok_{vault.tokenise(raw)}(last4={vault.last_four(raw)})"
    return PAN_RE.sub(_sub, text), found


if __name__ == "__main__":
    vault = TokenVault(pepper="hsm-managed")
    text = "Charge card 4111 1111 1111 1111 for invoice 9912, ref 1234567890123456"
    out, n = redact(text, vault)
    assert n >= 1 and "4111 1111 1111 1111" not in out, out
    assert "last4=1111" in out, out

    # A number that fails Luhn is not a PAN and must not be mangled.
    benign, n2 = redact("order 1234 5678 9012 3450 placed", vault)
    assert n2 == 0 and "1234 5678 9012 3450" in benign, benign

    sb = ScopeBoundary()
    sb.add("checkout", True); sb.add("token_vault", True)
    sb.add("llm_gateway", False); sb.add("analytics", False)
    sb.flow("checkout", "token_vault"); sb.flow("token_vault", "llm_gateway")
    scope = sb.in_scope()
    assert "llm_gateway" in scope, "connected-to is in scope — that is the point"

    clean = ScopeBoundary()
    clean.add("checkout", True); clean.add("token_vault", True)
    clean.add("llm_gateway", False)
    clean.flow("checkout", "token_vault")
    assert "llm_gateway" not in clean.in_scope(), clean.in_scope()

    print("PCI DSS 4.0 tokenisation + scope reduction: OK")
    print(f"  PANs tokenised {n}, naive scope {len(scope)} systems, "
          f"segmented scope {len(clean.in_scope())} systems")

The request path

Tenant-Fair Inference Tier — the request path, with the decision points that must exist before the first model call:

flowchart TB
    subgraph Tenants
      TA[Tenant A - batch]
      TB[Tenant B - interactive p95 400ms]
    end
    TA --> AD{Admission}
    TB --> AD
    AD -->|bucket empty| S429[429 + Retry-After]
    AD -->|ceiling full,<br/>outside reservation| S429
    AD -->|admit| Q[Deadline queue]
    Q -->|deadline passed| SH[Shed - do not serve late]
    Q --> W[Worker pool]
    W --> P[Model / vLLM]
    P --> OBS[Per-tenant p95 / p99 window]
    OBS -->|breach| ALERT[Page + autoscale signal]
Request path. Rendered as a diagram where scripting is available; the Mermaid source above is the authoritative definition either way.

Technical parameters

Every value below is a parameter you can hold a vendor or an internal team to. Adjectives are not acceptance criteria.

Implementation parameters for this deployment shape
Deployment vectorDedicated inference tier with per-tenant admission control
Security standardPCI DSS 4.0
Applicable regimesSOC 2 Type II, ISO/IEC 27001:2022, GDPR
Latency SLAp95 ≤ 450ms — interactive surfaces need sub-second; batch jobs do not
Integration protocolREST over HTTPS — the widest compatibility and the easiest audit story
Idempotency controlan Idempotency-Key header, honoured for 24h
Throughput envelope~2k rps per gateway node before connection churn dominates
Middleware requirementpolicy decision point in front of the model gateway
Data classes in scopePII, commercial-in-confidence data
Retention obligationper the data class retention schedule, enforced automatically
Data residencycontracted processing region, asserted on every response
Contractual instrumentDPA plus a disclosed subprocessor list
Systems of recordSAP, Salesforce, Workday
Primary failure modea retried POST creating a duplicate record

Risk and control matrix

Each row carries an acceptance parameter rather than an adjective, because "low latency" cannot be signed off. Retention here is bound by per the data class retention schedule, enforced automatically.

CategoryRiskControlAcceptance parameter
PCI DSS 4.0A PAN in a prompt pulls the inference tier into PCI scopeTokenise before the model boundary and prove segmentation with a penetration test at least annuallySegmentation test passes; 0 PANs detected in prompt or completion stores by continuous scanning
Latency SLAOne tenant's batch traffic breaches another's contractual p95Per-tenant token buckets plus reserved in-flight slots inside a global ceilingReserved tenants hold p95 under their SLO while a neighbour saturates the tier
Latency SLAMean latency hides the percentile the contract is written onTrack streaming p95/p99 per tenant; alert on the percentile, never the averageAlert fires on p95 breach within one window; mean is dashboard-only
Latency SLALate responses served instead of shed, so queues grow unboundedlyAttach a deadline to every request and shed on expiry0 responses served after deadline; shed rate published per tenant
Data privacyCross-tenant leakage through a shared cache or KV reuseNamespace every cache key by tenant id; never key on prompt text aloneCache key audit shows 100% tenant-namespaced; cross-tenant hit rate 0
Cost controlOver-provisioning GPU to absorb one tenant's burstsBound burst at the bucket, scale on admitted load rather than offered loadGPU utilisation 60-80%; scaling driven by admitted load
Cost controlRetries amplify a degradation into an outageAccurate Retry-After, client backoff, and a circuit breaker on the shed pathRetry amplification under 1.3x during an induced degradation
OperationalReservations sum above the ceiling, so guarantees are fictionValidate at configuration time; refuse to start with over-committed reservationsStartup check rejects over-commitment; covered by a unit test

What the auditor will ask

Related architectures

Enterprise infrastructure review

If you are carrying this blocker, the useful next step is not a demo. It is a review of your actual request path against the control points above:

  • Which of the 6 components exist in your estate today, and which are assumed
  • Your current numbers against the 7 acceptance parameters, starting with p95 ≤ 450ms
  • Which PCI DSS 4.0 control questions you cannot yet answer, and what closes each gap
Request an architecture review