Back to AI/ML Overview
Vendor-Agnostic AI Architecture

Your AI vendor is a dependency, not a destiny.

In 2025 a large federal customer had to migrate AI vendors mid-program. The teams that survived it had built one thing: a architecture, where the provider is a swappable part. This page is the full blueprint β€” the provider gateway, the model-selection guardrails, and the data-security layers (, with , and ) β€” decomposed far enough that a high-schooler and a 25-year veteran both finish it thinking β€œnothing was left hand-wavy.”

Provider PortabilityMulti-Tenant Data IntegrityEncryption + mTLSRunnable Code
1
neutral interface, any provider
0
vendor SDK imports in app code
3
independent walls per tenant
2
encryption states: transit + rest
100%
network hops under TLS
mTLS
on every internal + egress hop

🎯The whole idea in one sentence β€” then we break it down

πŸ”‘The abstraction statement

Build the system so that which AI vendor you use and whose data is flowing through it are both decisions the architecture enforces β€” not things a developer has to remember to get right on every call.

That sentence is the entire page. Everything below is the decomposition β€” because an abstraction you cannot break down into concrete, configured, diagrammed parts is just a slogan. We will take it apart into six layers, and for each one show the actual code, the actual config, and the exact place a private key or a physically lives.

1
πŸ”€

The Model Gateway

One provider-neutral interface. App code never imports a vendor SDK. Each vendor is a thin adapter.
2
βš–οΈ

The Decision Matrix

How the gateway picks a provider per request β€” capability, cost, latency, data residency, compliance, fallback.
3
πŸ›‘οΈ

Guardrails

The gate around the model β€” input filters for injection and jailbreaks, output filters for PII leaks and policy.
4
🏒

Multi-Tenant Integrity

Tenant, workspace, and role injected once at the controller β€” never passed by hand, never droppable.
5
πŸ”

Encryption in Transit

TLS on every hop, mTLS internally and on egress. Where each private key lives, and how it is configured.
6
πŸ—„οΈ

Encryption at Rest

AES-256-GCM, envelope encryption, per-tenant data keys in a KMS β€” and crypto-shredding for real deletion.

πŸ“ŠThe picture, before the prose

Here is the full request lifecycle β€” one user request, from the edge of the network to the model and back. Watch where identity gets attached, where the encryption changes shape, and where the data finally comes to rest. Every box below has its own section.

Animated data-flow diagram of a model-agnostic AI request lifecycle. A request packet enters at the edge over TLS 1.3, reaches a controller that injects tenant ID, workspace ID, and role from the verified token into a request-scoped context. It passes through input guardrails (prompt-injection, jailbreak, PII scan), then a model gateway with a decision matrix that routes to one of four interchangeable provider adapters (Anthropic, OpenAI, AWS Bedrock, Google Vertex). The egress hop to the provider uses mTLS with a client certificate whose private key never leaves the pod. The response flows back through output guardrails (PII leak, policy, hallucination check) and is persisted with AES-256-GCM encryption at rest using a per-tenant data key, with the root key held in a KMS, plus an append-only audit log.
One request, six layers. Identity is injected once at the controller and travels with the request; the provider is chosen by the gateway and is fully swappable; encryption changes shape (TLS β†’ β†’ AES-at-rest) but never has a gap.
Download: GIF (animated, for LinkedIn / Slack) Β· PNG (still, high-res)

If the image has not rendered yet, here is the same lifecycle as plain text β€” this is the spine of the whole page:

   User / Agent
        β”‚   TLS 1.3  (encryption in transit β€” Layer 5)
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ EDGE / LOAD BALANCER          terminates public TLS   β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚   mTLS  (client certificate from here inward)
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ CONTROLLER  β€” identity injected ONCE  (Layer 4)       β”‚
 β”‚   tenant_id Β· workspace_id Β· role                     β”‚
 β”‚   read from the verified token β†’ request-scoped ctx   β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ INPUT GUARDRAILS  (Layer 3)                           β”‚
 β”‚   prompt-injection Β· jailbreak Β· PII scan             β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ MODEL GATEWAY  β€” one neutral interface  (Layer 1)     β”‚
 β”‚   decision matrix picks a provider      (Layer 2)     β”‚
 β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
 β”‚   β”‚Anthropicβ”‚ β”‚ OpenAI β”‚ β”‚ Bedrock β”‚ β”‚ Vertex / ... β”‚ β”‚
 β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚   mTLS egress β€” client cert, private key
        β–Ό   stays in the pod / HSM, never on the wire
   [  LLM PROVIDER  ]
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ OUTPUT GUARDRAILS  (Layer 3)                          β”‚
 β”‚   PII leak Β· policy violation Β· hallucination check   β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ PERSIST + AUDIT  (Layer 6)                            β”‚
 β”‚   AES-256-GCM at rest Β· per-tenant data key           β”‚
 β”‚   KMS holds the root key Β· append-only audit log      β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🧭Layer 0 β€” why your vendor is a dependency, not a destiny

