On-premises compliance audit evidence architecture for enterprise LLM deployment — Reference Architecture

Reference architecture · published 2026-10-16 · regulated enterprise · SOC 2 Type II · layout matrix_first · implementation executed in CI before publish

The control that resolves this is an Attestable Inference Plane, evaluated before the request reaches a model provider rather than audited afterwards. In regulated enterprise the binding constraint is SOC 2 Type II, and the question it actually asks is did this control operate effectively for the whole observation window. The specific problem here is on-premises deployment. The shape below holds p95 ≤ 600ms over REST over HTTPS, and ships with a standard-library implementation that runs as written.

Deployment 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 vectorCustomer-managed VPC, provider model endpoint pinned to region
Security standardSOC 2 Type II
Applicable regimesSOC 2 Type II, ISO/IEC 27001:2022, GDPR
Latency SLAp95 ≤ 600ms — 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
SOC 2 Type IIEvidence population has gaps across the observation windowEmit a control-execution record on every request, not on exceptions; alert when daily volume drops below the expected floor0 days with no control records across the window
on-premises deploymentFixed capacity sized on average rather than peak concurrencySize on peak concurrency and context length; shed load deterministically rather than queueing without boundPeak-load test holds the SLO or sheds predictably; queue depth bounded and alerted
Data privacyPrompt and completion bodies persisted in the audit storeRedact before sealing; store sha256 digests of sensitive fields, never values0 raw PII fields in the audit store; digest-only verified by schema test in CI
Data privacySubprocessor exposure not disclosable to the customerPin provider + region per tenant; publish the subprocessor list as a versioned artifactSubprocessor list version referenced in every DPA; drift alert on change
Latency SLASealing and redaction added to the synchronous request pathRedact inline (cheap, deterministic); seal and persist asynchronously via an outboxAdded p95 under 12ms; outbox lag alert over 30s
Latency SLAVerifier scan over a long chain blocks the audit windowCheckpoint every 10k records; verify the delta against the last signed checkpointFull-chain verify under 60s per tenant per million records
Cost controlAudit volume grows faster than inference spendTier the store: hot 30 days, cold object storage to the retention horizonAudit storage held under 4% of inference spend
Cost controlRetention over-collection creates both cost and liabilityPer-class retention clock with documented, automated deletion0 records past their class retention horizon; deletion log reconciles monthly
OperationalKey rotation breaks historical seal verificationRecord key id per record; verifier resolves the key id against the KMS key historyRotation rehearsed quarterly; historical verification passes post-rotation

The architecture friction point

The model works and the pilot passed. The rollout is stopped by a control gap: the team can show that inference happened, but cannot produce per-tenant, tamper-evident evidence of WHAT was processed, by WHOM, under WHICH retention rule — which is the evidence an auditor asks for and a shared application log cannot give.

What makes on-premises deployment specifically hard

On-premises moves capacity planning, GPU supply and upgrade scheduling onto the customer's balance sheet and their change process. The architecture has to degrade gracefully on fixed hardware, because there is no autoscaling to hide behind.

What resolves it is a sizing model tied to concurrency and context length. The failure to design against: a deployment sized on average load that collapses at the quarter-end peak.

What SOC 2 Type II specifically requires

SOC 2 Type II is not a point-in-time assertion. The auditor samples evidence across a 3-12 month observation window and asks whether the control operated on every day of it, not whether it exists today.

The artifact that satisfies it is a sampled, time-ordered population the auditor can draw from. The way teams most often fail it: the control was added mid-window, so the population has a hole and the observation period restarts.

Why this blocks a rollout rather than slowing it: Audit evidence is an architectural property, not a logging level. Retrofitting it after the pilot means re-plumbing the request path, so the compliance gap surfaces exactly when the rollout date is already committed. In regulated enterprise the cost of getting it wrong is concrete — a failed Type II observation window restarts the clock.

Implementation components

  1. Policy decision point — Evaluates tenant, data class and purpose before the call. Fails closed.
  2. Redaction stage — Strips sensitive values and substitutes digests, BEFORE anything is sealed.
  3. Per-tenant hash chain — Each record commits to the previous digest, keyed per tenant.
  4. HMAC seal — A KMS-held key makes records unforgeable by anyone with store write access.
  5. Retention scheduler — Enforces the per-class retention clock and records the deletion.
  6. Verifier endpoint — The function you run in front of the auditor to prove the chain is intact.

The request path

Attestable Inference Plane — the request path, with the decision points that must exist before the first model call:

flowchart LR
    C[Client / Agent] --> G[Gateway]
    G --> PDP{Policy Decision Point<br/>tenant - class - purpose}
    PDP -->|deny| D[403 + denial record]
    PDP -->|allow| R[Redaction stage<br/>values to digests]
    R --> M[Model provider]
    M --> S[Seal: sha256 chain + HMAC]
    S --> L[(Per-tenant audit store)]
    L --> V[Verifier endpoint]
    V --> A[Auditor evidence pack]
    L --> RT[Retention scheduler]
    RT -->|expiry| X[Documented deletion]
Request path. Rendered as a diagram where scripting is available; the Mermaid source above is the authoritative definition either way.

Reference implementation — Per-tenant tamper-evident audit chain

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 observation-window evidence export.

"""Per-tenant, tamper-evident audit log for an LLM gateway.

Why this shape: a SOC 2 auditor does not ask "do you log?" — they ask whether a
log entry can be altered after the fact without detection, and whether tenant A
can ever read tenant B's entries. A plain append-only table answers neither.

This answers both with a per-tenant hash chain: each record commits to the
previous record's digest, so any edit or deletion breaks the chain at a
detectable point, and the chain is keyed per tenant so isolation is structural
rather than a WHERE clause someone can forget.
"""
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass, field


GENESIS = "0" * 64


def _canonical(obj) -> bytes:
    # Digests must be stable across processes and Python versions, so the JSON
    # has to be canonical: sorted keys, no insignificant whitespace.
    return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()


@dataclass
class AuditChain:
    """Append-only, per-tenant hash chain with an HMAC seal.

    secret: a per-deployment key from your KMS. It makes the seal
    unforgeable by anyone who only has write access to the store.
    """
    secret: bytes
    heads: dict = field(default_factory=dict)   # tenant_id -> last digest

    def append(self, tenant_id: str, event: dict) -> dict:
        if not tenant_id:
            raise ValueError("tenant_id is required: an unattributed audit "
                             "record cannot satisfy per-tenant evidence")
        prev = self.heads.get(tenant_id, GENESIS)
        record = {
            "tenant_id": tenant_id,
            "ts": event.get("ts") or time.time(),
            "actor": event.get("actor"),
            "action": event["action"],
            "resource": event.get("resource"),
            "prev": prev,
            # Redaction happens BEFORE sealing. Sealing a payload you later
            # need to redact forces you to break your own chain.
            "payload": redact(event.get("payload") or {}),
        }
        digest = hashlib.sha256(_canonical(record)).hexdigest()
        seal = hmac.new(self.secret, digest.encode(), hashlib.sha256).hexdigest()
        record["digest"] = digest
        record["seal"] = seal
        self.heads[tenant_id] = digest
        return record

    def verify(self, tenant_id: str, records: list) -> tuple:
        """-> (ok, first_broken_index). This is the function you demo to the
        auditor; without it the chain is a claim rather than evidence."""
        prev = GENESIS
        for i, r in enumerate(records):
            if r.get("tenant_id") != tenant_id:
                return False, i          # cross-tenant record in the stream
            body = {k: r[k] for k in
                    ("tenant_id", "ts", "actor", "action", "resource",
                     "prev", "payload")}
            if r.get("prev") != prev:
                return False, i
            if hashlib.sha256(_canonical(body)).hexdigest() != r.get("digest"):
                return False, i
            expect = hmac.new(self.secret, r["digest"].encode(),
                              hashlib.sha256).hexdigest()
            if not hmac.compare_digest(expect, r.get("seal", "")):
                return False, i
            prev = r["digest"]
        return True, -1


SENSITIVE_KEYS = {"prompt", "completion", "email", "ssn", "dob", "mrn",
                  "card", "iban", "phone", "address", "name"}


def redact(payload: dict, keep_hash: bool = True) -> dict:
    """Drop sensitive values, keep a digest so you can still prove WHICH
    payload was processed without storing the payload itself.

    This is the control that lets you keep audit evidence under GDPR data
    minimisation and an HIPAA BAA at the same time."""
    out = {}
    for k, v in payload.items():
        if k.lower() in SENSITIVE_KEYS:
            out[k] = ("sha256:" + hashlib.sha256(
                _canonical(v)).hexdigest()[:16]) if keep_hash else "[redacted]"
        elif isinstance(v, dict):
            out[k] = redact(v, keep_hash)
        else:
            out[k] = v
    return out


