GDPR reference architecture for enterprise LLM deployment at scale — Reference Architecture

Reference architecture · published 2026-10-16 · regulated enterprise · GDPR · 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 GDPR, and the question it actually asks is what is the lawful basis, and can you actually erase. The shape below holds p95 ≤ 450ms over REST over HTTPS, and ships with a standard-library implementation that runs as written.

The architecture friction point

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 GDPR specifically requires

GDPR asks two things an audit log does not answer: under what lawful basis was this processed, and can you erase it on request. Erasure is the harder one — a prompt embedded into a vector index and a fine-tune has propagated beyond the row you deleted.

The artifact that satisfies it is a lineage graph from source record to every derived artifact. The way teams most often fail it: an erasure request that cannot be honoured because the data survives in embeddings, caches and eval sets.

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.

The parts you have to build

  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.

Working 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 data-subject lineage and erasure 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 GDPR control, as code

Article 17 erasure propagated into derived artifacts. This is the artifact an assessor asks for under GDPR 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.

"""Article 17 erasure that propagates into derived artifacts.

The erasure request everyone implements is a DELETE on the source row. The one
that fails an audit is the derived copy: embeddings in a vector index, a cached
retrieval chunk, a fine-tuning corpus, a prompt log. Article 17(2) expects
controllers to take reasonable steps to inform processors of the erasure, and a
vector built from personal data is still personal data.

So erasure is modelled as a lineage walk with a receipt per derived artifact,
and the request is not complete until every downstream store confirms.
"""
from datetime import datetime, timezone


class Store:
    def __init__(self, name: str, kind: str):
        self.name, self.kind = name, kind
        self.rows: dict[str, dict] = {}
        self.erased: list[str] = []

    def put(self, key: str, subject_id: str, payload: str) -> None:
        self.rows[key] = {"subject_id": subject_id, "payload": payload}

    def erase_subject(self, subject_id: str) -> int:
        doomed = [k for k, v in self.rows.items() if v["subject_id"] == subject_id]
        for k in doomed:
            del self.rows[k]
            self.erased.append(k)
        return len(doomed)

    def holds(self, subject_id: str) -> int:
        return sum(1 for v in self.rows.values() if v["subject_id"] == subject_id)


class ErasureOrchestrator:
    """Lineage-aware erasure. A store that is not registered cannot be erased,
    so registration is the control — an unregistered derived store is exactly
    how personal data survives a 'completed' erasure."""

    def __init__(self):
        self.stores: list[Store] = []
        self.lineage: dict[str, list[str]] = {}

    def register(self, store: Store, derived_from: str | None = None) -> Store:
        self.stores.append(store)
        if derived_from:
            self.lineage.setdefault(derived_from, []).append(store.name)
        return store

    def execute(self, subject_id: str) -> dict:
        receipts = []
        for store in self.stores:
            held = store.holds(subject_id)
            removed = store.erase_subject(subject_id)
            receipts.append({"store": store.name, "kind": store.kind,
                             "held": held, "erased": removed})
        residual = {s.name: s.holds(subject_id) for s in self.stores
                    if s.holds(subject_id)}
        return {
            "subject_id": subject_id,
            "completed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "receipts": receipts,
            "residual": residual,
            "complete": not residual,
            "derived_stores_covered": sorted(
                s.name for s in self.stores if s.kind != "source"),
        }


if __name__ == "__main__":
    orch = ErasureOrchestrator()
    src = orch.register(Store("crm_postgres", "source"))
    vec = orch.register(Store("vector_index", "embedding"), derived_from="crm_postgres")
    cache = orch.register(Store("retrieval_cache", "cache"), derived_from="vector_index")
    logs = orch.register(Store("prompt_log", "log"), derived_from="crm_postgres")

    src.put("c-1", "subject-77", "Jana Soor, contract renewal notes")
    vec.put("v-1", "subject-77", "[0.11, -0.4, ...]")
    cache.put("k-1", "subject-77", "chunk: contract renewal notes")
    logs.put("l-1", "subject-77", "prompt containing the same notes")
    src.put("c-2", "subject-90", "unrelated subject")

    result = orch.execute("subject-77")
    assert result["complete"], result["residual"]
    assert len(result["derived_stores_covered"]) == 3, result
    assert sum(r["erased"] for r in result["receipts"]) == 4, result
    assert src.holds("subject-90") == 1, "erasure must not over-reach"

    # An unregistered derived store is the failure this design exists to expose.
    orphan = Store("analytics_extract", "warehouse")
    orphan.put("a-1", "subject-77", "same personal data, different copy")
    assert orphan.holds("subject-77") == 1
    assert orphan.name not in {s.name for s in orch.stores}

    print("GDPR Article 17 lineage erasure: OK")
    print(f"  stores erased {len(result['receipts'])}, complete={result['complete']}")
    print(f"  UNREGISTERED store still holding data: {orphan.name} "
          f"({orphan.holds('subject-77')} row) — registration is the control")

System architecture

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.

Implementation parameter matrix

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 standardGDPR
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 register with acceptance criteria

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
GDPRErasure request cannot reach data derived into embeddings or cachesTag every derived artifact with the source record id; make erasure a graph traversal, not a row deleteErasure completes across all derived stores within 30 days, evidenced by a lineage report per request
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

Review questions and answers

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 GDPR control questions you cannot yet answer, and what closes each gap
Request an architecture review