Before the layers, the motivation β€” because if you do not feel the problem, the solution looks like over-engineering.

When you call an provider, you are taking a hard dependency on a company you do not control. That company can β€” and routinely does β€” have an outage, change its prices, deprecate the exact model your prompts were tuned for, change its data-handling terms, or lose a compliance certification your customer requires. None of those are hypothetical. All of them happened to someone in the last year.

⚠️The lock-in tax is paid at the worst possible time

If your application code is full of openai.chat.completions.create(...) calls, then β€œswitch providers” is a multi-week refactor touching every file that talks to a model. You will be asked to do that refactor precisely when there is an outage, a price shock, or a compliance deadline β€” i.e. under maximum pressure. The architecture moves that cost forward and shrinks it: switching becomes a config change plus, at most, one new adapter.

This is not anti-vendor. You will still pick the best model for each job β€” that is Layer 2. The point is that the choice stays yours, reversible, and cheap to change. The same instinct that says β€œdo not hard-code your database vendor into every query” says β€œdo not hard-code your model vendor into every prompt.”

πŸ”€Layer 1 β€” the model gateway (the provider abstraction)

The model gateway is one internal module with one job: expose a provider-neutral way to call an . Application code calls the gateway. The gateway β€” and only the gateway β€” knows that Anthropic, OpenAI, Bedrock, and exist.

Step one is a neutral request and response shape. It is deliberately small β€” the common denominator of what every provider can do:

πŸ“„ gateway/types.ts β€” the provider-neutral contract
typescript
// The shape the WHOLE application speaks. No vendor words in here.
export interface ModelRequest {
  messages: { role: 'system' | 'user' | 'assistant'; content: string }[]
  maxTokens: number
  temperature?: number
  // a capability tag, NOT a vendor model name β€” Layer 2 turns this into a real model
  task: 'cheap-extract' | 'general' | 'hard-reasoning' | 'long-context'
}

export interface ModelResponse {
  text: string
  provider: string          // which vendor actually served it (for logs/audit)
  model: string             // the concrete model id that was used
  usage: { inputTokens: number; outputTokens: number; costUsd: number }
}

// Every vendor adapter implements exactly this. That's the entire seam.
export interface ModelProvider {
  readonly name: string
  complete(req: ModelRequest): Promise<ModelResponse>
}
↕ Scroll

Step two is one thin adapter per vendor. Each adapter translates the neutral shape into that vendor's wire format and back. This is the only file in the codebase that imports a vendor SDK:

πŸ“„ gateway/adapters/anthropic.ts β€” one of several adapters
typescript
import Anthropic from '@anthropic-ai/sdk'
import { ModelProvider, ModelRequest, ModelResponse } from '../types'
import { resolveModel } from '../decision-matrix'

export class AnthropicAdapter implements ModelProvider {
  readonly name = 'anthropic'
  private client = new Anthropic()  // reads its key from the KMS-backed secret mount

  async complete(req: ModelRequest): Promise<ModelResponse> {
    const model = resolveModel('anthropic', req.task)   // Layer 2 decides the concrete model
    const system = req.messages.find(m => m.role === 'system')?.content
    const turns  = req.messages.filter(m => m.role !== 'system')

    const res = await this.client.messages.create({
      model,
      max_tokens: req.maxTokens,
      temperature: req.temperature ?? 0.7,
      system,
      messages: turns as Anthropic.MessageParam[],
    })

    const text = res.content.filter(b => b.type === 'text').map(b => b.text).join('')
    return {
      text,
      provider: this.name,
      model,
      usage: {
        inputTokens: res.usage.input_tokens,
        outputTokens: res.usage.output_tokens,
        costUsd: priceFor(model, res.usage),
      },
    }
  }
}

// gateway/adapters/openai.ts, bedrock.ts, vertex.ts are the same shape β€”
// import the vendor SDK, map the neutral request in, map the response out.
↕ Scroll