if __name__ == "__main__":
    chain = AuditChain(secret=os.environb.get(b"AUDIT_HMAC_KEY", b"dev-only-key"))
    stream = [
        chain.append("tenant-a", {"actor": "svc:rag", "action": "inference",
                                  "resource": "gpt-4o",
                                  "payload": {"prompt": "patient MRN 9912",
                                              "tokens": 318}}),
        chain.append("tenant-a", {"actor": "u:42", "action": "export",
                                  "resource": "conversation/77"}),
    ]
    print("redacted payload:", stream[0]["payload"])
    print("chain verifies:", chain.verify("tenant-a", stream))
    stream[0]["payload"]["tokens"] = 1          # tamper
    print("after tamper:   ", chain.verify("tenant-a", stream))

The SOC 2 Type II control, as code

Type II evidence sampling over the observation window. This is the artifact an assessor asks for under SOC 2 Type II 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.

"""SOC 2 Type II evidence sampling over an observation window.

Type II is not "the control exists" — it is "the control operated throughout
the period". An auditor samples days from the window and asks for proof the
control ran on each sampled day. A gap of one day with no evidence is an
exception, so the population must be built from operational records rather
than asserted.
"""
import hashlib
from datetime import date, timedelta


class ObservationWindow:
    """The population an auditor samples from, plus deterministic selection."""

    def __init__(self, start: date, end: date, control_id: str):
        if end < start:
            raise ValueError("window end precedes start")
        self.start, self.end, self.control_id = start, end, control_id
        self._evidence: dict[date, list[str]] = {}

    def record(self, day: date, artifact_ref: str) -> None:
        if not (self.start <= day <= self.end):
            raise ValueError(f"{day} outside observation window")
        self._evidence.setdefault(day, []).append(artifact_ref)

    def population(self) -> list[date]:
        n = (self.end - self.start).days + 1
        return [self.start + timedelta(days=i) for i in range(n)]

    def gaps(self) -> list[date]:
        """Days with no evidence. Each one is a Type II exception."""
        return [d for d in self.population() if not self._evidence.get(d)]

    def sample(self, size: int) -> list[date]:
        """Deterministic, reproducible selection — an auditor must be able to
        re-derive the same sample from the same seed rather than trust ours."""
        pop = self.population()
        size = min(size, len(pop))
        seed = f"{self.control_id}:{self.start}:{self.end}"
        scored = sorted(
            pop, key=lambda d: hashlib.sha256(f"{seed}:{d}".encode()).hexdigest())
        return sorted(scored[:size])

    def evidence_for(self, day: date) -> list[str]:
        return list(self._evidence.get(day, []))

    def attestation(self, size: int = 25) -> dict:
        sampled = self.sample(size)
        missing = [d for d in sampled if not self._evidence.get(d)]
        return {
            "control_id": self.control_id,
            "window": f"{self.start}..{self.end}",
            "population_days": len(self.population()),
            "sampled_days": len(sampled),
            "exceptions": [str(d) for d in missing],
            "opinion": "unqualified" if not missing else "qualified",
        }


if __name__ == "__main__":
    start, end = date(2026, 1, 1), date(2026, 3, 31)
    w = ObservationWindow(start, end, "CC6.1-tenant-isolation")
    for d in w.population():
        w.record(d, f"s3://evidence/{d}/isolation-check.json")
    clean = w.attestation()
    assert clean["opinion"] == "unqualified", clean
    assert clean["population_days"] == 90, clean

    # One missing day is one exception — and it must survive sampling to matter.
    w2 = ObservationWindow(start, end, "CC6.1-tenant-isolation")
    sampled = set(w2.sample(25))
    for d in w2.population():
        if d != sorted(sampled)[0]:
            w2.record(d, "ok")
    assert w2.attestation()["opinion"] == "qualified", w2.attestation()
    assert len(w2.gaps()) == 1, w2.gaps()

    # The sample must be reproducible from the same seed.
    assert w.sample(25) == ObservationWindow(start, end,
                                             "CC6.1-tenant-isolation").sample(25)
    print("SOC 2 Type II evidence sampling: OK")
    print(f"  population {clean['population_days']} days, "
          f"sample {clean['sampled_days']}, opinion {clean['opinion']}")

Review questions and answers

Observed in the field

This page exists because the problem is reported publicly. The threads below are the primary sources, linked unmodified and not affiliated with this site.

Adjacent problems

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 ≤ 600ms
  • Which SOC 2 Type II control questions you cannot yet answer, and what closes each gap
Request an architecture review