Step three is the gateway itself. It owns the cross-cutting concerns so every call gets them for free β€” provider selection, fallback, retries, timeouts, cost accounting, and tenant tagging:

πŸ“„ gateway/index.ts β€” the one entry point the app calls
typescript
import { ModelRequest, ModelResponse, ModelProvider } from './types'
import { AnthropicAdapter } from './adapters/anthropic'
import { OpenAIAdapter } from './adapters/openai'
import { BedrockAdapter } from './adapters/bedrock'
import { VertexAdapter } from './adapters/vertex'
import { chooseProvider } from './decision-matrix'   // Layer 2
import { tenantContext } from '../context/tenant'    // Layer 4
import { auditLog } from '../audit'                  // Layer 6

const providers: Record<string, ModelProvider> = {
  anthropic: new AnthropicAdapter(),
  openai:    new OpenAIAdapter(),
  bedrock:   new BedrockAdapter(),
  vertex:    new VertexAdapter(),
}

export async function complete(req: ModelRequest): Promise<ModelResponse> {
  const { tenantId, workspaceId } = tenantContext.get()   // ambient β€” see Layer 4

  // Layer 2 returns an ordered list: [primary, ...fallbacks]
  const order = chooseProvider(req, { tenantId })

  let lastErr: unknown
  for (const name of order) {
    try {
      const res = await providers[name].complete(req)
      await auditLog({ tenantId, workspaceId, provider: name, model: res.model, usage: res.usage })
      return res
    } catch (err) {
      lastErr = err            // outage / rate limit / deprecation β†’ try the next provider
    }
  }
  throw new Error('All providers failed: ' + String(lastErr))
}
↕ Scroll
πŸ”‘Adding AWS Bedrock is a connector, not a redesign

When a new provider is required β€” say a customer mandates AWS Bedrock for FedRAMP reasons β€” you write one new file: gateway/adapters/bedrock.ts, implementing the same ModelProvider interface. You register it in the providers map. You add it to the decision matrix. Zero application files change, because no application file ever imported a vendor SDK in the first place. That is the whole payoff of Layer 1.

βš–οΈLayer 2 β€” the model-selection decision matrix (the guardrails on which model)

The gateway can reach four providers. Which one should serve a given request? That is not a vibe β€” it is a checklist. This is what the LinkedIn mock interview was really asking: what criteria do you use to choose a model, and what are the on that choice?

Here is the matrix. Every row is a question the router asks before a single token is spent:

CriterionThe question it answersExample consequence
Capability fitIs this task simple extraction, or hard multi-step reasoning?cheap-extract β†’ Haiku / Flash; hard-reasoning β†’ Opus / o-series
Cost per tokenWhat does this task cost at each provider, at expected volume?A 10M-call/day classifier on a frontier model is a budget fire
Latency budgetIs a human waiting on this, or is it a background job?Interactive chat β†’ fast model; nightly batch β†’ cheapest model
Context windowDoes the prompt plus retrieved context fit the model's window?A 400K-token document review forces a long-context model
Data residencyIs this tenant's data allowed to leave a region or a boundary?An EU or GovCloud tenant pins to an in-boundary provider only
Compliance postureDoes this provider hold the certification this tenant requires?FedRAMP / SOC 2 / HIPAA β€” a missing cert removes a provider
Provider healthIs the primary provider currently degraded or rate-limiting?Circuit-breaker open β†’ skip straight to the fallback
Fallback chainIf the chosen provider fails mid-request, who is next?Always return an ordered list, never a single provider

In code, the matrix is a pure function: request in, ordered provider list out. Pure means it is trivially testable β€” and a model-selection decision you cannot unit-test is a decision you cannot defend in an audit.

πŸ“„ gateway/decision-matrix.ts
typescript
import { ModelRequest } from './types'
import { getTenantPolicy } from '../tenancy/policy'   // per-tenant residency + compliance rules
import { circuitState } from './health'

// Returns an ORDERED list: [primary, ...fallbacks]. Never a single value.
export function chooseProvider(
  req: ModelRequest,
  ctx: { tenantId: string },
): string[] {
  const policy = getTenantPolicy(ctx.tenantId)   // e.g. { residency: 'us-gov', certs: ['fedramp'] }

  // 1. HARD FILTER β€” drop any provider this tenant is not allowed to use.
  //    Residency and compliance are guardrails, not preferences: they cannot be traded away.
  let eligible = ALL_PROVIDERS.filter(p =>
    p.regions.includes(policy.residency) &&
    policy.certs.every(c => p.certifications.includes(c)),
  )

  // 2. HEALTH FILTER β€” drop providers whose circuit breaker is currently open.
  eligible = eligible.filter(p => circuitState(p.name) !== 'open')

  // 3. RANK the survivors by capability fit, then latency, then cost.
  const ranked = eligible.sort((a, b) =>
    capabilityScore(b, req.task) - capabilityScore(a, req.task) ||
    a.p50LatencyMs - b.p50LatencyMs ||
    costFor(a, req) - costFor(b, req),
  )

  if (ranked.length === 0) {
    throw new Error(`No compliant provider for tenant ${ctx.tenantId}`)  // fail closed
  }
  return ranked.map(p => p.name)   // primary first, fallbacks after
}
↕ Scroll
πŸ’‘Capability and cost are preferences. Residency and compliance are guardrails.

Notice the two-stage shape: a hard filter first, then a ranking. The hard filter encodes the things that are never negotiable β€” a GovCloud tenant's data does not go to a non-GovCloud provider to save money, ever. The ranking encodes the things you optimize once the non-negotiables are satisfied. Mixing those two into one weighted score is the classic mistake: it lets a cost saving silently outvote a compliance requirement. Keep them separate, and the system fails closed β€” if nothing is compliant, it errors rather than guessing.

πŸ›‘οΈLayer 3 β€” guardrails (the gate around the model)

are the code that sits between the user and the model, and again between the model and anything the model's output touches. The model is powerful and gullible; the are the seatbelt.

⬇️

Input β€” before the model

  • detection β€” is the user (or a retrieved document) trying to override the ?
  • detection β€” known patterns that try to unlock disallowed behavior.
  • PII / secret scanning β€” strip or block card numbers, SSNs, credentials before they ever reach a third-party provider.
  • Topic + scope limits β€” keep the request inside what this product is allowed to answer.
⬆️

Output β€” after the model

  • PII leak check β€” did the model echo back sensitive data it should not have, or data from the wrong tenant?
  • Policy / safety filter β€” does the output violate content policy or a regulatory rule?
  • check β€” for grounded tasks, is every claim supported by the retrieved context?
  • Schema validation β€” if was promised, enforce it before it reaches a downstream system.

In the lifecycle, wrap the gateway call β€” nothing reaches a provider unchecked, and nothing leaves the model unchecked:

πŸ“„ guardrails/wrap.ts β€” guardrails wrap the gateway, not the other way round
typescript
import { complete } from '../gateway'
import { ModelRequest, ModelResponse } from '../gateway/types'
import { scanInput, scanOutput, GuardrailError } from './checks'
import { tenantContext } from '../context/tenant'

export async function guardedComplete(req: ModelRequest): Promise<ModelResponse> {
  const { tenantId } = tenantContext.get()

  // ── INPUT GATE ────────────────────────────────────────────────
  const inVerdict = await scanInput(req, tenantId)
  if (inVerdict.blocked) {
    throw new GuardrailError('input', inVerdict.reason)   // never reaches a provider
  }

  // ── THE MODEL CALL (Layers 1 + 2) ─────────────────────────────
  const res = await complete(inVerdict.sanitizedRequest)

  // ── OUTPUT GATE ───────────────────────────────────────────────
  const outVerdict = await scanOutput(res, tenantId)
  if (outVerdict.blocked) {
    throw new GuardrailError('output', outVerdict.reason)  // never reaches the user
  }
  return outVerdict.sanitizedResponse
}
↕ Scroll
πŸ’‘Guardrails are provider-agnostic too

Because wrap the gateway and not a specific vendor, they keep working unchanged when you switch providers. The injection scanner does not care whether the model underneath is Claude or GPT or a Bedrock-hosted model β€” it inspects the neutral request and the neutral response. One more thing the abstraction buys you.

🏒Layer 4 β€” multi-tenant data integrity (the part that actually leaks)

This is the layer the mock interview pushed hardest on, and rightly so. In a system, the catastrophic failure is not downtime β€” it is Tenant A seeing Tenant B's data. And here is the uncomfortable truth about how that happens:

⚠️Cross-tenant leaks are almost never a missing check β€” they are a forgotten one

Nobody writes SELECT * FROM documents on purpose. What happens is: a query is written correctly with WHERE tenant_id = ?, and then six months later someone adds a new query in a hurry and forgets the filter β€” or forgets to thread the tenantId argument through the fourth function in the call chain. The leak is a dropped parameter. So the architecture's job is to make the parameter impossible to drop.

The rule: inject, don't pass

Do not pass tenantId, workspaceId, and role as ordinary function arguments that every layer has to remember to forward. Instead, the controller establishes them once, at the very edge of the request, from the verified token β€” and injects them into a request-scoped context that every lower layer reads automatically.

πŸͺͺ

tenant_id

Which customer organization. The hard partition β€” data never crosses it.

πŸ“

workspace_id

Which project / team inside that customer. A softer partition for scoping within a tenant.

🎭

role

What this user may do () β€” admin, member, read-only β€” also injected, also enforced below.

Step one β€” the controller reads identity from the verified token and injects it. This is the only place identity is set:

πŸ“„ controllers/chat.ts β€” identity is established ONCE, here
typescript
import { Router } from 'express'
import { verifyToken } from '../auth'
import { tenantContext } from '../context/tenant'
import { guardedComplete } from '../guardrails/wrap'

export const chatRouter = Router()

chatRouter.post('/chat', async (req, res, next) => {
  // 1. Verify the token. This is the trust boundary β€” nothing before it is trusted.
  const claims = await verifyToken(req.headers.authorization)
  //    claims = { tenantId, workspaceId, role, userId }  β€” cryptographically verified

  // 2. INJECT identity into the request-scoped context, then run the handler INSIDE it.
  //    Everything downstream of tenantContext.run() can read this β€” and nothing can
  //    run a query without it, because the data layer refuses to (see step 3).
  await tenantContext.run(claims, async () => {
    const answer = await guardedComplete({
      messages: req.body.messages,
      maxTokens: 1024,
      task: 'general',
    })
    res.json({ answer: answer.text })
  })
})
↕ Scroll

Step two β€” the context itself. In Node this is AsyncLocalStorage (Python: contextvars; Java: a request-scoped bean). It is β€œambient” β€” available to any code running inside the request, without being handed down explicitly:

πŸ“„ context/tenant.ts β€” the ambient, request-scoped identity
typescript
import { AsyncLocalStorage } from 'node:async_hooks'

export interface TenantClaims {
  tenantId: string
  workspaceId: string
  role: 'admin' | 'member' | 'readonly'
  userId: string
}

const als = new AsyncLocalStorage<TenantClaims>()

export const tenantContext = {
  run: <T>(claims: TenantClaims, fn: () => Promise<T>) => als.run(claims, fn),

  // Throws if called outside a request scope. That throw is a FEATURE:
  // it means no code path can quietly run "tenant-less".
  get(): TenantClaims {
    const claims = als.getStore()
    if (!claims) throw new Error('No tenant context β€” query attempted outside a request scope')
    return claims
  },
}
↕ Scroll

Step three β€” the data layer reads the tenant from the context itself. There is no public way to query without a tenant filter, because the function that would let you do that does not exist:

πŸ“„ db/scoped.ts β€” the ONLY way to reach the database
typescript
import { pool } from './pool'
import { tenantContext } from '../context/tenant'

// Every query goes through here. There is no exported "raw query" function.
export async function scopedQuery<T>(sql: string, params: unknown[] = []): Promise<T[]> {
  const { tenantId } = tenantContext.get()    // ambient β€” cannot be forgotten, cannot be faked

  const client = await pool.connect()
  try {
    // Wall 1 (application): bind the tenant into a session variable for THIS connection.
    await client.query('SET LOCAL app.tenant_id = $1', [tenantId])
    // Wall 2 (database): Postgres row-level security reads that same variable β€” see below.
    const result = await client.query(sql, params)
    return result.rows as T[]
  } finally {
    client.release()
  }
}
↕ Scroll
πŸ“„ migrations/001_rls.sql β€” Wall 2, enforced by the database itself
sql
-- Row-level security: even a query with NO tenant filter only sees its own tenant's rows.
-- This is the independent second wall β€” it holds even if application code has a bug.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Now: SELECT * FROM documents  -- returns ONLY the current tenant's rows.
-- The filter is no longer the developer's responsibility β€” it is the database's.
πŸ”‘Three independent walls β€” and why 'independent' is the whole point

A request now passes three walls that each stop a cross-tenant leak on their own:

  • Wall 1 β€” the ambient context. Application code physically cannot run a query without a tenant, because scopedQuery reads it from tenantContext.get(), which throws when absent.
  • Wall 2 β€” Postgres row-level security. Even a query that somehow forgot its WHERE clause only sees the current tenant's rows, because the database enforces it.
  • Wall 3 β€” per-tenant encryption keys (Layer 6). Even raw bytes read off disk for the wrong tenant are ciphertext, because each tenant's data is encrypted under its own key.

β€œIndependent” means a single bug breaches at most one wall. You do not get a cross-tenant leak from one mistake β€” you would need three simultaneous, unrelated failures. That is what defense-in-depth actually means, made concrete.

And the model call inherits all of this for free: back in Layer 1, the gateway read tenantContext.get() to tag every provider call and every audit row with the tenant. Prompts, logs, and cost accounting stay partitioned by the same identity that partitions the database β€” because it is the same identity, injected once.

πŸ”Layer 5 β€” encryption in transit (and exactly where the private key lives)

means: while data is moving across a network, anyone who taps the wire sees ciphertext. The statement is easy. The engineering is in the details β€” which hops, what kind of TLS, and where the keys physically live. Let us not leave any of that hand-wavy.

Every hop, no exceptions

There is no hop β€” however β€œinternal” β€” that travels in plaintext. The network perimeter is not a substitute for encryption; that assumption is exactly what zero-trust architecture exists to kill.

HOP                                   PROTECTION         WHO PROVES IDENTITY
─────────────────────────────────────────────────────────────────────────────
browser  β†’ load balancer              TLS 1.3            server only
load balancer β†’ controller            mTLS               server + client
controller β†’ gateway / guardrails     mTLS               server + client
gateway  β†’ LLM provider (egress)      mTLS               server + client
service  β†’ database                   TLS 1.3 + cert     server (+ client cert)

The public edge uses ordinary TLS 1.3: the browser checks the server's certificate, the browser stays anonymous (the user authenticates with a token, separately). From the load balancer inward, every hop upgrades to β€” β€” where the client also presents a certificate and the server refuses the connection without one.

How actually works β€” and where each private key lives

This is the question the interview asked directly. In a client-certificate exchange there are two key pairs, and the entire security of the scheme rests on where the private halves live and never leave:

πŸ–₯️

The server's key pair

Public certificate: sent to the client during the handshake. Travels the wire freely β€” it is public.

Private key: never leaves the server. Mounted into the pod from a secrets store or a , file permissions 0400, readable only by the server process's user. Not in the container image. Not in source control. Not in an environment variable. Not in a log line.

πŸ“¦

The client's key pair

Public certificate: sent to the server during the handshake so the server can verify the caller. Also public, also fine on the wire.

Private key: never leaves the client. Same treatment β€” mounted secret or , 0400, process-readable only. The client uses it to sign one value during the handshake, proving it holds the key without ever transmitting it.

πŸ”‘The one sentence to remember about private keys

Only public certificates ever cross the wire. Private keys are mounted, never sent. A private key's entire life is: generated inside a boundary (a , an , or a sealed secrets process), delivered to exactly one workload as a read-only mount, used in memory, and destroyed when the pod dies. If a private key has ever been in a git repo, a Slack message, a Dockerfile, or a log β€” it is not a private key anymore, it is a public one, and the certificate must be revoked.

The handshake, step by step β€” note that the private keys are used but never sent:

CLIENT                                                    SERVER
  β”‚                                                          β”‚
  β”‚ ── 1. ClientHello ─────────────────────────────────────▢ β”‚
  β”‚                                                          β”‚
  β”‚ ◀── 2. ServerHello + server's PUBLIC certificate ──────  β”‚
  β”‚        + "I require a client certificate too"            β”‚
  β”‚                                                          β”‚
  β”‚   3. verify server cert against the trusted CA bundle    β”‚
  β”‚      (is this really who I meant to call?)               β”‚
  β”‚                                                          β”‚
  β”‚ ── 4. client's PUBLIC certificate ─────────────────────▢ β”‚
  β”‚ ── 5. a signature made WITH the client's PRIVATE key ──▢ β”‚
  β”‚      (the private key never leaves β€” only the signature) β”‚
  β”‚                                                          β”‚
  β”‚                          6. verify client cert against   β”‚
  β”‚                             the trusted CA bundle, and   β”‚
  β”‚                             verify the signature         β”‚
  β”‚                                                          β”‚
  β”‚ ◀════ 7. encrypted channel open β€” both sides proven ════▢│

And the configuration β€” concretely, this is all it is. Issue a cert + key pair per service from an internal certificate authority, mount them, point the client and server at the file paths, and pin the CA bundle so only internally-issued certificates are trusted:

πŸ“„ k8s/gateway-deployment.yaml β€” the private key is a read-only mount, nothing more
yaml
# The cert + key pair is issued by the internal CA (cert-manager / Vault PKI)
# and delivered as a secret. The pod MOUNTS it β€” the key is never in the image.
volumes:
  - name: mtls-certs
    secret:
      secretName: gateway-client-cert   # contains tls.crt, tls.key, ca.crt
      defaultMode: 0400                 # read-only, owner-only

containers:
  - name: model-gateway
    volumeMounts:
      - name: mtls-certs
        mountPath: /etc/mtls
        readOnly: true                  # the workload cannot even modify it
↕ Scroll
πŸ“„ gateway/http-client.ts β€” point the client at the mounted paths
typescript
import { Agent } from 'undici'
import { readFileSync } from 'node:fs'

// The private key is READ from the mount at startup, held in memory, and used to
// sign handshakes. It is never logged, never serialized, never sent.
export const mtlsAgent = new Agent({
  connect: {
    cert: readFileSync('/etc/mtls/tls.crt'),   // our PUBLIC certificate β€” ok to present
    key:  readFileSync('/etc/mtls/tls.key'),   // our PRIVATE key β€” used in memory only
    ca:   readFileSync('/etc/mtls/ca.crt'),    // pin: trust ONLY internally-issued certs
    rejectUnauthorized: true,                  // refuse any peer we cannot verify
  },
})

// Every egress call from the gateway uses this agent. In a service mesh
// (Istio, Linkerd) the sidecar does all of the above and the app code stays clean β€”
// but the model is identical: mounted key, public cert on the wire, pinned CA.
↕ Scroll

πŸ—„οΈLayer 6 β€” encryption at rest (and what 'where does the key live' means here)

means: while data sits in storage β€” the database, the object store, the backups, the logs β€” it is ciphertext. A stolen disk or a leaked snapshot is useless without the keys. Again the statement is simple; the engineering is in which keys, held where, and who can ask them to do what.

β€” the professional pattern

Naive encryption uses one key for everything; if it leaks, everything is exposed, and rotating it means re-encrypting the world. fixes both problems with a hierarchy of keys:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  KMS  (hardware security module)                                 β”‚
β”‚                                                                   β”‚
β”‚   ROOT KEY  ── never leaves the KMS hardware. Ever.               β”‚
β”‚      β”‚        the application cannot read it β€” it can only ASK   β”‚
β”‚      β”‚        the KMS to use it, and every ask is logged.        β”‚
β”‚      β–Ό                                                            β”‚
β”‚   wraps (encrypts) each tenant's DATA KEY                         β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚   the app stores only the WRAPPED (encrypted) data key
       β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ Tenant A   data key   β”‚   β”‚ Tenant B   data key   β”‚   ... one per tenant
 β”‚ (stored wrapped)      β”‚   β”‚ (stored wrapped)      β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚ to read Tenant A's data:  β”‚
            β”‚  1. ask KMS to unwrap A's data key (in memory only)
            β”‚  2. AES-256-GCM decrypt the rows with it
            β”‚  3. discard the unwrapped key
            β–Ό
   ciphertext rows in Postgres / object store / backups

So β€œwhere does the key live” has a precise answer at this layer:

  • The root key lives inside the / hardware and never comes out. The application never holds it β€” it can only request operations (β€œunwrap this data key”), and each request is authenticated, authorized, and audit-logged.
  • Each tenant data key is stored only in its wrapped (encrypted) form, next to the data. On its own it is useless β€” it has to be unwrapped by the , in memory, for each use, then discarded.
  • The application holds neither key . At most it holds an unwrapped data key in memory for the duration of one operation.
πŸ“„ crypto/envelope.ts β€” the app asks the KMS; it never holds the root key
typescript
import { KMSClient, DecryptCommand } from '@aws-sdk/client-kms'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { tenantContext } from '../context/tenant'
import { getWrappedDataKey } from './keystore'

const kms = new KMSClient()

// Decrypt a stored field for the CURRENT tenant (identity comes from Layer 4).
export async function decryptField(ciphertext: Buffer, iv: Buffer, tag: Buffer): Promise<string> {
  const { tenantId } = tenantContext.get()

  // 1. Fetch this tenant's data key β€” but it is WRAPPED; we cannot use it yet.
  const wrapped = await getWrappedDataKey(tenantId)

  // 2. Ask the KMS to unwrap it. The ROOT key does the work inside the KMS;
  //    we get back the data key in memory only. We never see the root key.
  const { Plaintext: dataKey } = await kms.send(new DecryptCommand({ CiphertextBlob: wrapped }))

  // 3. AES-256-GCM decrypt the field with the unwrapped data key.
  const decipher = createDecipheriv('aes-256-gcm', dataKey as Buffer, iv)
  decipher.setAuthTag(tag)
  const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()])

  // 4. Discard the unwrapped key β€” it lived in memory for one operation.
  ;(dataKey as Buffer).fill(0)
  return plain.toString('utf8')
}
↕ Scroll
πŸ”‘Per-tenant keys give you crypto-shredding β€” real, provable deletion

Because each tenant has its own data key, deleting a tenant for real does not mean hunting down every row and every backup. You destroy that tenant's key in the . Instantly, every copy of their data β€” including snapshots, including backups you cannot even reach β€” becomes permanently unreadable ciphertext. That is crypto-shredding, and for β€œright to be forgotten” and federal data-handling requirements it is the difference between a deletion you can prove and one you can only claim.

The layers of at-rest encryption stack β€” each is independent, like the tenancy walls:

πŸ’½

Full-disk

The volume itself is encrypted. Stops a physically stolen disk. Always on.

πŸ—ƒοΈ

Database TDE

Transparent data encryption β€” the DB files are ciphertext. Stops a leaked snapshot.

πŸ”’

Field-level

The most sensitive columns β€” prompts, documents, PII β€” encrypted per-tenant on top.

🧩Putting it together β€” one request, all six layers

Walk a single request through everything we built. This is the animated diagram at the top, narrated:

  1. Edge. The request arrives over TLS 1.3 (Layer 5). The load balancer terminates public TLS and re-originates the connection as for everything inward.
  2. Controller. The token is verified. Identity β€” tenant_id, workspace_id, role β€” is injected once into the request-scoped context (Layer 4). Nothing downstream has to be handed identity; it is ambient and enforced.
  3. Input . The request is scanned for , jailbreaks, and PII (Layer 3). If it fails, it never reaches a provider.
  4. Gateway + decision matrix. The gateway (Layer 1) asks the decision matrix (Layer 2) for an ordered provider list β€” hard-filtered by the tenant's residency and compliance policy, then ranked by capability, latency, and cost. It calls the primary provider through a vendor adapter.
  5. Egress. The call leaves over with a ; the private key that proves the gateway's identity was mounted into the pod and never touches the wire (Layer 5). If the provider is down, the gateway falls through to the next one on the list β€” no code change, no incident.
  6. Output . The response is scanned for PII leaks, policy violations, and before the user ever sees it (Layer 3).
  7. Persist + audit. Anything stored is written as AES-256-GCM ciphertext under the tenant's own data key, whose root key never leaves the (Layer 6). An append-only audit row β€” tenant, workspace, provider, model, token usage, cost β€” is written under the same injected identity.
πŸ’‘Notice what never appeared in application code

In that whole lifecycle, the application's feature code never imported a vendor SDK, never passed a by hand, never touched a private key, and never built an unscoped query. Every one of those was the architecture's job, not the developer's memory. That is the abstraction statement from the top, fully decomposed.

🎯

Leadership Takeaway

The reason to build it this way is not elegance β€” it is that the two most expensive failures in an AI system, vendor lock-in and a cross-tenant data leak, both come from the same root cause: a critical decision left to a developer to remember on every call. Make the provider a swappable adapter and make tenant identity ambient-and-enforced, and you have not added process β€” you have removed the two ways the system was most likely to hurt you. For a regulated or federal program, that is not a nice-to-have; it is the price of entry. And it is cheaper to build in from the first diagram than to retrofit under the pressure of an outage or an audit.

πŸŒ…If you remember nothing else

πŸ”‘The page in four sentences

Put one model gateway between your app and every AI vendor, so switching providers is a config change and adding one is a single adapter. Choose the provider per request with a decision matrix that hard-filters on residency and compliance before it ever optimizes for cost. Make tenant identity injected once at the controller and ambient everywhere below, backed by row-level security and per-tenant keys, so a cross-tenant leak needs three simultaneous failures, not one. Encrypt every hop (TLS, then , with private keys mounted and never sent) and every byte (AES-256-GCM, envelope-encrypted, root key sealed in a ) β€” so the vendor is a dependency you control, and the data is safe whether it is moving or still.

Published 2026-05-14 Β· Sam Muthu Β· sammuthu.com