<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ens on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/ens/</link><description>Recent content in Ens on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 15 Sep 2026 07:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/ens/index.xml" rel="self" type="application/rss+xml"/><item><title>deepagents on a cluster of your own: the SDK is free, the server is not, and what you have to build in between</title><link>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</link><pubDate>Tue, 15 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</guid><description>&lt;blockquote>
&lt;p>Opening of the agentic platform vertical. The previous articles dealt with the pieces separately: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the inference gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">its second door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">what a fleet of agents costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">isolation&lt;/a>. This one deals with what goes on top. Verified against &lt;code>deepagents&lt;/code> 0.7.14 and the LangGraph repository, commits of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>deepagents is not a runtime.&lt;/strong> It is a factory that assembles middleware and delegates to LangChain&amp;rsquo;s agent factory. It returns a compiled LangGraph graph and nothing else. The repository&amp;rsquo;s own documentation says so without decoration: it introduces no new runtime. That is good news for whoever deploys it, because it runs wherever Python runs.&lt;/p>
&lt;p>&lt;strong>The planning tool no longer exists.&lt;/strong> It disappeared in 0.7.0 as a breaking change, along with the state channel that backed it. Almost everything written about this harness still describes it as one of its three legs. Today you have to ask for it explicitly, and you have to ask for it in each subagent too.&lt;/p>
&lt;p>&lt;strong>With the default configuration, the agent&amp;rsquo;s files live inside the checkpoint.&lt;/strong> The state backend stores the full content, binaries in base64, in the same place the conversation is persisted. Changing that is one line and it is the first architecture decision of the deployment.&lt;/p>
&lt;p>&lt;strong>The database grows per loop step, not per turn.&lt;/strong> Four tables, one row per superstep, one per channel and version, another per task write. And of the four cleanup operations the base class declares, the Postgres implementation has only one: delete an entire thread. The other three raise not-implemented.&lt;/p>
&lt;p>&lt;strong>Pruning by hand can empty the history silently.&lt;/strong> The code itself warns that a partial delete breaks the delta chain and leaves the channels rebuilding empty without raising any error. The only safe pruning is by whole thread.&lt;/p>
&lt;p>&lt;strong>Tenant isolation is the thread identifier, and there is no owner check.&lt;/strong> Whoever can send a thread identifier reads that thread. The on-disk filesystem backend fixes its root when the agent is built, so it separates nobody. And the documentation&amp;rsquo;s example for computing the per-user namespace does not work outside the managed platform.&lt;/p>
&lt;p>&lt;strong>There is a configuration trap that leaves a short-window model uncompacted.&lt;/strong> If the model does not expose its window, the compaction threshold falls back to 170,000 tokens. Served on a vLLM with a 32,768 window, the agent blows up on context length long before it compacts. The failure is silent and is fixed by passing the profile by hand.&lt;/p>
&lt;p>&lt;strong>Observability without the platform does exist, and it goes through a package that is not obvious.&lt;/strong> LangChain&amp;rsquo;s core does not emit OpenTelemetry: zero references. The exporter lives inside the LangSmith SDK, and there is a mode that emits over OTLP only, without talking to the service. It is the road to a Langfuse of your own, and it is worth knowing where it passes.&lt;/p>
&lt;p>&lt;strong>And here is where the free part ends: the server.&lt;/strong> The SDK, the graph engine and the checkpointers are MIT. The server that publishes them over HTTP carries an Elastic 2.0 licence, asks for a commercial licence key for production, adds Redis to the stack and reports metadata to an external endpoint unless there is an isolation agreement. The development tool is explicitly for development.&lt;/p>
&lt;p>&lt;strong>What is left is a list of things you have to write.&lt;/strong> Per-thread authorisation, retention, a run queue, cancellation, clean draining and migrations. None of them is hard. All of them are work, and none of them appears in the twenty-line front-page example.&lt;/p>
&lt;h2 id="you-are-here-the-harness-arrives-after-the-model">You are here: the harness arrives after the model&lt;/h2>
&lt;p>The sequence repeats on every project. First you set up inference, which is the visible part. Then the gateway, because you need keys and budgets. Then observability, because nobody knows what is going on. And when all of that works, somebody asks why the assistant cannot do multi-step tasks, and the word agent enters the scene.&lt;/p>
&lt;p>At that point the temptation is to write the loop by hand, and for two weeks it looks like a good idea. The loop is easy. What is not easy is everything you discover afterwards: what happens when the context fills up, where the intermediate files are stored, how you resume a task that was left half done, how you ask a human for confirmation without losing state.&lt;/p>
&lt;p>An agent harness is exactly that: the packaged answer to those questions. And choosing one looks like a library decision, about the size of choosing an HTTP client. It is not. It decides where your users&amp;rsquo; state lives, which database grows, what can be isolated and what cannot, and in the case at hand, whether there is a commercial licence waiting at the end of the road.&lt;/p>
&lt;p>This article looks at &lt;code>deepagents&lt;/code> with those eyes. It is not a usage guide, and I do not assess whether its agents are any good. I look at what it forces you to build.&lt;/p>
&lt;h2 id="the-analogy-the-scaffolding-and-the-crane">The analogy: the scaffolding and the crane&lt;/h2>
&lt;p>A building site needs scaffolding and it needs a crane, and they are not the same thing even though both are metal structures surrounding the building.&lt;/p>
&lt;p>You put up the scaffolding yourself with parts you bought. It adapts to the façade, it can be extended, and the day the work finishes you take it down and put it away. Nobody charges you for using it once it is bought.&lt;/p>
&lt;p>The crane is another matter. You hire it, it comes with an operator, it has a serial number and you have to notify somebody when it goes up. Without a crane the work still moves forward, more slowly and with more people, but it moves forward.&lt;/p>
&lt;p>Here the scaffolding is the SDK and the graph engine, and it belongs to whoever buys it. The crane is the server that exposes the agent over HTTP, and that one comes with a contract. This article is about where exactly the line between the two sits, because you cannot see it in the brochure, and about how much work can be done without a crane.&lt;/p>
&lt;h2 id="what-deepagents-actually-is">What deepagents actually is&lt;/h2>
&lt;p>The first thing to get out of your head is that it is a system. It is a function.&lt;/p>
&lt;p>The main factory assembles a list of middleware and calls LangChain&amp;rsquo;s agent factory, which is what builds the graph. What it returns is a compiled graph, with an added configuration on top that raises the recursion limit to a very high number and adds an integration tag in the metadata. The repository&amp;rsquo;s own architecture document states it: no new runtime is introduced.&lt;/p>
&lt;p>Compiled with the default options, the graph has the nodes you would expect from a reasoning-and-action loop: start, a prior node that repairs incomplete tool calls, the model node, the tools node and the end. The conditional edges run from the model to tools, to the model itself or to the end. There is nothing more. Everything that looks like a system is middleware that adds nodes only when it declares hooks of its own.&lt;/p>
&lt;p>The tools that ship by default are the filesystem ones, plus the subagent delegation one: list, read, write, edit, delete, search by pattern, search by content, execute, and the task tool.&lt;/p>
&lt;p>The good consequence of this is portability. If something is a LangGraph graph and nothing else, it runs in any Python container, with the checkpointer you choose, against the model you choose. There is no hidden service behind it.&lt;/p>
&lt;h3 id="what-is-no-longer-there">What is no longer there&lt;/h3>
&lt;p>It deserves its own section because it contradicts almost everything published. The planning tool, the one that wrote a task list and kept it in a state channel, &lt;strong>was removed in 0.7.0&lt;/strong> as a breaking change declared in the changelog. The tool is gone, the channel is gone, and the prompt fragment that went with it is gone.&lt;/p>
&lt;p>It survives as LangChain middleware, and you have to ask for it by hand. With one detail that bites: you have to ask for it in each subagent too, because each subagent assembles its own stack. All that remains from the previous stage are archaeological leftovers, such as the state key that is still on the list of keys excluded when passing context to a subagent.&lt;/p>
&lt;p>The plan, today, rests on files. Which is consistent with the rest of the design, and is another reason to look closely at where those files live.&lt;/p>
&lt;h2 id="where-state-lives">Where state lives&lt;/h2>
&lt;p>This is the first architecture decision and the one that is most expensive to get wrong.&lt;/p>
&lt;p>The agent&amp;rsquo;s state has two large channels. Messages, with a reducer of its own that deduplicates by identifier and treats deletions as tombstones. And files, with its own reducer where a null value means deleted. Both use a channel type that writes deltas and stores a full snapshot every fifty updates, and the code comment explains why: to take checkpoint growth from quadratic to linear with respect to the number of messages.&lt;/p>
&lt;p>That channel type is marked beta in its own code, with a warning that the on-disk representation may change. Worth knowing before you rest a platform on top of it.&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>There is a common interface and several implementations. The ones that matter for deciding:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Where the files live&lt;/th>
&lt;th>Persistence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>State&lt;/td>
&lt;td>Inside the graph state, that is, inside the checkpoint&lt;/td>
&lt;td>Per thread, in the checkpointer&amp;rsquo;s database&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filesystem&lt;/td>
&lt;td>In a real directory anchored to a root&lt;/td>
&lt;td>Wherever you mount the volume&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Store&lt;/td>
&lt;td>In LangGraph&amp;rsquo;s store, with a namespace&lt;/td>
&lt;td>Crosses threads, with a time to live&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Composite&lt;/td>
&lt;td>Routed by path prefix among the above&lt;/td>
&lt;td>Mixed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default backend is the state one. That is: &lt;strong>by default, everything the agent writes ends up inside the checkpoint&lt;/strong>, and binaries go base64-encoded inside the same JSON. For a demo it is convenient, because you have nothing to set up. For a platform with real users it is a sizing decision taken by omission.&lt;/p>
&lt;p>There is a detail that rounds the matter off. The filesystem middleware automatically evicts large tool results to a file when they exceed a token threshold, so that they do not occupy the context window. It is a good mechanism. But with the state backend, evicting to a file means moving the content from one place in the checkpoint to another place in the same checkpoint. It leaves the model&amp;rsquo;s window and it does not leave the database.&lt;/p>
&lt;h3 id="what-grows-in-the-database">What grows in the database&lt;/h3>
&lt;p>The Postgres checkpointer creates four tables: one for migrations, one for checkpoints, one for blobs by channel and version, and one for writes by task. The primary keys all start with the thread identifier, and there is an index per table on that column.&lt;/p>
&lt;p>What you have to internalise is the rhythm. One checkpoint row is written &lt;strong>per engine superstep&lt;/strong>, not per conversation turn. A turn with four tool calls is several supersteps. None of that is recycled on its own.&lt;/p>
&lt;p>And here comes the uncomfortable part. The checkpointer&amp;rsquo;s base class declares four maintenance operations: delete a thread, delete by run, copy a thread and prune with a strategy. In the Postgres implementation &lt;strong>only the first is implemented&lt;/strong>. The other three inherit the not-implemented exception from the base class. Deleting a thread is three delete statements by identifier, and that is it.&lt;/p>
&lt;p>In other words: retention is yours to build. And with a care that the code itself documents and that is worth quoting in full, because it is the kind of warning you read after the incident. A naive pruning that removes intermediate checkpoints and their writes can cut the delta chain; the surviving checkpoint is rarely a snapshot point, so its channels would rebuild empty &lt;strong>without any error being raised&lt;/strong>.&lt;/p>
&lt;p>Translated into operations: the periodic sweep deletes whole threads and only whole threads. Never loose rows. And since the checkpoints table has no date column, to know which thread is old you have to keep count outside or rely on the checkpoint identifier being a time-sortable UUID.&lt;/p>
&lt;p>There is a variant of the checkpointer that stores only the last state and retains no history, which caps growth at the root. With one important reservation to verify before using it: it is not clear that it is compatible with the delta channels this harness uses by default for messages and files. Combining the two without checking is asking for a silent problem.&lt;/p>
&lt;h3 id="the-store-which-does-have-cleanup">The store, which does have cleanup&lt;/h3>
&lt;p>Curiously, the sibling piece is better resolved. The store has a configurable time to live, with refresh on read, skipping of expired entries and a sweep interval, and the sweep is a delete by expiry date.&lt;/p>
&lt;p>Two warnings. The sweeper is a thread inside the process, so with several replicas there are several sweepers competing for the same delete; it is idempotent, but it is free contention and you avoid it by switching it off and putting the delete in a scheduled cluster job. And the store&amp;rsquo;s semantic search requires the vector extension in the database, which constrains the managed Postgres image, which normally does not carry it.&lt;/p>
&lt;h2 id="the-isolation-that-does-not-exist">The isolation that does not exist&lt;/h2>
&lt;p>If the platform is going to have more than one tenant, this is the section that decides the deployment topology.&lt;/p>
&lt;p>The only thing separating one thread from another is the thread identifier. It is the first column of the primary key in all three tables. &lt;strong>There is no owner check anywhere in the checkpointer.&lt;/strong> Whoever manages to send a thread identifier in the call configuration reads that thread. Authorisation is the responsibility of the layer you write on top, and since that layer also has to be written, it is worth noting down now.&lt;/p>
&lt;p>By backend, the situation differs:&lt;/p>
&lt;ul>
&lt;li>The state one inherits thread isolation, which is enough if the HTTP layer validates that the user owns the thread.&lt;/li>
&lt;li>The store one computes the namespace per call, through a function that receives the runtime context. It is the correct option for multi-tenant.&lt;/li>
&lt;li>The filesystem one fixes its root &lt;strong>in the constructor&lt;/strong>, when the agent is built. There is no path in the code that recomputes it per request, per thread or per tenant. Sharing one deployment between tenants with this backend means everybody sees the same directory.&lt;/li>
&lt;/ul>
&lt;p>On that last point, do not confuse it with the protection that does exist. Virtual mode, which now comes enabled by default, blocks paths that escape the root. That protects against directory traversal. It does not protect against the neighbour. The project&amp;rsquo;s own threat document admits it: that mode exists to support the composite backend&amp;rsquo;s prefix routing, not as a security boundary.&lt;/p>
&lt;p>There is also a documentation trap worth flagging because it costs an afternoon. The example the documentation proposes for computing the per-user namespace reads the identity from a server-information field of the runtime context. That field is annotated in the LangGraph code as metadata injected by the managed server, and &lt;strong>null when running open-source LangGraph without managed deployments&lt;/strong>. On a cluster of your own with an HTTP layer of your own, that example fails. What you have to use is the context you fill in yourself, with the identifier that comes validated from the identity provider.&lt;/p>
&lt;p>And one more boundary that gets crossed without warning: the memory and skills middleware interpolate file content into the system prompt as is, without sanitising. If two tenants share a directory that serves as the source for that, the first writes instructions that the second&amp;rsquo;s agent executes. It is prompt injection through shared storage, and it is avoided with the same measure as everything above: do not share the filesystem backend between tenants.&lt;/p>
&lt;p>The topology conclusion is short. Real multi-tenant with files means &lt;strong>one deployment per tenant&lt;/strong>, with its namespace, its volume and its quota, or else the store backend with a namespace computed per request. There is no third way.&lt;/p>
&lt;h2 id="the-model-and-the-trap-you-need-to-know-about">The model, and the trap you need to know about&lt;/h2>
&lt;p>The default model is a specific commercial provider, and it is marked deprecated: passing an empty model warns and will stop working. You can inject any LangChain chat model, which is what anyone serving their own weights will do.&lt;/p>
&lt;p>With two integration details that are not obvious.&lt;/p>
&lt;p>The first. To point at an OpenAI-compatible endpoint, which is how a vLLM behind a gateway looks, you have to pass &lt;strong>the already-built instance&lt;/strong>, not the string with the provider prefix. With the string, a provider profile is applied that forces the use of the responses API, which a vLLM normally does not implement. Passing the built object applies no profile and the agent stays clean.&lt;/p>
&lt;p>The second is the trap, and in my view it is the most likely configuration failure in the whole deployment.&lt;/p>
&lt;p>The compaction middleware computes its thresholds from the model profile. If the model declares its input window, it compacts on reaching 85 % of the window and keeps 10 %. If it does not declare it, it falls back to a fixed value: &lt;strong>170,000 tokens&lt;/strong>.&lt;/p>
&lt;p>The chain that leads to not declaring it is the usual one in a deployment of your own. The OpenAI client resolves the profile by looking the model name up in a static table of the provider&amp;rsquo;s models. A model served as &lt;code>qwen3-30b-a3b&lt;/code> is not in that table, so the profile comes out empty, and empty becomes null. The method that assigns it swallows any exception, so there is no warning.&lt;/p>
&lt;p>Result: a vLLM with a 32,768-token window will not compact until 170,000. The server will return a context-length error long before that, and it will do so intermittently, only when conversations get long. There is a safety net that catches the error and trims, but that is reactive recovery after a failed call, not planning.&lt;/p>
&lt;p>The fix is one line, because the profile is a public field and only fills itself in when it is empty: you pass it when building the model, with a value below the real window to leave room for generation. And it deserves an &lt;code>assert&lt;/code> at start-up, because it is the kind of thing nobody looks at until it fails.&lt;/p>
&lt;p>A related sizing note. With the default thresholds and a 32,768 window, compaction would trigger at 27,852 tokens, while eviction of large tool results is fixed at 20,000. That is, a single tool result can occupy almost the whole budget before anything evicts it. With 128,000 windows the defaults are proportionate; below 64,000 you have to lower those two thresholds by hand.&lt;/p>
&lt;h2 id="the-tools-and-the-mcp-that-is-not-where-it-seems">The tools, and the MCP that is not where it seems&lt;/h2>
&lt;p>This harness has its filesystem tools and its delegation tool, and the rest are passed as an ordinary list of LangChain tools.&lt;/p>
&lt;p>The fact that matters to anyone with an MCP gateway in place: &lt;strong>the SDK does not integrate MCP&lt;/strong>. There is no reference to the protocol in the package, nor any dependency on adapters. The integration lives in the command-line tool that accompanies the project, which does depend on the MCP adapters for LangChain, resolves configurations in the style of the desktop clients, and turns each remote tool into a LangChain tool that then goes through the ordinary list.&lt;/p>
&lt;p>So the path exists and is proven, but you have to walk it yourself: load the gateway&amp;rsquo;s tools with the adapters and pass them in the list. Which, incidentally, fits well with &lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">what we already know about the catalogue&lt;/a>: the credential-based filtering the gateway does is applied in the listing, so each agent receives the subset that belongs to it without the harness having to know anything.&lt;/p>
&lt;h3 id="the-subagents">The subagents&lt;/h3>
&lt;p>They are declared in three ways and invoked with a task tool that takes a description and a type. What you need to know to design with them:&lt;/p>
&lt;p>The subagent receives the parent&amp;rsquo;s state &lt;strong>except&lt;/strong> the messages and a few private keys, and it starts with a single human message which is the task description. Files &lt;strong>are&lt;/strong> inherited, and it shares the same backend object, so parent and child work on the same logical filesystem. The result comes back as a tool message with the structured response or the child&amp;rsquo;s last text.&lt;/p>
&lt;p>Depth is effectively one level. Verified by compiling the graph: the stack assembled for a declarative subagent does not include the subagent middleware, so the child does not have the task tool and cannot delegate in turn.&lt;/p>
&lt;p>And there is no concurrency limit of its own. The parallelism is that of the engine&amp;rsquo;s parallel tool calls, and the task tool&amp;rsquo;s prompt explicitly invites launching them in parallel. Anyone with a per-tenant GPU quota will want to put the brake where it belongs, because it is not here.&lt;/p>
&lt;h2 id="code-execution">Code execution&lt;/h2>
&lt;p>The sandbox protocol is well defined and the packaged implementations are nearly all paid services: four commercial providers, plus an embedded JavaScript interpreter and a sandbox from the platform vendor itself.&lt;/p>
&lt;p>That leaves a local shell backend that is worth looking at closely before considering it. It runs the command with the system shell, and its own docstring lists what it does not do: no isolation, no process separation, no resource limits, and it lists production and multi-tenant environments as inappropriate use cases. It also has an option to inherit the process environment which, enabled, hands the model every variable, including the gateway credentials and the database connection string. It comes disabled, and it is an easy foot to shoot.&lt;/p>
&lt;p>The reasonable way out on a cluster of your own is to implement the protocol yourself, and it is smaller than it looks. The sandbox base class requires four things: an identifier, execute, upload files and download files. Everything else, list, read, write, edit, delete and search, is built by the base class on top of execute. Looking at one of the commercial implementations, it is on the order of a hundred and fifty lines.&lt;/p>
&lt;p>One image requirement that constrains the Dockerfile and is worth knowing beforehand: the base class helpers inject small encoded Python scripts to resolve searches and checks, so the sandbox image &lt;strong>needs Python and a POSIX shell&lt;/strong> or half the calls fail.&lt;/p>
&lt;p>On how to implement it, the shape that fits a Kubernetes of your own is an ephemeral container per session with a bounded time to live, resource limits, no service account token mounted and a network policy that only allows egress towards the inference gateway. Execute is resolved against the cluster&amp;rsquo;s exec API, and upload and download through the same channel. The alternative with less code is an isolated runtime class for the agent pod, one pod per tenant, and the shell backend inside; you lose multi-tenancy in a single pod and you have nothing to write.&lt;/p>
&lt;h2 id="observability-and-the-package-it-goes-through">Observability, and the package it goes through&lt;/h2>
&lt;p>Here there is a fact that surprises and that is worth being clear about before designing the trace pipeline.&lt;/p>
&lt;p>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry.&lt;/strong> Zero references in the whole package. Its native tracer talks to the vendor&amp;rsquo;s observability service and that is that.&lt;/p>
&lt;p>The OTLP exporter does exist, but it lives inside that service&amp;rsquo;s SDK. And there is the good news: that SDK has a tracing mode that emits &lt;strong>only&lt;/strong> over OTLP, without talking to the service, reading the destination and headers from OpenTelemetry&amp;rsquo;s standard environment variables. The transport is HTTP with protobuf, which is exactly what a self-hosted Langfuse accepts on its ingestion route.&lt;/p>
&lt;p>That is: to take the agent&amp;rsquo;s traces to your own observability you have to install the vendor&amp;rsquo;s package and ask it not to talk to the vendor. It works, it is supported and it is documented, but it is a dependency you have to declare in the supply chain analysis, not a configuration detail. And if the OpenTelemetry packages are missing, it emits a warning and silently stops tracing, so start-up should check for it.&lt;/p>
&lt;p>There is the alternative of the callback handler that Langfuse itself publishes, with fewer pieces. The practical difference is correlation: over the OTLP route the agent&amp;rsquo;s spans share a trace identifier with the gateway&amp;rsquo;s and the inference engine&amp;rsquo;s, and you see a whole request end to end. Over the callback route, you do not. For anyone who already has a collector deployed, the OTLP route is the one that pays.&lt;/p>
&lt;h2 id="the-server-here-is-where-the-free-part-ends">The server: here is where the free part ends&lt;/h2>
&lt;p>Everything above is an architecture decision. This is a licence decision, and it is the one that gives the article its title.&lt;/p>
&lt;p>The harness SDK is MIT. The graph engine is MIT. The memory, SQLite and Postgres checkpointers are MIT. All of that runs anywhere, with no key, no registration and without calling anybody.&lt;/p>
&lt;p>The server that exposes a graph over HTTP is another matter. The package that implements it carries an &lt;strong>Elastic 2.0 licence&lt;/strong>, whose relevant clauses forbid offering the software to third parties as a managed service and forbid modifying or circumventing the licence key functionality. The command-line tool&amp;rsquo;s own command prints the conditions: for local development it asks for a service API key, and &lt;strong>for production use it asks for a licence key&lt;/strong> in an environment variable.&lt;/p>
&lt;p>There is also usage reporting. The server code contains a beacon endpoint to which it sends metadata, with a header carrying the licence key. The self-hosting documentation confirms it from the other side, listing among the requirements network egress to that domain for licence verification and usage reporting &lt;strong>if not running in isolated mode&lt;/strong>. Isolated mode is a contractual variant, not a box you tick.&lt;/p>
&lt;p>And it drags infrastructure along: besides Postgres, it asks for Redis.&lt;/p>
&lt;p>The development tool, which is the one that appears in every tutorial, describes itself as development mode with hot reload and an in-memory server. It is not a production server and it does not claim to be.&lt;/p>
&lt;p>None of this is a reproach. It is a legitimate and fairly common business model: permissive core, commercial control plane. What is not legitimate is finding out late. Anyone building a platform with a sovereignty requirement, with no dependencies on external services and with the argument that the whole stack is open, has to know that &lt;strong>the piece that turns the graph into a service does not meet that requirement&lt;/strong>.&lt;/p>
&lt;h2 id="what-you-have-to-build">What you have to build&lt;/h2>
&lt;p>The way out is to write the HTTP layer yourself, and the good news is that it is boring. A service that compiles the graph once with the checkpointer pointing at the database, and exposes an endpoint that validates the identity provider&amp;rsquo;s token, derives the thread identifier and the tenant context, and invokes the graph. Event streaming if needed.&lt;/p>
&lt;p>What you additionally have to write, and which the commercial server gave you ready-made:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-thread authorisation.&lt;/strong> The engine does not have it. It is the most important and the easiest to forget, because nothing fails if it is missing.&lt;/li>
&lt;li>&lt;strong>Retention and deletion.&lt;/strong> A scheduled job that deletes whole threads, never loose rows, for the reasons in the state section.&lt;/li>
&lt;li>&lt;strong>Migrations.&lt;/strong> Schema setup has to be called explicitly and needs data-definition permissions, so it goes in a separate job with a different role from the runtime one. And since it creates indexes concurrently, it cannot go through a connection pooler in transaction mode; point it at the direct write service.&lt;/li>
&lt;li>&lt;strong>Health probes and draining.&lt;/strong> The engine has a cooperative draining mechanism, but wiring it to the container&amp;rsquo;s termination signal is your own work.&lt;/li>
&lt;li>&lt;strong>Run queue and cancellation&lt;/strong>, if you need long background runs. This is where the absence is most felt, and the reasonable answer is a queue on the same database rather than another system.&lt;/li>
&lt;li>&lt;strong>Durability decided deliberately.&lt;/strong> There are three modes: persist before the next step, persist in parallel, or persist only on exit. The last one is no good on Kubernetes: if the pod dies, the whole turn is lost.&lt;/li>
&lt;/ul>
&lt;p>On that last point it is worth being explicit, because it affects tool design. The engine stores each task&amp;rsquo;s writes as soon as it finishes, and on resume it reapplies those that were already there and does not repeat them. A tool that finished is not run again. A tool &lt;strong>that was in flight when the pod died&lt;/strong> is, because its write never landed. The semantics are at-least-once, so every tool with an external effect has to be idempotent or carry an idempotency key. There is no exactly-once.&lt;/p>
&lt;p>And the same applies to interrupts for human confirmation: on resume, the node is &lt;strong>re-executed in full&lt;/strong>, so any side effect that sat before the interrupt inside the same node is repeated. The rule is that the interrupt goes first.&lt;/p>
&lt;h2 id="how-it-fits-with-activity-logging">How it fits with activity logging&lt;/h2>
&lt;p>Two things about this setup touch compliance head on, and both come out of earlier sections.&lt;/p>
&lt;p>The first is that the checkpoint is, de facto, a repository of personal data. It contains the whole conversation and, with the default backend, the files the agent has written. If the platform serves identifiable people, that database needs encryption, retention of its own and separate access control, exactly like the gateway&amp;rsquo;s spend table. The difference is that here there is no redaction switch that helps: the state is the state.&lt;/p>
&lt;p>The second is traceability. An agent that delegates to subagents and executes tools produces a chain of actions that you have to be able to reconstruct, and the only place where that chain is complete is the trace. Traces going out over OTLP to your own observability stops being a technical preference and becomes the logging mechanism. It is worth designing it that way from the start, and not adding it afterwards.&lt;/p>
&lt;p>As always in this series, the annex&amp;rsquo;s specific codes have to be checked against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls article&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the filesystem backend before the first demo, because the default one puts the content in the database.&lt;/li>
&lt;li>Pass the model profile by hand with the real window, and check it at start-up.&lt;/li>
&lt;li>Lower the eviction thresholds if the model&amp;rsquo;s window is below 64,000 tokens.&lt;/li>
&lt;li>Build the model as an instance, never as a string with a provider prefix, if there is an OpenAI-compatible endpoint behind it.&lt;/li>
&lt;li>Write per-thread authorisation before opening the service to more than one user.&lt;/li>
&lt;li>One deployment per tenant if you use the filesystem backend; a namespace computed per request if you use the store one.&lt;/li>
&lt;li>Compute that namespace from your own context, not from the server-information field, which is null outside the managed platform.&lt;/li>
&lt;li>A scheduled retention job that deletes whole threads and only whole threads.&lt;/li>
&lt;li>Schema migrations in a separate job, with a role of their own and against the direct write service.&lt;/li>
&lt;li>Synchronous durability, and tools with external effects made idempotent.&lt;/li>
&lt;li>Traces over OTLP with the mode that does not talk to the vendor&amp;rsquo;s service, and a start-up check that the packages are there.&lt;/li>
&lt;li>Put a concurrency limit on subagents if there is a GPU quota involved.&lt;/li>
&lt;li>If you need code execution, implement the sandbox protocol against an ephemeral container; the local shell backend is advised against by its own documentation.&lt;/li>
&lt;li>Note in the supply chain analysis the hard dependencies on commercial providers that the package drags in even when unused.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The planning tool was removed in 0.7.0.&lt;/strong> You have to add it by hand, and in each subagent too.&lt;/li>
&lt;li>&lt;strong>The default backend stores files inside the checkpoint&lt;/strong>, binaries included in base64.&lt;/li>
&lt;li>&lt;strong>Evicting a large result to a file does not get it out of the database&lt;/strong> with that backend, only out of the context window.&lt;/li>
&lt;li>&lt;strong>One checkpoint is written per superstep&lt;/strong>, not per turn.&lt;/li>
&lt;li>&lt;strong>Of the four declared cleanup operations, only delete-a-thread exists in Postgres.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A partial pruning can empty the history without raising any error.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The delta channel is in beta&lt;/strong> and its on-disk representation may change.&lt;/li>
&lt;li>&lt;strong>There is no owner check in the checkpointer.&lt;/strong> The thread identifier is not a credential.&lt;/li>
&lt;li>&lt;strong>The filesystem backend&amp;rsquo;s root is fixed when the agent is built&lt;/strong> and it separates no tenants.&lt;/li>
&lt;li>&lt;strong>The documentation&amp;rsquo;s example for the per-user namespace does not work outside the managed platform.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The filesystem middleware and the subagent middleware cannot be removed.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>If the model does not declare its window, compaction does not trigger until 170,000 tokens&lt;/strong>, silently.&lt;/li>
&lt;li>&lt;strong>Passing the model as a string with a provider prefix forces an API that a vLLM does not implement.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The SDK does not integrate MCP&lt;/strong>; the integration lives in the command-line tool.&lt;/li>
&lt;li>&lt;strong>Subagents do not delegate&lt;/strong>: depth is one level.&lt;/li>
&lt;li>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry&lt;/strong>; the exporter lives in the vendor&amp;rsquo;s service SDK.&lt;/li>
&lt;li>&lt;strong>The HTTP server carries an Elastic 2.0 licence&lt;/strong>, asks for a licence key for production and reports usage unless there is an isolation agreement.&lt;/li>
&lt;li>&lt;strong>The development tool is for development&lt;/strong>, with an in-memory server.&lt;/li>
&lt;li>&lt;strong>Resumption is at-least-once&lt;/strong> for in-flight tools.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The question this article started with was whether &lt;code>deepagents&lt;/code> fits into a platform of your own. The answer is that it does, and that it fits better than I expected, precisely because it is less than it appears. A middleware stack on top of a graph is easy to host, easy to understand and easy to replace. What does not fit is the piece next to it.&lt;/p>
&lt;p>That asymmetry is the pattern I have been seeing all year across the ecosystem, and it deserves naming. The core is published under a permissive licence because the core is where you compete for adoption. The control plane is published under a restrictive licence because the control plane is where you charge. It happened with the gateways, it is happening with observability, and it happens here. Anyone building a sovereign platform cannot assess a whole project by the licence of its main repository: you have to look package by package, and look at which of them is the one you are going to need the day the thing has users.&lt;/p>
&lt;p>The good part is that the bill, in this case, is paid in work and not in money. What you have to build to do without the commercial server is an HTTP service with no mystery to it, a retention job, a migration job and an authorisation layer. None of that is research. All of it is a week of an engineer who knows what they are doing, and in exchange you are left with a system that deploys the same way in a school as in a classified datacenter, with no internet egress and with nobody counting runs on the other side.&lt;/p>
&lt;p>What I do recommend is making that list before the first demo and not after. Because the front-page example works in twenty lines, and the twenty lines have the default backend, the model without a profile and no authorisation at all.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing an inference gateway&lt;/a> — the piece underneath, and the same licence pattern seen somewhere else.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — where the tools this harness consumes come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">MCP tool priority&lt;/a> — how it is decided which subset of the catalogue each agent sees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the real load a tool loop generates on the gateway and the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a> — the network isolation this article takes as necessary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of agents&lt;/a> — durable execution and what a loop that fails halfway costs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where logging and retention fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the destination of the OTLP traces the article talks about.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, código y documento de arquitectura: &lt;a href="https://github.com/langchain-ai/deepagents">https://github.com/langchain-ai/deepagents&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, registro de cambios de la rama del SDK (eliminación de la herramienta de planificación en la 0.7.0): &lt;a href="https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md">https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/langgraph&lt;/code>, motor, canales y checkpointers: &lt;a href="https://github.com/langchain-ai/langgraph">https://github.com/langchain-ai/langgraph&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint-postgres&lt;/code>, esquema de tablas y operaciones de mantenimiento: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint&lt;/code>, clase base del checkpointer y aviso sobre poda con canales de deltas: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint&lt;/a>.&lt;/li>
&lt;li>LangChain, despliegue de servidor autónomo (requisitos de licencia, Postgres, Redis y salida de red): &lt;a href="https://docs.langchain.com/langsmith/deploy-standalone-server">https://docs.langchain.com/langsmith/deploy-standalone-server&lt;/a>.&lt;/li>
&lt;li>LangChain, trazado con OpenTelemetry: &lt;a href="https://docs.langchain.com/langsmith/trace-with-opentelemetry">https://docs.langchain.com/langsmith/trace-with-opentelemetry&lt;/a>.&lt;/li>
&lt;li>Elastic License 2.0: &lt;a href="https://www.elastic.co/licensing/elastic-license">https://www.elastic.co/licensing/elastic-license&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración nativa OpenTelemetry (ruta de ingesta y autenticación): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración con LangChain por retrollamadas: &lt;a href="https://langfuse.com/integrations/frameworks/langchain">https://langfuse.com/integrations/frameworks/langchain&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/helm&lt;/code>, chart del servidor: &lt;a href="https://github.com/langchain-ai/helm">https://github.com/langchain-ai/helm&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Making the model prefer our tools: the priority that exists in no layer, the two primitives that do, and the `_meta` that crosses the gateway</title><link>https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/</link><pubDate>Tue, 15 Sep 2026 03:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, which treated the tool catalogue as cost and as surface. This one treats the next question, the one that arrives in the meeting afterwards: so then, how do I get the model to use mine. Verified against LiteLLM 1.102.0 and MCP specification &lt;code>2026-07-28&lt;/code>, with a test of our own run on 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Tool priority does not exist as a concept in any layer of the stack.&lt;/strong> Not in the specification, not in the clients, not in the providers&amp;rsquo; APIs. What exist are two distinct primitives: removing the alternative from the catalogue, and leaving it behind a search step. Everything else is persuasion, and the model ignores it when it suits.&lt;/p>
&lt;p>&lt;strong>The specification has no ranking field.&lt;/strong> The &lt;code>Tool&lt;/code> object is name, title, icons, description, schemas, five annotations that are unreliable hints by their own declaration, and &lt;code>_meta&lt;/code>. There is a trap I see repeated: MCP does define a numeric &lt;code>priority&lt;/code>, but in another structure, the one that applies to content blocks, resources and prompts. It does not govern tool selection.&lt;/p>
&lt;p>&lt;strong>The only filtering the specification blesses is by credential.&lt;/strong> The current revision says the set of tools must not vary by connection, and that it may vary according to the authorisation presented. That is exactly what a gateway with per-key lists does.&lt;/p>
&lt;p>&lt;strong>The client is the one that decides, and only one of them has anything resembling priority.&lt;/strong> Claude Code lets you exempt a server from deferral with &lt;code>alwaysLoad&lt;/code>, so that its tools load in full at start-up while the rest require a prior search. It is not called priority and it works as such.&lt;/p>
&lt;p>&lt;strong>We proved that the per-tool mark crosses the gateway.&lt;/strong> We set up an MCP server with one marked tool, served it through LiteLLM 1.102.0 and captured the wire: the &lt;code>_meta&lt;/code> arrives whole and with the correct key. The chain is clean on purpose, with a comment in the code that says so.&lt;/p>
&lt;p>&lt;strong>And it is lost on exactly three routes, which are the gateway&amp;rsquo;s three catalogue-trimming modes.&lt;/strong> Proxy mode, tool search driven by key permissions, and the REST listing. In other words: the per-tool mark and LiteLLM&amp;rsquo;s catalogue trimming are incompatible. You have to choose one.&lt;/p>
&lt;p>&lt;strong>There is a library trap that makes the field disappear silently.&lt;/strong> The SDK&amp;rsquo;s &lt;code>Tool&lt;/code> model declares the alias without allowing population by field name, so building it with the field name puts the dictionary in the extras and serialises it under the wrong key. There is no error. The field simply does not arrive.&lt;/p>
&lt;p>&lt;strong>The cheap lever is the description, not the name.&lt;/strong> Swapping descriptions shifts the selection distribution substantially; changing only the name has minimal and inconsistent effects. And the gateway&amp;rsquo;s overrides really do replace what is sent to the client.&lt;/p>
&lt;p>&lt;strong>Reordering the catalogue is a weak lever.&lt;/strong> The model is already attending to the correct tool eighty per cent of the time when it fails. Interventions on prompt order repair at most 23 % of the failures.&lt;/p>
&lt;p>&lt;strong>And the asymmetry you build is attack surface.&lt;/strong> Whoever controls the order controls the selection: with injection rates of 1.2 % you can take over the head of a tool ranking between 91 % and 97 % of the time.&lt;/p>
&lt;h2 id="you-are-here-the-question-that-arrives-after-the-third-server">You are here: the question that arrives after the third server&lt;/h2>
&lt;p>The previous article closed with a decision ladder for the catalogue: trim, rewrite, split by route, and use progressive disclosure when the catalogue grows. That ladder answers how much catalogue to show.&lt;/p>
&lt;p>The question that arrives afterwards is a different one, and it is more uncomfortable. A real agent has native tools from the client hosting it, it has the in-house gateway with the house servers, and it has two or three third-party servers that somebody connected. All of them compete. And what you want is not to show less catalogue: you want that, faced with a query three tools could serve, yours wins. The audited one, the one with access control in place, the one that leaves a trace.&lt;/p>
&lt;p>The answer everybody gives is to write a rule in the agent&amp;rsquo;s instruction file politely asking it to prefer the in-house server. There is even a rule published in a template marketplace that does exactly that. It is text in the prompt and the model follows it when it feels like it.&lt;/p>
&lt;p>This article is what lies underneath.&lt;/p>
&lt;h2 id="the-analogy-the-counter-with-two-trays">The analogy: the counter with two trays&lt;/h2>
&lt;p>Let us go back to the switchboard operator from the previous article, the one who also hands out keys. Now the building has three key providers: the house cabinet, the maintenance contractor&amp;rsquo;s, and the cleaning service&amp;rsquo;s. All three have a key to the store room and all three open it.&lt;/p>
&lt;p>The head of security wants the house one used, because it is the one with a log. And he discovers he can do three things, not one more.&lt;/p>
&lt;p>He can take the other two off the counter. It always works and it annoys whoever needed them.&lt;/p>
&lt;p>He can leave his own in the front tray and the other two in a drawer, so that to take one you have to ask. It works almost always and annoys nobody.&lt;/p>
&lt;p>And he can change the label on his key so that it describes better when it is the right one. It works sometimes, it is the cheapest, and it is the only one that also protects against the contractor changing his label without warning.&lt;/p>
&lt;p>What he cannot do is put a number from one to ten on each key and trust the operator to respect it. That number does not exist. The rest of the article is why it does not exist and what to do instead.&lt;/p>
&lt;h2 id="what-the-specification-says-nothing">What the specification says: nothing&lt;/h2>
&lt;p>The current revision is &lt;code>2026-07-28&lt;/code>. Read against the source schema, the &lt;code>Tool&lt;/code> object has name, title, icons, description, input schema, output schema, annotations and &lt;code>_meta&lt;/code>. There is no field for priority, weight, rank, cost, group or tag.&lt;/p>
&lt;p>The annotations are exactly five: title, and the read-only, destructive, idempotent and open-world hints. The schema itself warns that all of them are hints and that a client should not make tool-use decisions based on them when they come from untrusted servers.&lt;/p>
&lt;h3 id="the-priority-that-exists-and-is-no-use">The &lt;code>priority&lt;/code> that exists and is no use&lt;/h3>
&lt;p>Here is the mistake I see repeated most. MCP &lt;strong>does&lt;/strong> define a &lt;code>priority&lt;/code> field, numeric between zero and one. It is in the annotations interface that applies to content blocks, to resources and to prompts, alongside the audience and the last-modified date.&lt;/p>
&lt;p>It is not in &lt;code>Tool&lt;/code>. It has no relation whatsoever to which tool the model picks. Anyone who finds it by searching the word in the schema and concludes that tool priority is standardised has the wrong structure.&lt;/p>
&lt;p>The only precedence the specification defines for a tool is one of presentation: to display the name, first the title, then the annotations title, then the name.&lt;/p>
&lt;h3 id="the-only-blessed-filtering-is-by-credential">The only blessed filtering is by credential&lt;/h3>
&lt;p>There is a new sentence in the current revision that is genuinely useful and that goes unnoticed. The set of tools must not vary by connection nor as a side effect of other requests, and it may vary according to the authorisation presented in the request.&lt;/p>
&lt;p>Translated: segmenting the catalogue by key, by team or by token is the legitimate way to do it. Segmenting it by session state stopped being legitimate when the July revision removed protocol sessions.&lt;/p>
&lt;h3 id="discovery-does-not-discover-tools">Discovery does not discover tools&lt;/h3>
&lt;p>&lt;code>server/discover&lt;/code>, which the current revision adds and which servers have to implement, does not return tools. It returns supported versions, capabilities, server instructions, identity and the cache freshness fields. It accepts neither filters nor a cursor.&lt;/p>
&lt;p>Listing is still asking for everything and paginating. The listing request accepts only a cursor: there is no query, no filter, no limit, no groups, no tags.&lt;/p>
&lt;p>And on collisions between servers, the specification explicitly and reasonedly washes its hands: clients or proxies that aggregate tools from several servers will encounter collisions and should implement a disambiguation strategy, for example prefixing. And it adds that the server name is not guaranteed to be unique and should not be used to disambiguate. There is no precedence rule between servers. There is not going to be one soon.&lt;/p>
&lt;h3 id="what-there-is-in-proposals">What there is in proposals&lt;/h3>
&lt;p>Nothing accepted. The official proposals index only lists those in a final state, and none of them deals with priority, groups or search. In draft there is one asking for a free-text query in the listing and a filtering capability; another on groups and tags that ended up superseded; and one carrying the word priority but which is per-tool model routing, which is another thing.&lt;/p>
&lt;p>The interest group exploring the grouping of primitives declares in its own minutes that it is not going to pick a canonical pattern soon, and the documents in its repository are empty. The year&amp;rsquo;s roadmap does not mention the problem.&lt;/p>
&lt;h2 id="the-layer-that-decides-the-client">The layer that decides: the client&lt;/h2>
&lt;p>If the specification gives you nothing, what is left is whatever each client has built on its own. And here there is a clear winner.&lt;/p>
&lt;h3 id="claude-code">Claude Code&lt;/h3>
&lt;p>Tool search is on by default, and what it does is defer: MCP tools are not loaded in full at start-up, the model sees just enough and discovers them when it searches. It requires a model that supports tool reference blocks, that is Claude Sonnet 4.5, Haiku 4.5, Opus 4.5 and later.&lt;/p>
&lt;p>On top of that, the lever:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;mcpServers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;gateway-casa&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://litellm.interna.svc/mcp&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The documentation describes it bluntly: if a server&amp;rsquo;s tools must always be visible without a search step, you set &lt;code>alwaysLoad&lt;/code> to true and all of that server&amp;rsquo;s tools are loaded into context at the start of the session, regardless of the global search setting. And it recommends using it for a small number of tools that the model needs on every turn, because each tool loaded up front consumes context.&lt;/p>
&lt;p>That is visibility asymmetry, and it works as priority even if it is not called that. Ours in front, the rest behind one step.&lt;/p>
&lt;p>There is an operational consequence that the documentation mentions and that matters more in a gateway than in an ordinary server: setting &lt;code>alwaysLoad&lt;/code> makes start-up &lt;strong>wait&lt;/strong> for that server to return its tools, capped by the standard five-second connection timeout. A gateway that aggregates eight upstream servers and does not cache the listing, as is the case, lists live against all eight every time. If one of them is slow, the session&amp;rsquo;s start-up feels it.&lt;/p>
&lt;p>There is also the fine-grained variant. An MCP server can mark individual tools as always loaded by including &lt;code>&amp;quot;anthropic/alwaysLoad&amp;quot;: true&lt;/code> in the tool&amp;rsquo;s &lt;code>_meta&lt;/code> object, with the same effect for that tool alone. That would let you load three tools from our gateway up front and leave the other forty from the same gateway deferred. It is the lever that matters, and it is the one that motivated the test in the next section.&lt;/p>
&lt;p>The rest of this client&amp;rsquo;s arsenal is pure exclusion, and it is worth knowing one detail that decides whether it works or not. A deny rule with the bare tool name &lt;strong>removes it from the model&amp;rsquo;s context&lt;/strong>; with parentheses, it does not. The documentation puts it in a table: plain &lt;code>WebFetch&lt;/code> removes the tool and the model cannot search at all, whereas &lt;code>WebFetch(domain:*)&lt;/code> keeps the tool and rejects every request. The difference is not cosmetic: in the second case the model still sees the tool, still tries it and still spends the context its definition occupies.&lt;/p>
&lt;p>The &lt;code>--tools&lt;/code> flag is the scalpel: it accepts the empty string to disable all the built-ins, and it does not affect MCP tools. That is, &lt;code>--tools &amp;quot;&amp;quot;&lt;/code> leaves the model with only what the gateway serves. It is a command-line flag and has no equivalent as a settings-file key; the functional equivalent there is deny rules.&lt;/p>
&lt;h3 id="the-other-clients">The other clients&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Client&lt;/th>
&lt;th>Disable the native ones&lt;/th>
&lt;th>Filter the MCP ones&lt;/th>
&lt;th>Priority&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude Code&lt;/td>
&lt;td>&lt;code>--tools &amp;quot;&amp;quot;&lt;/code>, deny by bare name&lt;/td>
&lt;td>deny by pattern, subagents&lt;/td>
&lt;td>&lt;code>alwaysLoad&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Codex CLI&lt;/td>
&lt;td>&lt;code>features.shell_tool&lt;/code> to false&lt;/td>
&lt;td>&lt;code>enabled_tools&lt;/code> and &lt;code>disabled_tools&lt;/code> per server&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gemini CLI&lt;/td>
&lt;td>&lt;code>tools.core&lt;/code> as an allowlist&lt;/td>
&lt;td>&lt;code>includeTools&lt;/code> and &lt;code>excludeTools&lt;/code> per server&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Copilot in VS Code&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>picker and tool sets&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cursor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>per-server switches&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two notes. Copilot cuts off at 128 tools per request and its virtualisation threshold does not go above that; its embedding-based routing is internal and has no configuration surface. And Cursor&amp;rsquo;s rules are purely indicative: they are text prepended to the context.&lt;/p>
&lt;h2 id="the-api-layer">The API layer&lt;/h2>
&lt;p>If the client is in-house, the margin is wider, though narrower than it looks.&lt;/p>
&lt;p>&lt;strong>Forcing a set is not possible.&lt;/strong> In Anthropic&amp;rsquo;s case, tool choice accepts auto, any, a specific one by name, or none, and the documentation says explicitly that pointing at a set of MCP tools or at a member is not supported. In vLLM it is the same: a specific one, all of them or none. There is no way to say &amp;ldquo;any of mine from the gateway&amp;rdquo;. The proposal that would introduce grammars restricting tool names to the request&amp;rsquo;s set has been open for months and is not merged.&lt;/p>
&lt;p>&lt;strong>Filtering the catalogue is possible, in all three of the big ones.&lt;/strong> In Anthropic, with an MCP tool-set block where the default configuration disables and specific ones are enabled. In OpenAI, with the allowed tools list inside the MCP block itself, which the documentation justifies on latency and cost grounds, avoiding the model seeing unnecessary definitions. And in Gemini, the remote MCP server configuration accepts restricting which of the server&amp;rsquo;s tools the agent may call.&lt;/p>
&lt;p>It is worth not confusing two things that are named almost identically in OpenAI: the allowed tools list &lt;strong>inside&lt;/strong> the MCP block filters the catalogue, and that is what matters here; the identically named tool-choice type is another thing, it is a selection restriction, and there is evidence that it does not combine well with hosted tools, without the current documentation recording it. If anyone depends on that, let them test it against their model: it fails as a request error, so it shows up on a cold start.&lt;/p>
&lt;p>&lt;strong>And there is a 2026 lever that almost nobody is using.&lt;/strong> Anthropic has in beta a mechanism for mid-conversation tool changes, with add and remove blocks that accept referencing an entire MCP set. The reason it exists is in its documentation: the tools array sits even earlier in the chunked prefix than the system field, so editing it invalidates the cache for the whole conversation; by declaring the full set at the start and using the blocks, the array never changes and the cached prefix stays intact.&lt;/p>
&lt;p>Applied to our case: you can remove a rival server&amp;rsquo;s set hot, mid-session, without paying for the whole prefix. It is not available on every model.&lt;/p>
&lt;h2 id="the-test-the-_meta-crosses-the-gateway">The test: the &lt;code>_meta&lt;/code> crosses the gateway&lt;/h2>
&lt;p>The fine-grained variant of Claude Code&amp;rsquo;s lever depends on a question no documentation answers: whether a gateway that aggregates upstream servers propagates each tool&amp;rsquo;s &lt;code>_meta&lt;/code> all the way to the client, or loses it along the way.&lt;/p>
&lt;p>We checked.&lt;/p>
&lt;h3 id="the-setup">The setup&lt;/h3>
&lt;p>A minimal MCP server with the Python SDK, serving two tools over HTTP, one of them with the mark in place:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">types&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Tool&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">**&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;always_loaded_tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool with _meta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;inputSchema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;anthropic/alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;UPSTREAM_META_MARKER&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In front of it, a LiteLLM from the main branch with that server declared in &lt;code>config.yaml&lt;/code>, with no database, with a master key. And an MCP client querying the listing against &lt;code>/mcp&lt;/code>.&lt;/p>
&lt;h3 id="the-result">The result&lt;/h3>
&lt;p>This is what comes out on the wire:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;litellm.ai/server_outcomes&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_count&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">}}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;probe-always_loaded_tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool with _meta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;inputSchema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;anthropic/alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;UPSTREAM_META_MARKER&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The mark arrives whole and with the correct key. The client parses it without trouble.&lt;/p>
&lt;p>Tracing the code, the chain is clean on purpose. LiteLLM keeps the SDK objects as they are when it lists against the upstream, instead of rebuilding them. The name prefixing does a deep copy and mutates only the name, with a comment in the code declaring the intention to preserve every field including the &lt;code>_meta&lt;/code> by avoiding mutation. The two permission filters are list comprehensions that reuse the same objects. And the name and description overrides mutate in place, so they do not lose anything either. Somebody thought about this.&lt;/p>
&lt;p>One operational detail: the name arrives prefixed with the server alias, governed by the configurable separator and the short prefix mode.&lt;/p>
&lt;h3 id="the-three-routes-where-it-is-lost">The three routes where it is lost&lt;/h3>
&lt;p>And here is the finding that changes the previous article&amp;rsquo;s recommendation.&lt;/p>
&lt;p>Proxy mode returns only its three fixed tools, with no &lt;code>_meta&lt;/code> and with no upstream tool at all. Verified against the wire as well.&lt;/p>
&lt;p>Tool search activated by key permissions does the same with four virtual tools.&lt;/p>
&lt;p>And the REST listing builds its response object by hand and discards the field: the tool that did carry a mark comes out with the field null. The class inherits from the type that has the field; it simply is not filled in. The fix fits on one line.&lt;/p>
&lt;p>Those three routes are, exactly, the three ways LiteLLM has of trimming the catalogue. The operational conclusion is uncomfortable and it is worth saying plainly: &lt;strong>either you use the gateway&amp;rsquo;s catalogue trimming, or you use the client&amp;rsquo;s per-tool mark. They cannot be combined.&lt;/strong> The previous article recommended proxy mode as the fifth rung of the ladder; with this in hand, that rung and the fine-grained mark are mutually exclusive.&lt;/p>
&lt;h3 id="the-library-trap">The library trap&lt;/h3>
&lt;p>This deserves its own section because it takes out anyone writing a server or a proxy, and it gives no warning.&lt;/p>
&lt;p>The SDK&amp;rsquo;s tool model declares the field with an alias, and does not enable populating it by name. The consequences, both checked by running them:&lt;/p>
&lt;p>Building with the field name &lt;strong>populates nothing&lt;/strong>. The dictionary slips into the model&amp;rsquo;s extras and comes out serialised under the wrong key, without the leading underscore, which is a key no client interprets.&lt;/p>
&lt;p>And dumping and revalidating without asking for aliases &lt;strong>loses the field&lt;/strong>, for the same reason.&lt;/p>
&lt;p>There is no exception, no warning in the log, nothing. The field disappears. If someone marks their tools and does not see them marked in the client, this is the first place to look, before the gateway.&lt;/p>
&lt;h3 id="what-we-have-not-tested">What we have not tested&lt;/h3>
&lt;p>For honesty&amp;rsquo;s sake, and because it is the link that remains: we have shown that the mark survives the gateway. We have not shown that the client acts on it when the tool arrives with the name prefixed by the gateway. Claude Code&amp;rsquo;s documentation describes the per-tool mark in a single sentence and says nothing about aggregating servers or about prefixes.&lt;/p>
&lt;p>It is a half-hour test for anyone who has the setup in front of them: two tools from the same gateway, one marked, and look at which one appears loaded at the start of the turn. If someone does it before I do, I am interested in the result.&lt;/p>
&lt;h2 id="what-litellm-can-and-cannot-do">What LiteLLM can and cannot do&lt;/h2>
&lt;p>With the above, the gateway&amp;rsquo;s inventory of levers comes out like this.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Preserves the &lt;code>_meta&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>allowed_tools&lt;/code> and &lt;code>disallowed_tools&lt;/code> per server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tool sets per route&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Proxy mode and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Search by key permissions&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>REST listing&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Not applicable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications on the previous article&amp;rsquo;s table, now that the code has been read again.&lt;/p>
&lt;p>The allowed and disallowed lists &lt;strong>are applied in the listing&lt;/strong>, not only on the call. They are the blunt, effective instrument, and they do not break the cache because they are static.&lt;/p>
&lt;p>And the overrides have a limit that was not accounted for: they are applied in the MCP protocol listing, but they are &lt;strong>skipped in proxy mode and not applied on the REST route&lt;/strong>. Anyone rewriting descriptions and also turning on proxy mode is not serving what they think.&lt;/p>
&lt;p>What the gateway cannot do is inject &lt;code>_meta&lt;/code>. There is no setting equivalent to the ones for name and description. The mark has to be put there by the origin server, which for in-house servers is trivial because we write them. To mark third-party tools you would have to patch, and it is small: one field in the type and three lines in the overrides function if configuring it by file is enough.&lt;/p>
&lt;p>There is also an ordering lever that did not appear in the previous article. In the proxy&amp;rsquo;s search mode there is a core tools setting that puts them at the front of the ranking and that also does not count against the results cap. It is the closest thing to a declarative priority in the whole stack, and it lives inside the one mode that discards the &lt;code>_meta&lt;/code>.&lt;/p>
&lt;p>And a nuance about proxy mode&amp;rsquo;s results cap. The client cannot ask for more than five, that is true, but the operator can raise it by configuration. The previous article implied it was immovable.&lt;/p>
&lt;h2 id="the-cheap-lever-the-description-not-the-name">The cheap lever: the description, not the name&lt;/h2>
&lt;p>If you cannot exclude, what is left is to bias. And bias comes in through the description.&lt;/p>
&lt;p>The work that measures selection bias between functionally equivalent tools puts the combined bias of the evaluated models between 0.25 and 0.38, that is, you would have to redistribute between 25 % and 38 % of the probability mass for equivalent tools to be picked equally. On that basis, swapping two tools&amp;rsquo; descriptions shifts selection substantially, whereas perturbations that touch only the name produce smaller and more inconsistent effects.&lt;/p>
&lt;p>Another piece of work measures that a single pass of automatic description rewriting improves the metric over a large corpus, almost as much as iterative refinement, and with two orders of magnitude less time. And it adds a warning worth retaining: descriptions tuned against a fixed set of candidates do not generalise to the dynamically retrieved set. You have to optimise for the regime you serve in.&lt;/p>
&lt;p>Applied: the gateway&amp;rsquo;s description override is at once token trimming, selection bias and mitigation of description change after approval. It is the lever with the best effort-to-effect ratio in the whole article, and it is static, so it does not touch the prompt cache.&lt;/p>
&lt;p>Name prefixing, by contrast, costs tokens and probably does not change which tool the model goes to.&lt;/p>
&lt;h2 id="what-does-not-work">What does not work&lt;/h2>
&lt;p>&lt;strong>Reordering the catalogue.&lt;/strong> A June paper measures that, when the model fails, it was already attending to the correct tool eighty per cent of the time, well above chance. The bottleneck is not at the input but in the late layers. Interventions on prompt order recover at most 23 % of the failures, against the 59 % to 91 % of those acting on the final readout. Reordering is cheap and that is why it gets recommended a lot; it is also weak.&lt;/p>
&lt;p>&lt;strong>Rules and instructions.&lt;/strong> They are text. They help and they do not decide.&lt;/p>
&lt;p>&lt;strong>Your own per-request top-K filters.&lt;/strong> This is covered in the previous article and it still holds: four out of five measured strategies perform worse than not filtering, and on top of that rewriting the tool block on every turn invalidates the entire cached prefix.&lt;/p>
&lt;h2 id="the-ladder-updated">The ladder, updated&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Exclude in the client.&lt;/strong> It is the only deterministic thing. &lt;code>--tools &amp;quot;&amp;quot;&lt;/code> or deny by bare name for the competing native tools.&lt;/li>
&lt;li>&lt;strong>Mark your own as always loaded.&lt;/strong> At server level with &lt;code>alwaysLoad&lt;/code>, or per tool with the mark in the &lt;code>_meta&lt;/code> if you control the origin server. Counting on start-up waiting for the server.&lt;/li>
&lt;li>&lt;strong>Trim with static lists&lt;/strong> per server and per key, and split a catalogue by agent profile across routes.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the gateway&amp;rsquo;s overrides.&lt;/li>
&lt;li>&lt;strong>Remove hot&lt;/strong> the rival sets, if the client is in-house and the model supports it.&lt;/li>
&lt;li>&lt;strong>Choose&lt;/strong>: the gateway&amp;rsquo;s catalogue trimming, or the per-tool mark. Not both.&lt;/li>
&lt;li>&lt;strong>Do not&lt;/strong>: reorder, rename, or trust prompt rules.&lt;/li>
&lt;/ol>
&lt;h2 id="the-risk-that-has-to-be-declared">The risk that has to be declared&lt;/h2>
&lt;p>The asymmetry you build is also a surface. Whoever controls which tools head a ranking controls the selection, and that can be attacked: there is work measuring that by injecting adversarial tools at rates of 1.2 % you take over the head of the ranking between 91 % and 97 % of the time.&lt;/p>
&lt;p>In a system under ENS (Esquema Nacional de Seguridad, Spain&amp;rsquo;s national security framework) this fits under change management, not access control: the set of tools a model sees and the order in which it sees them are security configuration, and today nobody versions them. Neither does the specification fix a hash of the description or of the schema, nor does the gateway. The specific Annex II codes are worth checking against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway article&lt;/a>.&lt;/p>
&lt;p>There is a precedent within the ecosystem itself that points the way. The MCP skills extension, which is in a final state, requires a manifest with a per-file hash, mandatory verification before use, and approval tied to the set of files and their digests, so that any change revokes the approval. And it obliges hosts to prevent two skills with the same name from silently replacing one another. It is exactly the pattern missing for tools. Anyone who needs it today has to implement it in their gateway.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the strategy before touching anything: catalogue trimming in the gateway, or the per-tool mark in the client. They are mutually exclusive.&lt;/li>
&lt;li>If you pick the mark, put it in the origin server, because the gateway cannot inject it.&lt;/li>
&lt;li>Build the tool object with the alias key, never with the field name, or the field disappears without warning.&lt;/li>
&lt;li>Do not revalidate tool objects from a dump without asking for aliases.&lt;/li>
&lt;li>Count on marking a server as always loaded making session start-up wait for that server, and on a gateway listing live against all of its upstreams.&lt;/li>
&lt;li>Exclude in the client the native tools that compete, by bare name and not with a pattern in parentheses.&lt;/li>
&lt;li>Rewrite the descriptions of your own tools, and do not waste time renaming.&lt;/li>
&lt;li>Do not turn on proxy mode if you depend on the description overrides, because they are not applied there.&lt;/li>
&lt;li>Version the exposed tool set and its order as security configuration, with a hash of the description and the schema.&lt;/li>
&lt;li>If you use the gateway&amp;rsquo;s REST route for anything, know that it discards the &lt;code>_meta&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP&amp;rsquo;s &lt;code>priority&lt;/code> exists, and it is not about tools.&lt;/strong> It lives in the annotations for content, resources and prompts.&lt;/li>
&lt;li>&lt;strong>Tool annotations are hints and the schema itself says they are not reliable&lt;/strong> from untrusted servers.&lt;/li>
&lt;li>&lt;strong>The specification gives no precedence rule between servers&lt;/strong>, and it says besides that the server name is no use for disambiguating.&lt;/li>
&lt;li>&lt;strong>The tool set may indeed vary by authorisation&lt;/strong>, and that is the only blessed segmentation.&lt;/li>
&lt;li>&lt;strong>&lt;code>server/discover&lt;/code> does not return tools.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A deny rule with parentheses does not remove the tool from the context&lt;/strong>, it only rejects the calls.&lt;/li>
&lt;li>&lt;strong>&lt;code>--tools&lt;/code> is a command-line flag&lt;/strong>, with no equivalent in the settings file.&lt;/li>
&lt;li>&lt;strong>Marking a server as always loaded delays start-up&lt;/strong> by up to five seconds per server.&lt;/li>
&lt;li>&lt;strong>&lt;code>Tool(meta=...)&lt;/code> does not populate the field&lt;/strong> and serialises it under the wrong key.&lt;/li>
&lt;li>&lt;strong>Proxy mode, search by permissions and the REST listing discard the &lt;code>_meta&lt;/code>.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Description overrides are skipped in proxy mode&lt;/strong> and are not applied on REST.&lt;/li>
&lt;li>&lt;strong>Proxy mode&amp;rsquo;s cap of five results can be raised by the operator&lt;/strong>, even though the client cannot ask for more.&lt;/li>
&lt;li>&lt;strong>The core tools of the proxy&amp;rsquo;s ranking are the only declarative priority in the stack&lt;/strong>, and they live in the mode that loses the &lt;code>_meta&lt;/code>.&lt;/li>
&lt;li>&lt;strong>You cannot force &amp;ldquo;any of mine from the gateway&amp;rdquo;&lt;/strong> in any API.&lt;/li>
&lt;li>&lt;strong>In OpenAI, the allowed list inside the MCP block and the identically named tool-choice type are different things.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Gemini does accept a per-tool allowlist&lt;/strong> on its remote MCP servers.&lt;/li>
&lt;li>&lt;strong>Reordering the catalogue repairs at most 23 % of the failures.&lt;/strong>&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The starting question had a trap in it, and the trap is the word. When someone asks how you prioritise an MCP server, they are assuming a dial exists somewhere. It does not, and I have spent the whole article showing the places where it is not.&lt;/p>
&lt;p>What there is instead is poorer and more manageable: you remove what competes, you leave your own in front, and you write the description better. Three things, none of them elegant, all three effective in that order.&lt;/p>
&lt;p>What does seem to me worth taking away is the shape of the problem. The tool catalogue a model sees is security configuration in the full sense, because it determines what the agent can do and which system it is going to talk to. And today it is managed like a list of connections: things get added, it is not versioned, it is not signed, and nobody detects a change. The ecosystem has already solved that problem once, for skills, with hashes and tied approval. For tools, not yet.&lt;/p>
&lt;p>In the meantime, the asymmetry has to be built by hand, server by server, and knowing that it rests on the &lt;code>_meta&lt;/code> of an object that a library can empty without saying anything.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — the article this one is a direct follow-up to, with the catalogue as cost and the ladder that gets corrected here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the separation by key and team that credential-based filtering rests on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — what it costs in proxy CPU to serialise the catalogue on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — why touching the start of the prompt is paid for so dearly.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the protected resource side and audience validation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where change management over the catalogue fits.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Model Context Protocol, herramientas en la revisión &lt;code>2026-07-28&lt;/code>: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/tools">https://modelcontextprotocol.io/specification/2026-07-28/server/tools&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;code>server/discover&lt;/code>: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/discover">https://modelcontextprotocol.io/specification/2026-07-28/server/discover&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, paginación: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination">https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, caché de listados: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching">https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, esquema fuente de la revisión: &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts">https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, extensión de habilidades: &lt;a href="https://modelcontextprotocol.io/extensions/skills/overview">https://modelcontextprotocol.io/extensions/skills/overview&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, grupo de interés sobre agrupación de primitivas: &lt;a href="https://modelcontextprotocol.io/community/interest-groups/primitive-grouping">https://modelcontextprotocol.io/community/interest-groups/primitive-grouping&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code y MCP (&lt;code>alwaysLoad&lt;/code>, búsqueda de herramientas): &lt;a href="https://code.claude.com/docs/en/mcp">https://code.claude.com/docs/en/mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code, referencia de línea de comandos (&lt;code>--tools&lt;/code>): &lt;a href="https://code.claude.com/docs/en/cli-reference">https://code.claude.com/docs/en/cli-reference&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code, permisos (denegación por nombre pelado): &lt;a href="https://code.claude.com/docs/en/permissions">https://code.claude.com/docs/en/permissions&lt;/a>.&lt;/li>
&lt;li>Anthropic, conector MCP y conjuntos de herramientas: &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector">https://platform.claude.com/docs/en/agents-and-tools/mcp-connector&lt;/a>.&lt;/li>
&lt;li>Anthropic, cambios de herramientas a mitad de conversación: &lt;a href="https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages">https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages&lt;/a>.&lt;/li>
&lt;li>Anthropic, búsqueda de herramientas: &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, MCP y conectores (lista de herramientas permitidas): &lt;a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp">https://developers.openai.com/api/docs/guides/tools-connectors-mcp&lt;/a>.&lt;/li>
&lt;li>OpenAI, búsqueda de herramientas y caché: &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>Google, llamada a funciones en la Interactions API: &lt;a href="https://ai.google.dev/gemini-api/docs/function-calling">https://ai.google.dev/gemini-api/docs/function-calling&lt;/a>.&lt;/li>
&lt;li>vLLM, llamada a herramientas y decodificación restringida: &lt;a href="https://docs.vllm.ai/en/latest/features/tool_calling.html">https://docs.vllm.ai/en/latest/features/tool_calling.html&lt;/a>.&lt;/li>
&lt;li>vLLM, propuesta de decodificación guiada por regiones y gramáticas de herramienta: &lt;a href="https://github.com/vllm-project/vllm/issues/39848">https://github.com/vllm-project/vllm/issues/39848&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código del gateway MCP: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>tool_search.py&lt;/code>, &lt;code>litellm/experimental_mcp_client/tools.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>SDK de Python de MCP, modelo &lt;code>Tool&lt;/code> y serialización por alias: &lt;a href="https://github.com/modelcontextprotocol/python-sdk">https://github.com/modelcontextprotocol/python-sdk&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307v2">https://arxiv.org/html/2510.00307v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>Looking Is Not Picking&lt;/em>, atención frente a lectura final y techo de las intervenciones sobre el prompt: &lt;a href="https://arxiv.org/html/2606.16364v1">https://arxiv.org/html/2606.16364v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>A Single Rewrite Suffices&lt;/em>, reescritura de descripciones y generalización al conjunto recuperado: &lt;a href="https://arxiv.org/html/2606.30775">https://arxiv.org/html/2606.30775&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Completing Keycloak for MCP: the protected resource no SDK hands you ready-made, and the enterprise extension that skips the whole flow</title><link>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</link><pubDate>Sat, 12 Sep 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, which flagged the gap: resource indicators are not supported and protected resource metadata belongs to the MCP server. This post tries to close it. Verified against revision 2026-07-28 of the specification, the official Python and TypeScript SDKs, Keycloak 26.7.3 and LiteLLM 1.102.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The specification splits three obligations and the authorisation server covers only one.&lt;/strong> The MCP server must publish its protected resource metadata. The client must send the resource indicator in both requests, and must do so even if the authorisation server does not support it. The MCP server must validate that the token was issued for it. Keycloak does not understand the indicator and considers the metadata someone else&amp;rsquo;s, so all three end up in the resource layer.&lt;/p>
&lt;p>&lt;strong>The Python SDK mounts the metadata on its own, but degraded.&lt;/strong> As soon as the resource server URL is configured, the route appears. What that automatic route publishes carries a single authorisation server, reuses as its scope catalogue the ones the middleware requires, and leaves the name and the documentation null. To publish a complete document you have to mount the route by hand.&lt;/p>
&lt;p>&lt;strong>Audience validation comes switched off in Python and does not exist in TypeScript.&lt;/strong> In Python, if the resource URL is configured and the corresponding flag is not enabled, the SDK emits a deprecation warning and behaves as if it were disabled; the docstring itself promises that version 3 will set it to true. In TypeScript the bearer verification does three things, and none of them is looking at the audience. Neither of the two ships a signature verifier with public keys.&lt;/p>
&lt;p>&lt;strong>Token passthrough is forbidden in writing, and the substitute has a name.&lt;/strong> The specification says the MCP server must not accept or relay tokens that were not issued for it, and that if it calls upstream APIs the token must be a different one. The mechanism is token exchange, which Keycloak supports in its standard version with a limitation you need to know: the audience is a client identifier, not a resource URL.&lt;/p>
&lt;p>&lt;strong>There is an official extension that changes the whole picture, and it is stable.&lt;/strong> Enterprise-managed authorisation replaces the redirect to the MCP authorisation server with an exchange at the corporate identity provider, which evaluates the policy and issues a grant based on an identity assertion. It has been stable since June 2026 and there are servers in production.&lt;/p>
&lt;p>&lt;strong>Keycloak implements it halfway and in experimental.&lt;/strong> It only knows how to act as receiver, not as issuer, behind a feature flag, and against draft 01 when the working group is on 04. The official documentation literally says not to use it in production.&lt;/p>
&lt;p>&lt;strong>LiteLLM already implements it on the client side and does not document it.&lt;/strong> In 1.102.0 there is an authentication mode towards MCP servers that runs the two stages of that grant. There is not a single documentation file that mentions it.&lt;/p>
&lt;h2 id="you-are-here-the-resource-side">You are here: the resource side&lt;/h2>
&lt;p>The previous post walked through the identity provider. This one walks through what sits on the other side of the token, which is where the MCP specification puts almost all its normative obligations.&lt;/p>
&lt;p>The natural reading order is &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up&lt;/a> for the basic setup, &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> for the identity piece, and this one for the missing layer. The MCP gateway seen from the inside is &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in its own post&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-visa-and-the-border-checkpoint">The analogy: the visa and the border checkpoint&lt;/h2>
&lt;p>A consulate issues visas. A border post checks them. They are two different offices, and the classic mistake consists of assuming that because the visa is genuine it is good for entering through any door.&lt;/p>
&lt;p>The visa has written on it which country it is good for. That is the token&amp;rsquo;s audience, and here the problem appears: the consulate we work with does not know how to write the destination the traveller asks for, because it does not understand that field on the form. It knows how to write a destination if asked for it under a different name, by way of a pre-agreed stamp, which is the workaround of scopes and the audience mapper.&lt;/p>
&lt;p>And then there is the other half, the one almost nobody builds. The border post has to exist, it has to announce where it is and from which consulates it accepts visas, and it has to read the destination written on the visa before letting anyone through. If the border post limits itself to checking that the stamp is genuine and does not look at the destination, any valid visa from the same consulate is good for entering. That is exactly what the two official SDKs do by default.&lt;/p>
&lt;h2 id="part-1-the-split-of-obligations">Part 1. The split of obligations&lt;/h2>
&lt;p>From revision 2026-07-28, with the normative words as they are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Obligation&lt;/th>
&lt;th>Whose&lt;/th>
&lt;th>Status with Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Publish protected resource metadata (RFC 9728)&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Out of its scope, by design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Send the resource indicator (RFC 8707) in authorisation and in token&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>, even if the authorisation server does not support it&lt;/td>
&lt;td>It does not understand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate that the token was issued for oneself&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not accept or relay other tokens&lt;/td>
&lt;td>MCP server, &lt;strong>MUST NOT&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate the issuer of the authorisation response (RFC 9207)&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>; authorisation server &lt;strong>SHOULD&lt;/strong> emit it&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consent for each dynamically registered client&lt;/td>
&lt;td>MCP proxy with a static identifier, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Configurable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most confusing part of that split is the second line. The specification obliges the client to send the parameter &lt;strong>regardless of whether the authorisation server supports it&lt;/strong>. With Keycloak, that parameter is lost: the official documentation says it cannot recognise it, and the standard behaviour towards an unknown parameter is to ignore it. The client complies, the token comes out, and what does not come out is the correct audience. The failure appears later and somewhere else, which is the worst kind of failure.&lt;/p>
&lt;p>It is worth knowing where that work stands. The matter is issue 14355 of the project, open, with a milestone in 26.8.0. There was a complete implementation in pull request 35711, with a mapper of its own and an extension point for resolving resources, which &lt;strong>went back to draft in October 2025&lt;/strong> because of a total lack of tests and because it was decided to redo it in phases, starting with a single resource per client. In March 2026 a new issue was opened for experimental support, also with a milestone in 26.8.0 and with no associated pull request. In 26.7.x there is no feature flag that enables it.&lt;/p>
&lt;h3 id="what-is-new-in-the-revision-and-affects-the-setup">What is new in the revision and affects the setup&lt;/h3>
&lt;p>Four things changed with respect to the previous revision and are worth collecting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RFC 9207.&lt;/strong> A new section on validating the authorisation response. The client must record the issuer from the validated metadata document, and the comparison is literal: you are not allowed to normalise case, elide the default port, add or remove the trailing slash or re-encode characters before comparing.&lt;/li>
&lt;li>&lt;strong>Offline tokens leave the catalogue.&lt;/strong> A new section on refresh tokens: MCP servers &lt;strong>should not&lt;/strong> include the corresponding scope either in the challenge header or in the supported scopes of their metadata.&lt;/li>
&lt;li>&lt;strong>Dynamic registration becomes deprecated.&lt;/strong> It goes from optional to optional and deprecated, retained for compatibility with authorisation servers that do not support client identifier metadata documents. And a new requirement is added to registration: the application type is mandatory, and a client must not reuse credentials from another authorisation server, it has to register again.&lt;/li>
&lt;li>&lt;strong>Step-up elevation is rewritten.&lt;/strong> The menu of three server strategies disappears and is replaced by two rules: the challenge&amp;rsquo;s scope attribute describes what is needed for the requested resource, with no obligation to include what has already been granted, and scope accumulation becomes the client&amp;rsquo;s responsibility. With a new requirement for the server: it &lt;strong>must&lt;/strong> take scope hierarchies into account, where a broad one implies the narrow ones.&lt;/li>
&lt;/ul>
&lt;h2 id="part-2-what-the-sdks-give-and-what-they-do-not">Part 2. What the SDKs give, and what they do not&lt;/h2>
&lt;p>Here is the uncomfortable part, and it is the main reason for writing this post. Verified by reading the code of the Python SDK and of the TypeScript 2.0.0-alpha one.&lt;/p>
&lt;h3 id="protected-resource-metadata">Protected resource metadata&lt;/h3>
&lt;p>&lt;strong>In Python the complete model exists and it mounts on its own.&lt;/strong> The metadata class carries the RFC fields: resource, authorisation servers with a minimum of one, key set URL, supported scopes, bearer methods with a default value of header, name, documentation, policy, terms, and the client certificate and DPoP fields. The handler serves the document with a one-hour cache directive, and the URL is built by inserting the well-known path in front of the resource path, as the RFC requires.&lt;/p>
&lt;p>As soon as the resource server URL is configured, the route appears with no further work. &lt;strong>But what that automatic route publishes is degraded in three points&lt;/strong>: it passes a single authorisation server, it takes as its scope catalogue the ones the middleware requires (which are not the same as the ones the resource supports), and it passes neither the name nor the documentation, which come out null. To publish a complete document you have to call the route creation function by hand.&lt;/p>
&lt;p>&lt;strong>In TypeScript it exists and does not mount on its own.&lt;/strong> The function that builds the document emits &lt;strong>only five fields&lt;/strong>: resource, authorisation servers, supported scopes, name and documentation. &lt;strong>It does not emit the supported bearer methods&lt;/strong>, nor the key set URL, nor anything about DPoP. And serving it is explicit: either you call the response function from your own handler, or you mount the metadata router. There is no point in the SDK that does it on its own.&lt;/p>
&lt;h3 id="audience-validation">Audience validation&lt;/h3>
&lt;p>This is the one to fix on day one.&lt;/p>
&lt;p>&lt;strong>In Python it comes disabled.&lt;/strong> The token verifier is a single-method protocol, that is to say a hole the implementer fills. The access token&amp;rsquo;s resource field is the indicator you set yourself. And the comparison only happens if you ask for it: there is a function that normalises as a URL and ignores the trailing slash, but the resource server URL reaches it empty unless the validation flag is enabled.&lt;/p>
&lt;p>The detail to read twice is in the configuration: &lt;strong>if the resource URL is set and the flag is not, the SDK raises a deprecation warning and behaves as if the flag were false&lt;/strong>. The docstring says version 3 will set the default to true. Until then, a Python server configured with authorisation accepts tokens issued for another resource, unless the implementer enables the flag or validates the audience inside their own verifier.&lt;/p>
&lt;p>&lt;strong>In TypeScript it is not validated at all.&lt;/strong> Bearer token verification does exactly three things: strip the prefix, check the required scopes, and require that the expiry is present and not past. A search for audience or key set across the server and middleware packages returns no validation. It is all left in the hands of whichever verifier you plug in.&lt;/p>
&lt;p>&lt;strong>Neither of the two ships a signature verifier with public keys.&lt;/strong> In Python the only example is introspection against the authorisation server, and its audience check sits behind a flag that also comes out false. In TypeScript the interface is empty. That is: the component that validates the token, which is the one holding up the most important normative requirement of the specification, is your own code in both cases.&lt;/p>
&lt;h3 id="the-authentication-challenge">The authentication challenge&lt;/h3>
&lt;p>&lt;strong>Python&lt;/strong> builds the challenge with the error and its description, and adds the metadata URL only if it is configured. It returns 401 with invalid token and 403 with insufficient scope, both from the same function, so both carry the metadata URL. &lt;strong>What it never emits is the scope parameter&lt;/strong>, which revision 2026-07-28 asks to be included, and which is exactly what the client needs in order to know what to ask for.&lt;/p>
&lt;p>&lt;strong>TypeScript does emit the scope&lt;/strong> when there are required scopes, in addition to the metadata URL, and it correctly maps invalid token to 401 and insufficient scope to 403.&lt;/p>
&lt;p>That is, each SDK gets a different half right. The Python one validates better and warns worse; the TypeScript one warns better and does not validate.&lt;/p>
&lt;h3 id="scopes-which-are-not-per-tool">Scopes, which are not per tool&lt;/h3>
&lt;p>In both SDKs scopes are &lt;strong>a static list at the transport mounting level&lt;/strong>, not per tool. There is no per-tool scope declaration and no step-up elevation help on the server side. Emitting a 403 with the specific scope that call requires, which is what the specification describes, is your own code.&lt;/p>
&lt;p>That is the origin of almost all the fine-grained authorisation you have to build, and it is why there is a Part 6.&lt;/p>
&lt;h3 id="which-revision-each-one-announces">Which revision each one announces&lt;/h3>
&lt;p>&lt;strong>Python is already on 2026-07-28.&lt;/strong> The revision appears in the known versions and in the list of modern versions, described as the ones that use the stateless envelope per request. The server discovery method is registered with a default handler, and the method and name headers are validated against the body.&lt;/p>
&lt;p>&lt;strong>TypeScript has it, but in a separate list.&lt;/strong> The latest protocol version constant is still 2025-11-25, because that list is only the one for the initial handshake. The modern era lives in a separate module, with a comment explaining the reason: keeping them deliberately separate so that adding a revision there never leaks a modern version string into a 2025-era handshake.&lt;/p>
&lt;p>And it is worth recalling the mismatch that already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>: LiteLLM 1.102.0 still announces 2025-06-18.&lt;/p>
&lt;h2 id="part-3-building-the-protected-resource">Part 3. Building the protected resource&lt;/h2>
&lt;p>With the above, the list of what you have to write is short and concrete.&lt;/p>
&lt;p>&lt;strong>1. A token verifier that validates the signature and the audience.&lt;/strong> Against the realm&amp;rsquo;s key set, checking issuer, expiry and that the audience contains the MCP server&amp;rsquo;s canonical URL and &lt;strong>only&lt;/strong> things that concern it. Here the side effect of the Keycloak workaround comes in: if a client asks for two scopes from two different resources, out comes a token with two audiences, because each mapper contributes its own to the array. There is no client policy condition or executor in 26.7 that limits the number of audiences per token. The practical defence is the resource&amp;rsquo;s: reject tokens whose audience includes other resources, instead of merely checking that its own is present.&lt;/p>
&lt;p>&lt;strong>2. The complete metadata document&lt;/strong>, with all the authorisation servers you accept, the real catalogue of scopes the resource understands, and the name and documentation filled in. In Python, mounting the route by hand instead of leaving the automatic one. In TypeScript, mounting it, full stop.&lt;/p>
&lt;p>&lt;strong>3. The authentication challenge with the scope.&lt;/strong> In Python you have to add it, because the SDK does not emit it. And you have to take scope hierarchies into account, which is a new requirement of the revision.&lt;/p>
&lt;p>&lt;strong>4. The decision between local validation and introspection.&lt;/strong> Validating the signature locally costs no network, but &lt;strong>revocation does not take effect until the token expires&lt;/strong>: there are no revocation lists for signed tokens. Introspection costs a round trip per request and only confidential clients can invoke it. The reasonable combination is a short token life and introspection on the operations that change state.&lt;/p>
&lt;p>&lt;strong>5. Binding state to the user.&lt;/strong> The new revision is explicit about this, because with protocol sessions gone, state is carried in handlers that travel as an ordinary tool argument. Servers &lt;strong>must&lt;/strong> verify every incoming request and &lt;strong>must not&lt;/strong> treat possession of a handler as authentication; and they &lt;strong>should&lt;/strong> bind the handler to the user on the server side, for example storing the state under a key that combines the user identifier derived from the verified token with the handler, and reject the handler if someone else presents it. It is a change in the way of working compared with the sessions of before.&lt;/p>
&lt;h2 id="part-4-the-hop-from-the-gateway-to-the-mcp-server">Part 4. The hop from the gateway to the MCP server&lt;/h2>
&lt;p>The real shape in an inference platform is not client against MCP server: it is client against gateway, and gateway against MCP server. That second hop is where it is decided whether the architecture is correct.&lt;/p>
&lt;p>The specification leaves no room. The MCP server &lt;strong>must not&lt;/strong> accept or relay tokens that were not issued for it. And if it makes requests to upstream APIs, it may act as an OAuth client of theirs, but &lt;strong>the token it uses there is a different token, issued by the upstream authorisation server&lt;/strong>, and it must not forward the one it received.&lt;/p>
&lt;p>The standard mechanism for that is token exchange, and here you need to know three Keycloak details.&lt;/p>
&lt;p>&lt;strong>First, the permission model changed.&lt;/strong> The old version required fine-grained administration permissions and an explicit exchange authorisation on the target client. The standard version, supported since 26.2, does not require them: it is enough for the requesting client to be confidential and to have the corresponding switch enabled. In exchange there is a surprising condition: &lt;strong>the subject token has to carry the requesting client in its audience&lt;/strong>, unless it exchanges its own token. That is, the gateway needs to appear in the audience of the user token, which is achieved with an audience mapper in a default scope of the gateway. Only then can it narrow the audience to that of the specific MCP server.&lt;/p>
&lt;p>&lt;strong>Second, the audience is a client identifier, not a resource URL.&lt;/strong> The parameter filters audiences, that is to say it narrows them, which is what you want. But it takes the identifier of a client registered in the realm. The practical consequence is that &lt;strong>the MCP server has to be registered as a client, and its identifier should be its canonical URL&lt;/strong>, so that the resulting audience matches what the resource validates. It is a naming trick, and it has to be documented for whoever comes after. The documentation itself acknowledges it: token exchange does not yet support the resource parameter.&lt;/p>
&lt;p>&lt;strong>Third, the subject token type is limited.&lt;/strong> The standard version only accepts access tokens as subject.&lt;/p>
&lt;p>There is also a new experimental feature in 26.7 that adds a parameterised scope type for validating whether the requesting user is authorised to act on behalf of another. Interesting for delegation, but experimental.&lt;/p>
&lt;h3 id="what-the-gateway-does-today">What the gateway does today&lt;/h3>
&lt;p>In LiteLLM 1.102.0 the authentication modes towards MCP servers number twelve, with a default value of none. The ones that matter for this discussion are four.&lt;/p>
&lt;p>&lt;code>oauth2_token_exchange&lt;/code> implements the standard exchange. It sends the correct grant type, the subject token and its type, and &lt;strong>the audience, never the resource&lt;/strong>. The omission is deliberate according to the code itself: fabricating a target risks an invalid target error. It caches the resulting token under a key that combines the subject token with the whole configuration, and with a lifetime equal to the token&amp;rsquo;s minus one minute. And it &lt;strong>blocks, never degrades&lt;/strong>: with no incoming token it returns 401, which the edge turns into a challenge with the resource metadata URL; a rejection from the identity provider returns 401; a gateway configuration error returns 500; a transport failure, 503.&lt;/p>
&lt;p>&lt;code>true_passthrough&lt;/code> and &lt;code>oauth_delegate&lt;/code> forward the client&amp;rsquo;s authorisation header as is. That is token passthrough, by the name the specification gives it, and its legitimate use is narrow: when the token the client presents was already issued for the target MCP server and the gateway is a mere transport. Outside that case, it is non-compliant.&lt;/p>
&lt;p>&lt;code>oauth2_id_jag&lt;/code> is the surprise, and it deserves a part of its own.&lt;/p>
&lt;h2 id="part-5-the-enterprise-extension-which-changes-the-picture">Part 5. The enterprise extension, which changes the picture&lt;/h2>
&lt;p>In June 2026 the MCP project published an authorisation extension that solves a different problem from the one the standard flow solves, and which in an organisation with an identity provider of its own is the real problem.&lt;/p>
&lt;p>The normal flow is per user: each employee authorises each client against each MCP server. It works for consumer applications and it does not work in a company, because onboarding a person requires authorising dozens of services one by one and offboarding requires revoking them one by one.&lt;/p>
&lt;p>&lt;strong>The &lt;code>io.modelcontextprotocol/enterprise-managed-authorization&lt;/code> extension inverts that.&lt;/strong> It is in the stable specifications directory of the extensions repository, it comes from SEP-990, and the flow is this:&lt;/p>
&lt;ol>
&lt;li>The MCP client authenticates the user against the corporate identity provider through the normal flow, and &lt;strong>stores the identity assertion&lt;/strong>, which may be an OpenID identity token or a SAML assertion.&lt;/li>
&lt;li>When the server indicates that enterprise-managed authorisation is needed, the client &lt;strong>exchanges that assertion at the corporate provider for an authorisation grant based on an identity assertion&lt;/strong>. The provider evaluates the organisation&amp;rsquo;s policy there: group membership, roles, conditional access.&lt;/li>
&lt;li>The client presents that grant to the MCP authorisation server and obtains an access token.&lt;/li>
&lt;li>The sentence that defines the extension, verbatim: &lt;strong>the user is not redirected to the authorisation endpoint of the MCP authorisation server&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>The MCP authorisation server validates the signature against the corporate provider&amp;rsquo;s key set, plus audience, issuer and expiry, and uses the subject as the user&amp;rsquo;s stable identifier, with the email as a fallback for linking earlier accounts.&lt;/p>
&lt;p>What that buys is exactly what a compliance file asks for: policy in a single place, an auditable decision at the identity provider, and &lt;strong>centralised revocation that takes effect across all clients at once&lt;/strong>. The employee who loses access stops receiving grants, without touching any server.&lt;/p>
&lt;p>The underlying standard is a draft from the IETF&amp;rsquo;s OAuth working group, under the name of JWT identity assertion authorisation grant, revision 04 of May 2026, signed by people from Okta, Ping Identity and an independent author. It profiles identity chaining across trust domains by combining token exchange with the JWT profile.&lt;/p>
&lt;h3 id="and-here-comes-the-problem">And here comes the problem&lt;/h3>
&lt;p>&lt;strong>Keycloak implements it only halfway.&lt;/strong> It has its own documentation page, behind a feature flag. And three limitations you need in front of you before designing anything:&lt;/p>
&lt;ul>
&lt;li>It only acts as receiver. It accepts assertions issued by an external provider and issues local tokens. Native support for acting as issuer, the documentation says, is not yet fully implemented. In the extension&amp;rsquo;s flow, the issuer is the corporate provider: if that provider is Keycloak, the missing piece is exactly the one you need.&lt;/li>
&lt;li>It is based on draft 01 while the working group is on 04.&lt;/li>
&lt;li>The official documentation says not to use it in production.&lt;/li>
&lt;/ul>
&lt;p>The asymmetry is striking and worth pointing out: Keycloak does not support a standard published eight years ago such as resource indicators, and does partially support a draft from a few months back.&lt;/p>
&lt;h3 id="what-is-already-in-the-gateway-undocumented">What is already in the gateway, undocumented&lt;/h3>
&lt;p>In LiteLLM 1.102.0, the &lt;code>oauth2_id_jag&lt;/code> mode runs the two stages of the flow. In the first it asks the corporate provider for a token exchange with the requested token type specific to this grant, sending the user&amp;rsquo;s identity token as subject and, optionally, audience, &lt;strong>resource&lt;/strong> and scope. In the second it presents the resulting assertion to the resource&amp;rsquo;s authorisation server with the JWT bearer grant type. In both the gateway authenticates as a client with a JWT signed by private key.&lt;/p>
&lt;p>Two operational warnings that come out of the code and are nowhere else:&lt;/p>
&lt;ul>
&lt;li>The subject comes from the caller&amp;rsquo;s identity token, or from the one captured at login. And only the generic OIDC provider captures assertions: with Google, Microsoft or SAML there is none, and &lt;strong>all users fail&lt;/strong>. The code raises a warning when loading the configuration.&lt;/li>
&lt;li>A badly configured server refuses with a 500 instead of falling back to the static credential. It is the right decision, and it is worth knowing before it happens.&lt;/li>
&lt;/ul>
&lt;p>And the main warning: &lt;strong>there is no documentation&lt;/strong>. Not a single file in the repository that mentions this mode. Anyone using it is reading code, like this post.&lt;/p>
&lt;h2 id="part-6-per-tool-authorisation">Part 6. Per-tool authorisation&lt;/h2>
&lt;p>None of the previous pieces answers the question the business ends up asking: whether this person may call this tool with these arguments. The identity provider gives roles and scopes, and with that you do not model a graph of relations, as was already argued &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the previous post&lt;/a>.&lt;/p>
&lt;p>What is published today, with sources:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenFGA&lt;/strong> is the only one with a dedicated product guide, updated on 9 September 2026. The pattern is a tool type with an invocation relation, checked on each call, and a listing query so the client only sees the tools it may invoke. It is a pattern and a modelling language, not a reference implementation.&lt;/li>
&lt;li>&lt;strong>Pomerium&lt;/strong> ships a per-tool policy criterion, with exact, prefix, suffix and list matching.&lt;/li>
&lt;li>&lt;strong>Kong&lt;/strong> offers it as a product, with per-tool control lists.&lt;/li>
&lt;li>&lt;strong>Traefik Hub&lt;/strong> goes as far as parameter-level restrictions, which is the finest published grain.&lt;/li>
&lt;li>&lt;strong>Envoy&lt;/strong> has an MCP filter that extracts protocol attributes for fine-grained access control, declared to be in active development and with no OAuth of its own: the hook is external authorisation towards a policy engine.&lt;/li>
&lt;/ul>
&lt;p>The lowest-friction option in a platform that already has a service mesh is external authorisation against a policy engine. The most expressive one, the relations model.&lt;/p>
&lt;h2 id="part-7-what-still-has-no-standard">Part 7. What still has no standard&lt;/h2>
&lt;p>Two gaps the specification does not cover and that have to be closed from outside.&lt;/p>
&lt;p>&lt;strong>Tool definition change.&lt;/strong> Neither revision 2026-07-28 nor the SDKs pin a hash of the description or of the input schema. The best practices page covers confused deputy, token passthrough, server-side request forgery in discovery, state handler hijacking, server confusion and validation of the authorisation URL scheme, but &lt;strong>there is no integrity or pinning requirement&lt;/strong>. The tool that does it is the Invariant Labs scanner, under the Apache 2.0 licence, which pins hashes to detect definition change after approval and also ships a proxy mode with guardrails. It already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a> and it remains the only answer.&lt;/p>
&lt;p>&lt;strong>The public guidance that does get into this&lt;/strong> is the NSA&amp;rsquo;s on MCP security design considerations, from May 2026, done with Carnegie Mellon&amp;rsquo;s software engineering institute. Two of its observations are directly actionable: that authorisation in MCP is optional and that the specification imposes no requirement on token lifecycle management, so expiry and rotation are left in the organisation&amp;rsquo;s hands; and the warning against dynamic tool discovery with no origin verification and no authorisation checks, which is the most direct acknowledgement of the risk in a state guidance document.&lt;/p>
&lt;h2 id="part-8-who-covers-what">Part 8. Who covers what&lt;/h2>
&lt;p>With all of the above, the decision table for the layer you put in front of the MCP server:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Publishes resource metadata&lt;/th>
&lt;th>Validates audience&lt;/th>
&lt;th>Token exchange&lt;/th>
&lt;th>Per-tool authorisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Python SDK, automatic route&lt;/td>
&lt;td>Yes, degraded&lt;/td>
&lt;td>&lt;strong>Not by default&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TypeScript SDK&lt;/td>
&lt;td>Does not mount on its own&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traefik Hub (commercial)&lt;/td>
&lt;td>Yes, automatic&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, down to parameter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong, OAuth plugin for MCP&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pomerium&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool criterion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>agentgateway (Solo.io)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes, by JWT policy&lt;/td>
&lt;td>Yes, including the assertion grant&lt;/td>
&lt;td>Yes, with expressions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mcp-context-forge (IBM)&lt;/td>
&lt;td>Not in release notes&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, since 1.0.6&lt;/td>
&lt;td>Yes, per-role control&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy, MCP filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Via external authorisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM 1.102.0&lt;/td>
&lt;td>Yes, and authorisation server metadata too&lt;/td>
&lt;td>&lt;strong>Not on the incoming JWT&lt;/strong>&lt;/td>
&lt;td>Yes, and the assertion grant&lt;/td>
&lt;td>Yes, per-key and per-team permissions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two warnings about that table. The Kong plugin requires a minimum version of 3.12, is in technical preview and needs a licence for its AI edition; it also introduces a breaking change in 3.13, which starts treating all traffic as MCP in order to close a possible authentication bypass. And on oauth2-proxy I found no primary documentation either for or against protected resource mode: I am not claiming it does not have it, I am claiming it is not documented where I could look.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;p>&lt;strong>Keycloak: the per-resource scope, optional, with its audience.&lt;/strong> That it is optional and not default is the key piece, because it is what makes the client have to ask for it explicitly and makes it work as a substitute for the resource indicator:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp:inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the MCP server registered as a client &lt;strong>with its canonical URL as identifier&lt;/strong>, so that token exchange produces the right audience:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create clients -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">clientId&lt;/span>&lt;span class="o">=&lt;/span>https://mcp.ejemplo.es/inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">enabled&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> -s &lt;span class="nv">publicClient&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&lt;/span> -s &lt;span class="nv">consentRequired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The gateway, with token exchange instead of token passthrough:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oauth2_token_exchange&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm-gateway&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/GW_SECRET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">audience&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subject_token_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;urn:ietf:params:oauth:token-type:access_token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_profile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;rfc8693&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The resource verifier, which is what nobody hands you ready-made.&lt;/strong> The essential bit is the last check, the one that rejects other resources&amp;rsquo; audiences instead of settling for finding its own:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">RECURSO&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">verificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AccessToken&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">jwks&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">audience&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># validates that I am there&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">aud&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># and that nobody else is: the scopes workaround allows&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># tokens with two audiences, and that reopens the confused deputy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">CLIENTE_GATEWAY&lt;/span>&lt;span class="p">}:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AccessToken&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;azp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expires_at&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;exp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And when mounting the server, the flag that does not come set:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AuthSettings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource_server_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">validate_token_resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># without this there is only a warning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required_scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mcp:inventario&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Enable audience validation in the Python SDK, or write it into the verifier if it is TypeScript. Without that, the central normative requirement is not met.&lt;/li>
&lt;li>Reject tokens with other resources&amp;rsquo; audiences, not just check that your own is present.&lt;/li>
&lt;li>Mount the metadata document by hand, with all the authorisation servers and the real catalogue of scopes.&lt;/li>
&lt;li>Add the scope parameter to the challenge, in Python, and account for scope hierarchies.&lt;/li>
&lt;li>Register each MCP server as a Keycloak client with its canonical URL as identifier, so that token exchange produces the right audience.&lt;/li>
&lt;li>Replace any token passthrough mode with token exchange, except the narrow case where the token was already issued for the target.&lt;/li>
&lt;li>Check that the gateway appears in the audience of the user token, or the exchange will fail.&lt;/li>
&lt;li>Bind state handlers to the user on the server side, and never treat them as authentication.&lt;/li>
&lt;li>Decide between local validation and introspection knowing that the first delays revocation until expiry, and shorten the token life accordingly.&lt;/li>
&lt;li>Pin a hash of the description and the input schema from outside, because the specification does not cover it.&lt;/li>
&lt;li>Put a policy engine in for per-tool authorisation, and do not try to model it with roles.&lt;/li>
&lt;li>If you are considering the enterprise extension, check first who issues the grant: Keycloak today only knows how to receive it, in experimental and against an earlier draft.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The Python SDK mounts the metadata document on its own, and that is why it looks solved.&lt;/strong> What it publishes carries a single authorisation server and no name and no documentation.&lt;/p>
&lt;p>&lt;strong>The audience validation deprecation warning does not enable anything.&lt;/strong> It warns and carries on behaving as disabled.&lt;/p>
&lt;p>&lt;strong>The TypeScript SDK does not validate the audience at all.&lt;/strong> It is not configuration, it is that there is no code for it.&lt;/p>
&lt;p>&lt;strong>Python&amp;rsquo;s challenge does not carry the scope&lt;/strong>, so a client that receives a 403 does not know what to ask for.&lt;/p>
&lt;p>&lt;strong>The scopes workaround allows two audiences in the same token.&lt;/strong> Each mapper contributes its own, and there is no client policy that limits it. The defence is in the resource.&lt;/p>
&lt;p>&lt;strong>The token exchange audience is a client identifier, not a URL.&lt;/strong> If the MCP server is not registered with its URL as identifier, the resulting audience will not match what the resource validates.&lt;/p>
&lt;p>&lt;strong>Token exchange requires the gateway to be in the audience of the user token.&lt;/strong> It is the condition that wastes the most time the first time round.&lt;/p>
&lt;p>&lt;strong>Dynamic client registration is deprecated&lt;/strong> as of this revision, and the substitute, client identifier metadata documents, is experimental in Keycloak and has an open bug with documents carrying unknown fields, which blocks login with real clients.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s identity assertion grant depends on the generic OIDC provider.&lt;/strong> With Google, Microsoft or SAML no assertion is captured and all users fail.&lt;/p>
&lt;p>&lt;strong>Step-up elevation is no longer the server&amp;rsquo;s responsibility.&lt;/strong> Scope accumulation moved to the client in this revision.&lt;/p>
&lt;p>&lt;strong>The authentication elevation signal goes in a 401, not a 403.&lt;/strong> Insufficient scope is 403; asking for a higher authentication level is a different thing and a different code.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The summary of this post fits in one sentence: the identity provider is the cheap half of the problem, and the expensive half is the protected resource, which no SDK hands you ready-made and which in the two official ones comes with audience validation disabled or absent.&lt;/p>
&lt;p>From that comes an order of work. First the token verifier, with the audience validated in both directions, because without it everything else is decoration. Second, the complete metadata document, mounted by hand. Third, replacing any token passthrough with exchange, with the MCP server registered with its URL as identifier. Fourth, a policy engine for what roles do not model.&lt;/p>
&lt;p>And a scheduling decision worth taking with the information in front of you. The enterprise-managed authorisation extension is the right route for an organisation with a provider of its own, it is stable and it has servers in production behind it. What is not ready is Keycloak as issuer. Anyone who wants that flow today has two honest options: delegate the mechanics to a gateway that implements it, or wait. Putting it into production with a feature the official documentation marks as experimental and expressly advises against is not one of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside, and the gap this post closes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the basic setup, predating this revision of the specification.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions, its cost and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: the audience validation that does not happen on the gateway side either.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: workload identity with attestation, the other plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its observability&lt;/a>: the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>: the network isolation everything above presupposes.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Especificación MCP, revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">autorización&lt;/a>, &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration">registro de clientes&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization">Extensión de autorización gestionada por la empresa&lt;/a> y su &lt;a href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx">especificación estable&lt;/a>, del SEP-990. Anuncio de 18 de junio de 2026.&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/">draft-ietf-oauth-identity-assertion-authz-grant&lt;/a>, revisión 04 de 21 de mayo de 2026, grupo de trabajo OAuth del IETF.&lt;/li>
&lt;li>Código de los SDK oficiales de MCP: &lt;code>python-sdk&lt;/code> y &lt;code>typescript-sdk&lt;/code> 2.0.0-alfa, clonados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración de Keycloak con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/securing-apps/identity-assertion-jwt-authorization-grant">concesión de autorización por aserción de identidad&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Incidencias de Keycloak &lt;a href="https://github.com/keycloak/keycloak/issues/14355">14355&lt;/a>, &lt;a href="https://github.com/keycloak/keycloak/issues/47117">47117&lt;/a> y &lt;a href="https://github.com/keycloak/keycloak/issues/51039">51039&lt;/a>, y petición de cambios &lt;a href="https://github.com/keycloak/keycloak/pull/35711">35711&lt;/a>.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">NSA y SEI, Model Context Protocol: Security Design Considerations&lt;/a>, mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://openfga.dev/docs/use-cases/mcp-server-authorization">OpenFGA, autorización de servidores MCP&lt;/a>, actualizado el 9 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://invariantlabs-ai.github.io/docs/mcp-scan/">mcp-scan de Invariant Labs&lt;/a>, Apache 2.0.&lt;/li>
&lt;li>Documentación de &lt;a href="https://docs.solo.io/agentgateway/2.3.x/mcp/auth/setup/">agentgateway&lt;/a>, &lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/mcp">Traefik Hub&lt;/a>, &lt;a href="https://developer.konghq.com/plugins/ai-mcp-oauth2/">Kong&lt;/a> y &lt;a href="https://www.pomerium.com/docs/capabilities/mcp">Pomerium&lt;/a>, consultadas el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak in an AI platform: where it fits, what it solves and the two standards MCP requires and it does not implement</title><link>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</guid><description>&lt;blockquote>
&lt;p>This post opens the identity block of the operations track. &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The previous one&lt;/a> covered the gateway side: the JWT that does not validate audience, the user who never reaches the trace. This one covers the piece on the other side. Verified against Keycloak 26.7.3, revision 2026-07-28 of the MCP specification and the text of Royal Decree 311/2022 in the BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 was released on 31 August 2026&lt;/strong> and is still incubating at the CNCF, which it entered in April 2023. The cadence is four minor releases a year and support in practice covers a single branch: the twenty security bugs fixed in 26.7.3 were not backported to the 26.4, 26.5 or 26.6 branches at the time of release.&lt;/p>
&lt;p>&lt;strong>The Quarkus-based distribution separates build and runtime configuration.&lt;/strong> What is set in the build phase is frozen into the image, and changing it at startup forces a rebuild. In containers, the pattern is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Storage is relational and only relational.&lt;/strong> The alternative engine that was in development was withdrawn in version 25.0. Since 26.0 sessions are persisted to the database by default, which changes the load profile: the dominant cost in the official benchmarks is database CPU.&lt;/p>
&lt;p>&lt;strong>Organisations solves multi-tenancy inside a realm&lt;/strong>, fully supported since 26.0. It groups members, associates email domains and links identity providers per organisation. It is the alternative to replicating realms per customer.&lt;/p>
&lt;p>&lt;strong>The measured bottleneck is password hashing.&lt;/strong> Since 25.0 the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. The official sizing guide gives one vCPU for every 15 password logins per second, against 120 per second for client credentials and for refreshes.&lt;/p>
&lt;p>&lt;strong>Keycloak does not implement two standards the MCP specification marks as mandatory.&lt;/strong> Protected resource metadata belongs to the MCP server, not the authorisation server, so there is nothing to hold against it there. Resource indicators do belong to it and are &lt;strong>not supported&lt;/strong>. Keycloak&amp;rsquo;s own documentation declares partial conformance with the last three MCP revisions for that reason. The practical substitute is scopes plus an audience mapper.&lt;/p>
&lt;p>&lt;strong>There is a recent critical bug worth looking at today.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, scored 9.1, allowed resetting any user&amp;rsquo;s password without authenticating, administrators included. Fixed in 26.7.2, released on 19 August 2026.&lt;/p>
&lt;h2 id="you-are-here-the-cross-cutting-layer">You are here: the cross-cutting layer&lt;/h2>
&lt;p>Identity is not a stage of the pipeline, it is a layer that crosses all of them. It shows up in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a> as one of the seven layers of defence, in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the model&amp;rsquo;s chain of trust&lt;/a> as workload identity, in &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the gateway&lt;/a> as token validation, and in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the post about authenticating MCP servers&lt;/a> as authorisation server.&lt;/p>
&lt;p>What was missing was the post about the piece itself, and above all the decision of where to put it and what to expect from it.&lt;/p>
&lt;h2 id="the-analogy-the-civil-registry-and-building-permits">The analogy: the civil registry and building permits&lt;/h2>
&lt;p>A small town hall has a population register, which says who lives there, and it has permits, which say who can do what. They are two different things and it is worth not confusing them, because the register is good for the whole town while a permit is good for one specific job on one specific street.&lt;/p>
&lt;p>Keycloak is the register. It knows who each person is, identifies them reliably, knows which groups they belong to and issues a credential document that expires. What it does not do is decide whether this person can modify this particular file: that is the permit, and it lives in each service.&lt;/p>
&lt;p>Confusing the two produces the most expensive design mistake in platforms of this kind, which consists of putting into the token everything that will need to be decided later. The register document grows, gets handed out everywhere, and when a permission changes you have to wait for everyone&amp;rsquo;s document to expire.&lt;/p>
&lt;p>There is a third element the analogy captures well. A credential document issued for presenting at the tax office should not be good for entering the municipal depot. That is the token&amp;rsquo;s audience, and it is the central subject of the second half of this post.&lt;/p>
&lt;h2 id="part-1-what-keycloak-is-today">Part 1. What Keycloak is today&lt;/h2>
&lt;h3 id="project-status">Project status&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Latest stable version&lt;/td>
&lt;td>26.7.3, released on 31 August 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Previous ones&lt;/td>
&lt;td>26.7.2 (19 August), 26.7.0 (9 July), 26.6.0 (8 April)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadence&lt;/td>
&lt;td>four minor releases a year&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubating since 10 April 2023, not graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>the current branch; earlier ones do not receive fixes reliably&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That last point deserves underlining because it conditions the upgrade policy. The analysis of 26.7.3 shows that its twenty security fixes were not backported to earlier branches at release time. Operating Keycloak means keeping up to date with the current branch, not staying on a &amp;ldquo;stable&amp;rdquo; version for a year.&lt;/p>
&lt;p>Since 26.0 there is a backward compatibility guarantee across minor versions for features marked as fully supported, and breaking changes are opt-in.&lt;/p>
&lt;h3 id="the-internal-architecture-in-five-pieces">The internal architecture in five pieces&lt;/h3>
&lt;p>&lt;strong>Two startup modes.&lt;/strong> The development one enables HTTP, does not resolve the hostname strictly and disables the theme cache. The production one requires a hostname and TLS, or it will not start. Confusing them in production is the first antipattern on the final list.&lt;/p>
&lt;p>&lt;strong>The build phase.&lt;/strong> The build command materialises the optimisations into the image. Build options, such as the database engine, the enabled features or metrics, are frozen. Runtime ones, such as the database password or the hostname, are applied at startup. Changing a build option at startup forces a rebuild with the corresponding latency cost. The correct pattern in containers is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Extensions via SPI.&lt;/strong> Authenticators, protocol mappers, user storage, event listeners, hashing algorithms and secret vaults. They are packaged as JARs and resolved in the build phase.&lt;/p>
&lt;p>&lt;strong>Infinispan cache.&lt;/strong> Embedded by default, with database-based discovery as the recommended mechanism. The discovery alternatives via Kubernetes DNS, TCP and UDP are deprecated. There are local caches, with default capacities of 10,000 entries in several of them, and replicated caches for sessions, client sessions, offline sessions, authentication sessions, login failures and action tokens. Remote mode, with an external Infinispan, is needed for first-generation multi-site deployment.&lt;/p>
&lt;p>&lt;strong>Relational storage.&lt;/strong> JPA over a database. The alternative engine was removed in version 25.0. The databases supported today include PostgreSQL from 14 to 18, MariaDB and MySQL in their extended support versions, SQL Server 2019 and 2022, Oracle 19c and 23.x, and the managed variants Aurora PostgreSQL and Azure SQL.&lt;/p>
&lt;h3 id="the-minimum-conceptual-model">The minimum conceptual model&lt;/h3>
&lt;p>Before touching anything you need to be clear on eight concepts, and the order in which they relate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: the isolation boundary. Its own users, its own signing keys, its own policies. Two realms share nothing.&lt;/li>
&lt;li>&lt;strong>Client&lt;/strong>: an application that asks for tokens. Public if it cannot keep a secret, such as a browser application; confidential if it can, such as a service; and bearer-only if it merely validates tokens without requesting them.&lt;/li>
&lt;li>&lt;strong>Client scope&lt;/strong>: a reusable bundle of scopes and mappers, assigned to clients as default or as optional. It is the unit with which you bound what a token carries.&lt;/li>
&lt;li>&lt;strong>Protocol mapper&lt;/strong>: what projects an attribute, a role or an audience into the token. The audience mapper is the one that solves the central problem of this post.&lt;/li>
&lt;li>&lt;strong>Role&lt;/strong>, realm or client, and &lt;strong>composite role&lt;/strong>, which aggregates others.&lt;/li>
&lt;li>&lt;strong>Group&lt;/strong>, hierarchical, with inherited roles and attributes.&lt;/li>
&lt;li>&lt;strong>Identity provider&lt;/strong>: federation towards another external OIDC or SAML. &lt;strong>User federation&lt;/strong>: LDAP, Active Directory and Kerberos, with synchronisation and attribute mapping.&lt;/li>
&lt;li>&lt;strong>Authentication flow&lt;/strong>: a sequence of executions with required, alternative, conditional or disabled requirements, and subflows. It is where you build a second factor or a check of your own.&lt;/li>
&lt;/ul>
&lt;p>And one feature that changes architectures: &lt;strong>organisations&lt;/strong>, fully supported since 26.0. It solves multi-tenancy inside a single realm, grouping members, associating email domains and linking identity providers per organisation, so that provider discovery is done by the user&amp;rsquo;s email domain. The classic alternative, one realm per customer, multiplies administration and shares no configuration.&lt;/p>
&lt;h2 id="part-2-who-can-authenticate-against-it">Part 2. Who can authenticate against it&lt;/h2>
&lt;p>This is the table you need before drawing anything. The right-hand column is usually the one that decides.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Protocol&lt;/th>
&lt;th>Cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kubernetes API&lt;/td>
&lt;td>native OIDC, or the structured authentication configuration file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC and JWT, plus UI SSO&lt;/td>
&lt;td>&lt;strong>JWT and SSO above five users: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>static key only&lt;/td>
&lt;td>No OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>generic OIDC and Keycloak provider&lt;/td>
&lt;td>SSO free; per-project access control: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>generic OAuth &lt;strong>yes in the open edition&lt;/strong>&lt;/td>
&lt;td>Team synchronisation: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>generic OIDC and community-maintained Keycloak provider&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, with role management by claim&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>generic OIDC, with Keycloak documented&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>native OIDC or Dex, with groups into its RBAC file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC with role assumption via web identity&lt;/td>
&lt;td>See the note below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications that change decisions.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> The modern mechanism is structured authentication configuration, which allows several JWT issuers plus validations and mappings with CEL expressions. The corresponding proposal, KEP-3331, went alpha in 1.29, beta in 1.30 and is &lt;strong>stable since 1.34&lt;/strong>. It is the route to use today, and it replaces the loose API server flags.&lt;/p>
&lt;p>&lt;strong>vLLM has no identity.&lt;/strong> All there is is a static key via environment variable. This is not a defect to fix, it is an architectural fact: the engine sits behind the gateway and its access control is network-based, not identity-based. Any design that exposes vLLM directly to users is giving up per-person traceability.&lt;/p>
&lt;p>About MinIO a note of honesty is in order: the public documentation now redirects to the commercial edition, so the detail of how it maps policies from a claim is verified there and not in the open edition. Anyone depending on that mechanism should confirm it against their own version before designing on top of it.&lt;/p>
&lt;h2 id="part-3-humans-and-machines">Part 3. Humans and machines&lt;/h2>
&lt;p>They are two different problems and it is worth solving them separately.&lt;/p>
&lt;h3 id="people">People&lt;/h3>
&lt;p>Authorization code with PKCE, with the S256 method. The MCP specification requires it when the client is technically capable, and it goes further: it mandates &lt;strong>rejecting the flow&lt;/strong> if the authorisation server metadata does not publish the supported challenge methods.&lt;/p>
&lt;h3 id="workloads-with-no-person-behind-them">Workloads with no person behind them&lt;/h3>
&lt;p>Here the obvious option is client credentials with a Keycloak service account, authenticated with a secret or, better, with a JWT signed by private key.&lt;/p>
&lt;p>But there is a less known and better option for workloads inside the cluster: &lt;strong>federated client authentication&lt;/strong>, introduced in January 2026 under the banner of doing away with secrets. It lets a client authenticate with a token issued by an external provider, and one of those providers is Kubernetes. The announcement placed full support for the Kubernetes service account variant and for OIDC in version 26.6; the SPIFFE variant, by contrast, expressly says it will stay in preview until the SPIFFE client authentication standard is finalised at the IETF.&lt;/p>
&lt;p>That means a pod can authenticate against Keycloak with the token the kubelet already projects into it, with no additional secret to rotate. It is worth confirming the exact status and the flag name against the release notes of whichever version you have deployed, because the feature is recent and has changed status between 26.5 and 26.6. It is preferable to token exchange, whose standard version is supported but is internal-to-internal only, while the old one, which covered external-to-internal and impersonation, is deprecated and in preview.&lt;/p>
&lt;h3 id="keycloak-against-spiffe">Keycloak against SPIFFE&lt;/h3>
&lt;p>The question always comes up and the answer is that they solve different things with a small overlap.&lt;/p>
&lt;p>SPIRE attests the node and the process, and issues short-lived identity documents with no prior secret. Keycloak does not attest workloads: it trusts a credential someone had to place there, except in the federated authentication case just mentioned, where the one attesting is Kubernetes. In exchange, Keycloak federates people, issues tokens with business data and governs consent, things SPIFFE does not do.&lt;/p>
&lt;p>The practical rule: SPIFFE for service-to-service identity inside the perimeter, Keycloak for anything that has a person behind it or crosses the perimeter. The SPIFFE detail is in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the close of the chain of trust series&lt;/a>.&lt;/p>
&lt;h2 id="part-4-mcp-and-the-two-missing-standards">Part 4. MCP, and the two missing standards&lt;/h2>
&lt;p>This part has the most consequences and is the worst documented outside the primary sources.&lt;/p>
&lt;h3 id="what-the-specification-requires">What the specification requires&lt;/h3>
&lt;p>From revision 2026-07-28, with the normative words as they appear:&lt;/p>
&lt;ul>
&lt;li>The MCP server &lt;strong>must&lt;/strong> implement RFC 9728 protected resource metadata, and the client &lt;strong>must&lt;/strong> use it to discover the authorisation server.&lt;/li>
&lt;li>The client &lt;strong>must&lt;/strong> implement RFC 8707 resource indicators, including the &lt;code>resource&lt;/code> parameter &lt;strong>in both the authorisation request and the token request&lt;/strong>, and &lt;strong>must&lt;/strong> send it regardless of whether the authorisation server supports it.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the access token was issued specifically for it as the audience, and &lt;strong>must not&lt;/strong> accept or relay any other token.&lt;/li>
&lt;li>Authorisation servers and clients &lt;strong>should&lt;/strong> support client identifier metadata documents. RFC 7591 dynamic registration becomes optional and is declared deprecated, retained for compatibility.&lt;/li>
&lt;/ul>
&lt;p>The revision also adds the issuer in the authorisation response, from RFC 9207, and a stateless model with state handlers where it is expressly stated that holding a state handler is not equivalent to being authenticated.&lt;/p>
&lt;h3 id="what-keycloak-supports">What Keycloak supports&lt;/h3>
&lt;p>From its own MCP integration guide:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 and authorisation server metadata&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, issuer in the response&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, dynamic registration&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client identifier metadata document&lt;/td>
&lt;td>Supported, &lt;strong>experimental&lt;/strong>, after enabling the feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, resource indicators&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, protected resource metadata&lt;/strong>&lt;/td>
&lt;td>Out of scope, it belongs to the MCP server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conformance declared by the project itself is &amp;ldquo;partially supported, without resource indicators&amp;rdquo; for the last three MCP revisions.&lt;/p>
&lt;p>Two practical conclusions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The protected resource metadata document is published by the MCP server or a proxy in front of it&lt;/strong>, never by Keycloak. That is correct and there is nothing to fix, but you have to remember to implement it, because the client needs it to discover where to go.&lt;/li>
&lt;li>&lt;strong>Without resource indicators, the audience is bounded with scopes.&lt;/strong> The official route is to use the &lt;code>scope&lt;/code> parameter instead of &lt;code>resource&lt;/code>, and to hang from that client scope an audience mapper with the MCP server URL as the included custom audience.&lt;/li>
&lt;/ol>
&lt;p>It is a workaround, it works, and it has to be documented in the design because anyone coming after will look for the standard parameter and will not find it.&lt;/p>
&lt;h2 id="part-5-the-confused-deputy">Part 5. The confused deputy&lt;/h2>
&lt;p>With the above on the table, the classic mistake becomes obvious on its own. A token with a generic audience turns any compromised service into a master key: whoever obtains the token a user presented to the dashboard can present it to the gateway, and from there to the MCP server.&lt;/p>
&lt;p>The MCP specification is explicit in three sentences worth quoting verbatim in any architecture document:&lt;/p>
&lt;ul>
&lt;li>MCP servers &lt;strong>must not&lt;/strong> accept tokens that were not explicitly issued for them.&lt;/li>
&lt;li>If the MCP server calls upstream APIs, it &lt;strong>must not&lt;/strong> forward the token it received from the client.&lt;/li>
&lt;li>MCP proxies that use static client identifiers &lt;strong>must&lt;/strong> obtain user consent for each dynamically registered client before forwarding to third-party authorisation servers.&lt;/li>
&lt;/ul>
&lt;p>Translated into the specific figure this track has been dealing with: &lt;strong>the gateway must not forward the user&amp;rsquo;s JWT to third-party MCP servers&lt;/strong>. It has to obtain a different token with the right audience, or act as an OAuth client in its own right. By default, as already documented &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>, the behaviour is to use the server&amp;rsquo;s own credential, which is the correct one; there are two modes that forward the caller&amp;rsquo;s token and both have a name in the specification.&lt;/p>
&lt;h2 id="part-6-two-hours-of-task-against-five-minutes-of-token">Part 6. Two hours of task against five minutes of token&lt;/h2>
&lt;p>The specification pushes towards short-lived tokens and mandatory refresh token rotation in public clients. A two-hour agentic task comfortably exceeds the life of an access token and may also exceed session idle time if nobody refreshes.&lt;/p>
&lt;p>Three real options, with their cost:&lt;/p>
&lt;p>&lt;strong>Offline tokens.&lt;/strong> They survive session expiry, and expire through their own idle timeout or through a maximum if that limit is enabled. The risk is obvious: a near-permanent credential, hard to inventory. And the MCP specification adds that MCP servers &lt;strong>should not&lt;/strong> include that scope either in the authentication header or in the supported scopes of their metadata.&lt;/p>
&lt;p>&lt;strong>Automatic refresh in the client.&lt;/strong> It is the right answer when there is a client that can implement it, with refresh token rotation and secure storage. For an agent with no person behind it, it is better to renew via client credentials.&lt;/p>
&lt;p>&lt;strong>Long-lived virtual keys in the gateway.&lt;/strong> It works, and it is what most people end up doing. The price has to be written down in the risk analysis: identity ends up decoupled from the provider, so deactivating someone in Keycloak does not invalidate their key, and traceability back to the real person depends on the discipline with which the keys were created. The levers that compensate are expiry, automatic rotation and a mandatory alias, covered &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">in the virtual keys post&lt;/a>.&lt;/p>
&lt;p>A note of honesty about the default values. The numbers going around (five minutes of access token, thirty of session idle time, ten hours of maximum) do not appear published in the administration guide and I did not locate them in the code. Treat them as a console observation and confirm them in your own realm before quoting them in a document.&lt;/p>
&lt;h2 id="part-7-sizing-and-operating">Part 7. Sizing and operating&lt;/h2>
&lt;h3 id="on-kubernetes">On Kubernetes&lt;/h3>
&lt;p>The operator exposes three custom resources: the server, the realm import and the OIDC client. Probes and metrics live on &lt;strong>management port 9000&lt;/strong>, with the health and readiness paths, and metrics in Prometheus format provided the corresponding option was enabled in the build phase. That port is not exposed publicly.&lt;/p>
&lt;p>For high availability there are three shapes. A single cluster spread across zones, with no additional infrastructure. First-generation multi-cluster, with an external load balancer and an external Infinispan at each site. And second-generation multi-cluster, introduced in 26.7.0 and still in preview, which connects clusters &lt;strong>without external Infinispan&lt;/strong>, using the synchronously replicated database as the source of truth, with less cache and more database load.&lt;/p>
&lt;h3 id="the-official-sizing-figures">The official sizing figures&lt;/h3>
&lt;p>From the CPU and memory sizing concepts guide, which is still published:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU for every 15 password logins per second&lt;/strong>, tested up to 300 per second.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 client credential grants per second&lt;/strong>, tested up to 2,000.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 token refresh requests per second&lt;/strong>, tested up to 435.&lt;/li>
&lt;li>Leave a &lt;strong>150 % CPU headroom&lt;/strong> for spikes, startup and failover.&lt;/li>
&lt;li>&lt;strong>1,250 MB of memory per pod&lt;/strong> with realm data and 10,000 cached sessions. Keycloak allocates 70 % of the limit to the heap and around 300 MB outside it, so the limit comes from subtracting those 300 MB from the expected usage and dividing by 0.7.&lt;/li>
&lt;/ul>
&lt;p>The stated assumptions behind those figures include Argon2 with five iterations, sessions in the database and the default cache of 10,000 entries.&lt;/p>
&lt;p>The difference between 15 and 120 is the whole story of Keycloak&amp;rsquo;s performance: &lt;strong>the cost is in password hashing&lt;/strong>. Since 25.0, the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. Before it was PBKDF2, whose iterations were multiplied by ten in version 24. In FIPS mode PBKDF2 is still used.&lt;/p>
&lt;p>From that comes an architectural consequence for an AI platform: &lt;strong>machine workloads, which go through client credentials, cost eight times less than a human login&lt;/strong>. Sizing by number of services is cheap; sizing by morning login peaks is the expensive part.&lt;/p>
&lt;h3 id="the-benchmark">The benchmark&lt;/h3>
&lt;p>From the 26.4 performance report, published on 1 October 2025: up to 2,000 logins per second and 10,000 refreshes per second with three pods, 74 vCPU in total and 8 GB per pod, over an Aurora PostgreSQL database, with almost linear vertical scaling.&lt;/p>
&lt;p>Two useful findings from that report. With 20 ms of round-trip time between sites, the 99th percentile rises from 51 to 130 ms, and the project &lt;strong>advises against deploying across different regions&lt;/strong>. And raising the cache from 10,000 to 200,000 entries brought the database CPU peak down from 77.77 % to 63.77 %, in exchange for somewhat longer garbage collection pauses.&lt;/p>
&lt;h3 id="security">Security&lt;/h3>
&lt;p>The urgent matter, if the deployment is below 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, score 9.1, critical.&lt;/strong> A bypass of the email action token in the credential reset flow allowed resetting any user&amp;rsquo;s password with no prior authentication, administrators included. Fixed in 26.7.2, released on 19 August 2026. The temporary mitigation, if you cannot upgrade, is to disable password recovery in all realms. No known exploitation was recorded as of the end of August.&lt;/p>
&lt;p>In 26.7.3 there are another nineteen, among them the missing hostname verification in the TLS certificate towards LDAP with a score of 8.8, two tenant restriction bypasses in token exchange with Microsoft and Google brokers, and two access control bypasses in organisations.&lt;/p>
&lt;p>Official hardening comes down to four decisions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hostname&lt;/strong>, which is the default value since the second generation of that configuration. The server does not infer the URL from headers, which prevents poisoning email links and redirects.&lt;/li>
&lt;li>&lt;strong>Administration console on a different hostname&lt;/strong>, and network-restricted.&lt;/li>
&lt;li>&lt;strong>Proxy headers declared explicitly&lt;/strong>, trusting only a controlled reverse proxy.&lt;/li>
&lt;li>&lt;strong>Separate management port, not exposed.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="part-8-the-mapping-to-the-ens">Part 8. The mapping to the ENS&lt;/h2>
&lt;p>The codes and titles below are verified against the official text of Royal Decree 311/2022 published in the BOE, Annex II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Code&lt;/th>
&lt;th>Literal title&lt;/th>
&lt;th>What a central IdP contributes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>A unique identifier per entity, user or process that gains access&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Permissions are granted by role and group, not by loose credential&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>It supports it with roles, but the control is organisational&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Least privilege and a specific remote access policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flows and factors configurable per flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Reinforcements required already at the low level&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>It contributes part of the events, not all of them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Management and rotation of the realm signing keys&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two nuances an auditor is going to look for.&lt;/p>
&lt;p>The first, about &lt;strong>op.acc.1&lt;/strong>: the text requires every entity that accesses the system, whether user or process, to have a unique identifier. That includes agents. A virtual key shared between several agents does not comply, and that is exactly what usually happens when JWT authentication falls outside the budget.&lt;/p>
&lt;p>The second, about &lt;strong>op.exp.8&lt;/strong>: the control requires recording the identifier of the user or entity associated with the event, the date and time, what information it is performed on, the type and the result. Keycloak contributes the authentication events. The usage events are contributed by the gateway and the MCP servers, and there the two already documented gaps reappear: &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">adding and removing MCP servers generates no audit log&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the trace user is set by the client&lt;/a>.&lt;/p>
&lt;p>The correspondence between control and product in this table is my own interpretation and must be validated by the certification body. The codes and titles are not: those are literal from the BOE.&lt;/p>
&lt;h2 id="part-9-the-configuration-to-leave-written-down">Part 9. The configuration to leave written down&lt;/h2>
&lt;p>Three concrete pieces, so as not to stay at the level of concepts.&lt;/p>
&lt;p>&lt;strong>Hardened startup&lt;/strong>, as container arguments:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>The metrics, health, cache and database options are build options: they go into the image, and passing them here forces a rebuild if they do not match.&lt;/p>
&lt;p>&lt;strong>The client scope with audience&lt;/strong>, which is the workaround forced by the lack of resource indicators. Created with the administration tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># one scope per protected resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the mapper that sets the token audience&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for that scope and receives a token whose audience is only that MCP server. The MCP server validates the audience and rejects any other token, as the specification requires.&lt;/p>
&lt;p>&lt;strong>Realm import via GitOps&lt;/strong>, with the operator&amp;rsquo;s custom resource, so that the configuration does not live only in the console:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the recovery email is attack surface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="part-10-what-keycloak-does-not-solve">Part 10. What Keycloak does not solve&lt;/h2>
&lt;p>&lt;strong>Fine-grained authorisation over resources.&lt;/strong> The question &amp;ldquo;can this person see this particular trace&amp;rdquo; is a relation between objects, not a role. That problem is solved by Zanzibar-style engines, with OpenFGA and its object, relation and user tuples, or a policy language such as Cedar. Keycloak gives roles and scopes, and with those you do not model a graph of relations.&lt;/p>
&lt;p>&lt;strong>Machine secrets.&lt;/strong> Storage credentials, provider keys, database passwords. That is a secrets manager, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Cryptographic workload identity with attestation.&lt;/strong> That is SPIFFE and SPIRE. In Keycloak it exists as an identity provider in preview, which is a different thing.&lt;/p>
&lt;p>&lt;strong>Authorisation inside the model.&lt;/strong> Which MCP tool an agent may invoke, and with what arguments. The specification offers scopes, the insufficient scope error and step-up elevation, but the per-tool and per-argument decision is taken by the MCP server or a policy engine, not by the identity provider.&lt;/p>
&lt;h2 id="antipatterns">Antipatterns&lt;/h2>
&lt;ol>
&lt;li>Forwarding the user&amp;rsquo;s token from the gateway to third-party MCP servers. Explicitly forbidden by the specification.&lt;/li>
&lt;li>Static client identifier with dynamic registration and no per-client consent, which is the exact recipe for the confused deputy.&lt;/li>
&lt;li>General scopes, of the full-access kind, and publishing the entire catalogue of supported scopes in the metadata.&lt;/li>
&lt;li>Treating a state handler as authentication, which the new revision expressly forbids.&lt;/li>
&lt;li>Following OAuth discovery towards any URL, which opens the door to requests towards internal addresses. It also applies to the authorisation server when it downloads a client metadata document.&lt;/li>
&lt;li>Trust headers badly exposed in chat front ends. The Open WebUI documentation warns that an incorrect configuration allows authenticating as any user.&lt;/li>
&lt;li>Starting in development mode in production, with the hostname not resolved strictly.&lt;/li>
&lt;li>A single shared static key as the only control between the gateway and the engine.&lt;/li>
&lt;/ol>
&lt;h2 id="startup-checklist">Startup checklist&lt;/h2>
&lt;ol>
&lt;li>Check the version. Below 26.7.2 there is a critical password reset bug.&lt;/li>
&lt;li>Build the image with the build phase done and start with the optimised flag.&lt;/li>
&lt;li>Strict hostname, administration console on another hostname, separate management port and not exposed.&lt;/li>
&lt;li>Decide between organisations and one realm per tenant before creating the second tenant.&lt;/li>
&lt;li>One client scope per resource, with an audience mapper, because resource indicators are not supported.&lt;/li>
&lt;li>In the gateway, define the two audience and issuer variables. Without them none of that is validated.&lt;/li>
&lt;li>For workloads inside the cluster, look at the Kubernetes identity provider before handing out client secrets.&lt;/li>
&lt;li>Size by login peaks, not by number of services: the difference is eightfold.&lt;/li>
&lt;li>Export the Keycloak events to the same place the gateway&amp;rsquo;s go, because the activity logging control needs them together.&lt;/li>
&lt;li>Write into the design what the system does with a two-hour task, before someone solves the problem with an offline token.&lt;/li>
&lt;/ol>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Keycloak is a mature piece that solves one bounded problem well: knowing who the caller is, and saying so verifiably in a document that expires. In a sovereign inference platform that is worth a lot, because the alternative is an inventory of static keys spread across ten services that no audit can walk through.&lt;/p>
&lt;p>What is not advisable is asking more of it. Fine-grained authorisation lives in each service, workload identity with attestation lives in SPIFFE, and the decision of which tool an agent may call lives in the MCP server.&lt;/p>
&lt;p>And there is one concrete gap worth noting down, because it does not close on its own: the MCP specification marks two standards as mandatory, and of the two that would fall to an authorisation server, resource indicators are not implemented. The workaround with scopes and an audience mapper works, but it is a workaround, and whoever comes after will look for the standard parameter. Leaving it written in the design saves an afternoon.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: how the gateway validates the tokens this piece issues, and the two variables you have to define.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the concrete setup for MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: SPIFFE, SPIRE and workload identity with attestation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: where identity fits among the seven layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a>: the alternative when JWT falls outside the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a>: the follow-up: the protected resource side you have to build, token exchange and the enterprise authorisation extension.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The gateway does not live alone: the JWT that does not validate the audience, the user who never reaches the trace, and the seams you have to write by hand</title><link>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</link><pubDate>Sat, 12 Sep 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</guid><description>&lt;blockquote>
&lt;p>Eighth article in the operational track of the control layer. The previous seven deal with the gateway from the inside: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a>. This one deals with what surrounds it. Verified against LiteLLM 1.102.0 and Langfuse 4.35.0, both with a commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The gateway has five neighbours: a database, a cache, an identity provider, a trace backend and a collector. Each neighbourhood has its own physics, and these are the seven things it decides.&lt;/p>
&lt;p>&lt;strong>The proxy starts without Postgres, but then only the master key works.&lt;/strong> The &lt;code>DATABASE_URL&lt;/code> variable is optional (&lt;code>proxy_server.py:1163&lt;/code>); without it, any key other than the master one returns a 400 for a missing connection (&lt;code>user_api_key_auth.py:1886&lt;/code>). With a database configured but down, the default behaviour is to return 503, unless &lt;code>general_settings.allow_requests_on_db_unavailable&lt;/code> is switched on.&lt;/p>
&lt;p>&lt;strong>Redis is never mandatory, and that is precisely the trap.&lt;/strong> There is a circuit breaker with a threshold of five failures and a recovery of sixty seconds (&lt;code>constants.py:456&lt;/code>). When it opens, the proxy fails no request: it degrades to local memory. What is lost silently is that rate limits stop being global and become per pod.&lt;/p>
&lt;p>&lt;strong>No observability backend can bring a request down.&lt;/strong> All callbacks are launched with &lt;code>asyncio.create_task&lt;/code> and their exceptions are caught and logged (&lt;code>litellm_logging.py:3235&lt;/code>). If Langfuse does not respond, events are discarded as the queue fills up. There is no added latency, and there is no reliable record either: the pair&amp;rsquo;s observability is a statistical figure, not an audit record.&lt;/p>
&lt;p>&lt;strong>JWT authentication validates neither the audience nor the issuer unless two variables are defined.&lt;/strong> In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>) the audience comes from &lt;code>JWT_AUDIENCE&lt;/code> and the issuer from &lt;code>JWT_ISSUER&lt;/code>; if they are empty, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code> and emits a warning once only. Any valid token signed by that provider, issued for another service, is accepted.&lt;/p>
&lt;p>&lt;strong>Role-based access control ships disabled.&lt;/strong> &lt;code>enforce_rbac&lt;/code> has a default value of &lt;code>False&lt;/code> (&lt;code>_types.py:4848&lt;/code> onwards). With that value, a token whose role resolves to nothing does not produce a 403: it carries on through the flow. Real authorisation, then, comes from team membership, if there is a team claim configured at all.&lt;/p>
&lt;p>&lt;strong>The user identifier that reaches Langfuse is not the one from Keycloak.&lt;/strong> The &lt;code>user_id&lt;/code> field of the trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code>), that is, with the &lt;code>user&lt;/code> field of the OpenAI body, which the client sets. The subject of the token and the owner of the key do not travel. Anyone who wants per-person traceability needs to configure &lt;code>end_user_id_jwt_field&lt;/code> or make the client send that field.&lt;/p>
&lt;p>&lt;strong>Langfuse provisions nothing from a claim.&lt;/strong> There is no variable that maps an OIDC claim to an organisation or to a project. The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code>, the same for everyone. On top of that, per-project access control sits behind the Enterprise licence in the self-hosted edition, as do audit logs and per-project retention.&lt;/p>
&lt;h2 id="you-are-here-between-the-four-pieces">You are here: between the four pieces&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse pair&lt;/a> walked through the seam between the gateway and the trace backend: integration routes, cost that arrives as zero, correlation and queues with discarding. This article widens the focus to the other three neighbourhoods, with identity at the centre, because that is the one that orders everything else and the one an auditor asks about.&lt;/p>
&lt;p>The identity piece itself, that is, Keycloak, is dealt with in &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">the next article&lt;/a>. Here we deal with the gateway side.&lt;/p>
&lt;h2 id="the-analogy-the-building-site-with-five-trades">The analogy: the building site with five trades&lt;/h2>
&lt;p>On a small building site, the problem is rarely inside each trade&amp;rsquo;s work, it is at the joins: where the plumber leaves the pipe sleeve and the bricklayer closes up the partition wall, where the electrician wants to run through the same gap. Each trade does its part well and the join ends up badly resolved because it belongs to nobody.&lt;/p>
&lt;p>An inference platform has five trades. The gateway knows about tokens, the identity provider knows about people, the trace backend knows about events, the database knows about spend and the cache knows about counters. Each one has good documentation. The joins have nobody&amp;rsquo;s documentation, and that is where the user&amp;rsquo;s identity gets lost between the token and the trace.&lt;/p>
&lt;p>There is one more detail of the analogy that is useful. On a building site, the part that causes the most problems is not the one that looks badly finished, it is the one that gets covered up. The same happens here: the five seams in this article fail silently.&lt;/p>
&lt;h2 id="seam-1-what-brings-things-down-and-what-degrades-them">Seam 1. What brings things down and what degrades them&lt;/h2>
&lt;p>It pays to have written down what happens when each neighbour falls over, because intuition fails in almost every case.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Neighbour&lt;/th>
&lt;th>Does it start without it?&lt;/th>
&lt;th>If it falls over&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>Yes, master key only&lt;/td>
&lt;td>503 by default; degraded with &lt;code>allow_requests_on_db_unavailable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Circuit breaker after 5 failures; limits become per pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Events discarded as the queue fills; no impact on latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Same as the previous one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity provider&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Access to the interface and the JWT flow goes down; virtual keys carry on&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>Without &lt;code>DATABASE_URL&lt;/code>, &lt;code>_setup_prisma_client&lt;/code> returns &lt;code>None&lt;/code> without error (&lt;code>proxy_server.py:10375&lt;/code>). The proxy serves &lt;code>/chat/completions&lt;/code> with the master key. Any other key gets a 400. There is no global budget either, and start-up itself warns about it: Redis does not replace the database (&lt;code>proxy_server.py:9240&lt;/code>).&lt;/p>
&lt;p>With a database configured and down, the exception handler re-raises unless the degradation flag is active (&lt;code>db/exception_handler.py:56&lt;/code>). There is also a &lt;code>DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP&lt;/code> and a watcher that reconnects. The detail that matters for diagnosis: &lt;strong>a transient database failure returns 503, not 401&lt;/strong>. If during an incident you see 401s, the problem is credentials; if you see 503s, it is the database or admission.&lt;/p>
&lt;h3 id="redis">Redis&lt;/h3>
&lt;p>The circuit breaker is enabled by default, with a threshold of five failures, a recovery of sixty seconds and a minimum duration of five (&lt;code>constants.py:456&lt;/code>, logic in &lt;code>caching/redis_cache.py:153&lt;/code>). The socket has a timeout of 0.1 seconds (&lt;code>constants.py:453&lt;/code>). With the circuit open, &lt;code>DualCache&lt;/code> serves from memory without propagating an exception (&lt;code>dual_cache.py:215&lt;/code>).&lt;/p>
&lt;p>The consequence has to be measured, not assumed: with four workers and three replicas, a limit of one hundred requests per minute becomes one thousand two hundred. It is a failure mode that generates no errors and that can last for days without anyone noticing, until the bill arrives or the engine saturates.&lt;/p>
&lt;p>The right alert is not about Redis, it is about &lt;code>litellm_service_latency&lt;/code> labelled by service (&lt;code>integrations/prometheus_services.py:119&lt;/code>), which exposes Redis and database latency separately.&lt;/p>
&lt;h3 id="the-callbacks">The callbacks&lt;/h3>
&lt;p>Everything behind, that is, Langfuse and the collector, is launched outside the response path and its failures are caught. The classic integration uses the v2 SDK with its own background thread and a flush interval of one second, adjustable with &lt;code>LANGFUSE_FLUSH_INTERVAL&lt;/code> (&lt;code>langfuse.py:198&lt;/code>). There is a cap of fifty instantiated Langfuse clients (&lt;code>constants.py:554&lt;/code>), because each client is a thread.&lt;/p>
&lt;p>The OTLP integration uses the batch processor of the OpenTelemetry SDK (&lt;code>opentelemetry.py:3060&lt;/code>) with its default values. In both cases, saturation means discarding, and discarding generates no error towards the client.&lt;/p>
&lt;h2 id="seam-2-identity-the-three-doors-and-what-each-one-validates">Seam 2. Identity: the three doors and what each one validates&lt;/h2>
&lt;p>Everything goes through a single authentication constructor (&lt;code>user_api_key_auth.py:1245&lt;/code>), in this order:&lt;/p>
&lt;ol>
&lt;li>Custom authentication from the enterprise edition.&lt;/li>
&lt;li>Public routes.&lt;/li>
&lt;li>Opaque OAuth2, if enabled, with a licence check.&lt;/li>
&lt;li>JWT, if &lt;code>general_settings.enable_jwt_auth&lt;/code> is active and the token has three parts.&lt;/li>
&lt;li>Master key, with constant-time comparison.&lt;/li>
&lt;li>Virtual key, with SHA-256 hash and lookup in the verification table.&lt;/li>
&lt;/ol>
&lt;p>A JWT works for &lt;code>/chat/completions&lt;/code>, not just for administration: the routes allowed by default for a team include &lt;code>openai_routes&lt;/code> (&lt;code>_types.py:4886&lt;/code>), which contains the chat endpoint. Administration, on the other hand, is only reachable by a token with an administrator role.&lt;/p>
&lt;h3 id="the-first-thing-to-know-it-is-paid-for">The first thing to know: it is paid for&lt;/h3>
&lt;p>The JWT branch carries an explicit licence check, with the literal message that JWT authentication is an enterprise-only feature (&lt;code>user_api_key_auth.py:1416&lt;/code>). SSO for the administration interface is free up to five billable users and requires a licence above that (&lt;code>ui_sso.py:976&lt;/code>).&lt;/p>
&lt;p>This conditions the architecture of any sovereign deployment that does not want to pay for a licence: the Keycloak integration is left for the administration interface and for provisioning, while inference traffic authenticates with virtual keys. Virtual keys are not worse, but they are a different thing, and it has to be said in the risk analysis: they are credentials decoupled from the identity provider, so removing a person from Keycloak does not invalidate their key.&lt;/p>
&lt;h3 id="the-litellm_jwtauth-fields-that-decide-security">The &lt;code>litellm_jwtauth&lt;/code> fields that decide security&lt;/h3>
&lt;p>From the &lt;code>LiteLLM_JWTAuth&lt;/code> class (&lt;code>_types.py:4848&lt;/code> onwards), these are the ones that matter, with their default values:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>enforce_rbac&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, an unresolved role does not produce a 403&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_scope_based_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Without this, &lt;code>scope_mappings&lt;/code> is not applied&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_team_based_model_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Model access by team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_jwt_field&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Claim the team comes from&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, a team that does not exist gives a 404&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Same with the user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>600&lt;/code>&lt;/td>
&lt;td>Cache of the public keys&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_stale_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>3600&lt;/code>&lt;/td>
&lt;td>Extra margin if the provider is down&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>admin_jwt_scope&lt;/code>&lt;/td>
&lt;td>&lt;code>litellm_proxy_admin&lt;/code>&lt;/td>
&lt;td>Scope that grants administration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_allowed_email_domain&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Restriction by email domain&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The accepted algorithms are RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA (&lt;code>handle_jwt.py:163&lt;/code>). There is no symmetric one, which eliminates the algorithm confusion class of attack. It is a good design decision and it deserves recognition.&lt;/p>
&lt;p>Key download supports several comma-separated URLs in &lt;code>JWT_PUBLIC_KEY_URL&lt;/code>, resolves discovery if the URL points to the well-known OpenID configuration, retries three times with increasing waits and memoises the failure for thirty seconds (&lt;code>handle_jwt.py:680&lt;/code>, &lt;code>:751&lt;/code>, &lt;code>:88&lt;/code>). If the provider is down, it serves the stale copy up to the sum of the two TTLs, that is, up to seventy minutes. For availability that is fine; for revocation it is not, and it has to be written down.&lt;/p>
&lt;p>One detail of key rotation: with more than one key in the set, an exact match of the key identifier is required, but &lt;strong>with a single key and no identifier it is accepted without checking&lt;/strong> (&lt;code>handle_jwt.py:913&lt;/code>).&lt;/p>
&lt;h3 id="the-validation-that-does-not-happen">The validation that does not happen&lt;/h3>
&lt;p>This is the part to fix on day one. In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>), the audience is taken from the &lt;code>JWT_AUDIENCE&lt;/code> environment variable and the issuer from &lt;code>JWT_ISSUER&lt;/code>. If they are not defined, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code>, and emits a warning once only.&lt;/p>
&lt;p>What that means in a real deployment with Keycloak: a token issued for the Grafana client, or for the Backstage one, signed by the same realm, is accepted by the gateway as a valid credential. Expiry is always validated, with zero margin. So is the signature. The audience is not.&lt;/p>
&lt;p>There is a second route, &lt;code>issuers&lt;/code>, with per-issuer configuration, which does validate issuer and audience unless explicitly disabled (&lt;code>handle_jwt.py:1186&lt;/code>). That is the one to use when there is more than one provider.&lt;/p>
&lt;p>The minimum fix is two lines of environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Keycloak side, an audience mapper in a dedicated client scope that injects that audience. The detail is &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>, because the standard that would resolve this cleanly, resource indicators, is not supported.&lt;/p>
&lt;h3 id="from-claim-to-permission">From claim to permission&lt;/h3>
&lt;p>The administrator role comes from &lt;code>admin_jwt_scope&lt;/code> appearing in the &lt;code>scope&lt;/code> claim, and it returns a result without going through a team (&lt;code>handle_jwt.py:309&lt;/code>, &lt;code>:1373&lt;/code>). The rest is resolved by team.&lt;/p>
&lt;p>If the team in the claim does not exist in the database, with &lt;code>team_id_upsert&lt;/code> false a 404 is returned with the message that the team has to be created (&lt;code>auth_checks.py:3035&lt;/code>). With the flag active, it is created by invoking team creation with a synthetic administrator identity (&lt;code>auth_checks.py:2874&lt;/code>). That is the only automatic provisioning route that exists from Keycloak towards the gateway, and it works well. It pays to know that it creates teams with no budget and no limits, so a default value is needed in the configuration.&lt;/p>
&lt;h3 id="the-vulnerability-worth-knowing-about">The vulnerability worth knowing about&lt;/h3>
&lt;p>&lt;code>CVE-2026-35030&lt;/code>, scoring 9.1 on CVSS 3.1 and 9.4 on CVSS 4.0, advisory &lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code>. With JWT authentication active, the cache of the OIDC user info endpoint used the first twenty characters of the token as the key, so that two tokens with the same prefix were confused with each other. Fixed in 1.83.0.&lt;/p>
&lt;p>In 1.102.0 the code uses the full SHA-256 hash (&lt;code>handle_jwt.py:962&lt;/code>). A search for similar patterns across the tree finds no authentication cache indexed by token prefix: the truncations that remain are masked for logging. And it is worth recalling what has already been published &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">about the MCP gateway&lt;/a>: below 1.83.14 there is unauthenticated remote execution or SQL injection.&lt;/p>
&lt;h2 id="seam-3-identity-does-not-reach-the-trace">Seam 3. Identity does not reach the trace&lt;/h2>
&lt;p>Here lies the most expensive design flaw in the arrangement, because it is discovered at the first audit.&lt;/p>
&lt;p>The &lt;code>user_id&lt;/code> field of a Langfuse trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code> and &lt;code>:724&lt;/code>), which comes from the &lt;code>user&lt;/code> field of the OpenAI request body or from &lt;code>end_user_id_jwt_field&lt;/code> (&lt;code>litellm_pre_call_utils.py:1584&lt;/code>). It is not the subject of the token nor the owner of the virtual key.&lt;/p>
&lt;p>The knock-on consequences:&lt;/p>
&lt;ul>
&lt;li>If the client does not send &lt;code>user&lt;/code>, the trace has no user.&lt;/li>
&lt;li>If the client does send &lt;code>user&lt;/code>, the trace has whatever value the client cares to put there, unverified.&lt;/li>
&lt;li>An agent calling with a service key leaves traces with no person behind them.&lt;/li>
&lt;/ul>
&lt;p>There are three ways to fix it, in order of robustness:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Configure &lt;code>end_user_id_jwt_field&lt;/code>&lt;/strong> so that the end user comes from a token claim and not from the body. It is the only option in which the value is not controlled by the client, and it requires JWT authentication, that is, a licence.&lt;/li>
&lt;li>&lt;strong>Force the &lt;code>trace_user_id&lt;/code> metadata field&lt;/strong>, which overwrites the field (&lt;code>langfuse.py:726&lt;/code>; on the OTLP route, &lt;code>langfuse_otel.py:96&lt;/code>). It can be set per team.&lt;/li>
&lt;li>&lt;strong>Contractually require the client to send &lt;code>user&lt;/code>&lt;/strong> and validate it in a guardrail. It works, but it is a client declaration, not a proof.&lt;/li>
&lt;/ol>
&lt;p>Something similar happens towards the engine. The caller&amp;rsquo;s identity does not travel to vLLM unless &lt;code>litellm.add_user_information_to_llm_headers&lt;/code> is switched on, whose default value is &lt;code>None&lt;/code> (&lt;code>litellm/__init__.py:223&lt;/code>). With it, &lt;code>x-litellm-*&lt;/code> headers are injected with user identifier, team identifier and key hash (&lt;code>litellm_pre_call_utils.py:1356&lt;/code>). Forwarding client headers requires &lt;code>general_settings.forward_client_headers_to_llm_api&lt;/code>, and the &lt;code>authorization&lt;/code> header is always filtered out, which is correct.&lt;/p>
&lt;p>Towards MCP servers, by default the server&amp;rsquo;s own credential is used, not the user&amp;rsquo;s token. Only two modes forward the caller&amp;rsquo;s token, and doing so has a name in the MCP specification: token passthrough, which is explicitly forbidden. It comes up again &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>.&lt;/p>
&lt;h2 id="seam-4-langfuse-as-a-neighbour">Seam 4. Langfuse as a neighbour&lt;/h2>
&lt;h3 id="what-can-be-automated-and-what-cannot">What can be automated and what cannot&lt;/h3>
&lt;p>The exact list of variables for SSO in Langfuse 4.x, taken from &lt;code>web/src/env.mjs&lt;/code>:&lt;/p>
&lt;p>With the Keycloak provider: &lt;code>AUTH_KEYCLOAK_CLIENT_ID&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_SECRET&lt;/code>, &lt;code>AUTH_KEYCLOAK_ISSUER&lt;/code>, &lt;code>AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_AUTH_METHOD&lt;/code>, &lt;code>AUTH_KEYCLOAK_CHECKS&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG&lt;/code>, &lt;code>AUTH_KEYCLOAK_SCOPE&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN&lt;/code>, &lt;code>AUTH_KEYCLOAK_NAME&lt;/code>.&lt;/p>
&lt;p>With generic OIDC: the same set with the &lt;code>AUTH_CUSTOM_&lt;/code> prefix, plus &lt;code>AUTH_CUSTOM_FETCH_USERINFO&lt;/code> and claim mapping with &lt;code>LANGFUSE_CUSTOM_SSO_SUB_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_NAME_CLAIM&lt;/code> and &lt;code>LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM&lt;/code>.&lt;/p>
&lt;p>Global ones: &lt;code>AUTH_DISABLE_USERNAME_PASSWORD&lt;/code>, &lt;code>AUTH_DISABLE_SIGNUP&lt;/code>, &lt;code>AUTH_DOMAINS_WITH_SSO_ENFORCEMENT&lt;/code>, &lt;code>AUTH_SESSION_MAX_AGE&lt;/code>.&lt;/p>
&lt;p>And now what is missing. &lt;strong>There is no variable that maps a claim to an organisation or a project.&lt;/strong> The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code>, &lt;code>LANGFUSE_DEFAULT_ORG_ROLE&lt;/code> with a default value of &lt;code>VIEWER&lt;/code>, &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ROLE&lt;/code> (&lt;code>features/auth/lib/createProjectMembershipsOnSignup.ts:61&lt;/code>). The documentation itself confirms it: enterprise SSO does not provision roles automatically at sign-up.&lt;/p>
&lt;p>On top of that, in the per-edition permissions table (&lt;code>features/entitlements/constants/entitlements.ts:139&lt;/code>), the open and self-hosted professional editions &lt;strong>do not include&lt;/strong> per-project access control or the administration API. They belong to the enterprise edition, as do audit logs and per-project retention. In the open edition, every user inherits the role of their organisation.&lt;/p>
&lt;h3 id="the-declarative-bootstrap-which-does-exist">The declarative bootstrap, which does exist&lt;/h3>
&lt;p>For on-premise without a licence, the workable route is bootstrapping through variables (&lt;code>web/src/initialize.ts&lt;/code>), which is idempotent: &lt;code>LANGFUSE_INIT_ORG_ID&lt;/code> (mandatory, if missing the rest is ignored with a warning), &lt;code>LANGFUSE_INIT_ORG_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_ID&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_RETENTION&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_SECRET_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_USER_EMAIL&lt;/code>, &lt;code>LANGFUSE_INIT_USER_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_USER_PASSWORD&lt;/code>.&lt;/p>
&lt;p>With that, one project per tenant is created from GitOps with one initialisation container per tenant and the keys in Kubernetes secrets.&lt;/p>
&lt;h3 id="langfuse-credentials-per-team">Langfuse credentials per team&lt;/h3>
&lt;p>This part is well resolved on the gateway side and few people use it. &lt;code>TeamCallbackMetadata.callback_vars&lt;/code> (&lt;code>_types.py:2152&lt;/code>) accepts &lt;code>langfuse_public_key&lt;/code>, &lt;code>langfuse_secret_key&lt;/code>, &lt;code>langfuse_host&lt;/code> and &lt;code>langfuse_environment&lt;/code>, with an allow list in &lt;code>initialize_dynamic_callback_params.py:67&lt;/code>. The gateway caches one logger per credential set (&lt;code>langfuse_handler.py:24&lt;/code>) and, on the OTLP route, builds a full exporter per key (&lt;code>langfuse_otel.py:400&lt;/code>).&lt;/p>
&lt;p>That is to say: &lt;strong>one Langfuse project per LiteLLM team is possible today&lt;/strong>, without a licence, and it is the correct way to isolate traces between tenants. What does not exist is automatic project creation, nor the correspondence between the team and the project. That is glue you have to write.&lt;/p>
&lt;h3 id="the-ingestion-route">The ingestion route&lt;/h3>
&lt;p>Langfuse accepts &lt;strong>only OTLP over HTTP&lt;/strong>, in &lt;code>http/protobuf&lt;/code> or &lt;code>http/json&lt;/code>. gRPC is not supported. The base route is &lt;code>/api/public/otel&lt;/code> and the signal lives at &lt;code>/api/public/otel/v1/traces&lt;/code>, verified in the repository. Authentication is Basic with the project&amp;rsquo;s key pair, plus the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header for the version 4 route.&lt;/p>
&lt;p>The gateway builds it exactly like that (&lt;code>langfuse_otel.py:328&lt;/code>, &lt;code>:347&lt;/code>, &lt;code>:359&lt;/code>) and then normalises the endpoint by adding the signal suffix (&lt;code>opentelemetry.py:3237&lt;/code>). The result matches the real route, so the integration works without touching anything. The detail matters when a collector is put in the middle and somebody copies the endpoint by hand.&lt;/p>
&lt;p>And the calendar warning that already appeared &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">in the operational pair&lt;/a> still stands: the classic callback is tied to the v2 SDK and the date marked is 16 November 2026. With a nuance worth reading before scheduling an emergency migration, which is detailed &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">in the data model article&lt;/a>: that date applies to Langfuse Cloud, and in the code of the self-hosted version the old route is not switched off, it changes behaviour according to the write mode.&lt;/p>
&lt;h3 id="when-to-put-a-collector-in">When to put a collector in&lt;/h3>
&lt;p>Direct if Langfuse is the only trace destination. Via a collector if there is more than one consumer, or if any of these three things is needed that the proxy&amp;rsquo;s batch processor does not provide:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Retries with increasing waits and an on-disk queue&lt;/strong>, with the &lt;code>sending_queue&lt;/code> and &lt;code>file_storage&lt;/code> processors. With that, a Langfuse maintenance window stops losing traces.&lt;/li>
&lt;li>&lt;strong>Tail sampling&lt;/strong>, which lets you keep 100 % of errors and 1 % of the rest. The proxy cannot sample by outcome, because at the point it decides it does not yet know how things ended.&lt;/li>
&lt;li>&lt;strong>Attribute redaction&lt;/strong> before they leave the namespace. It is the only layer where you can delete the MCP tool arguments which, as has already been documented, are written in the clear, bypassing the message redaction switch.&lt;/li>
&lt;/ul>
&lt;h2 id="seam-5-postgres-redis-and-the-memory-policy">Seam 5. Postgres, Redis and the memory policy&lt;/h2>
&lt;p>&lt;strong>Separate Postgres, always.&lt;/strong> Each piece applies its own Prisma migrations over the public schema of the database it is given. Two Prisma clients migrating the same schema collide over table names and over migration history. On top of that, Langfuse 4.x requires Postgres 15 as a minimum, recommends 16 and wants UTC by default. The load profile is also the opposite: the gateway writes spend records at high frequency, while Langfuse does light transactional work because trace data goes to ClickHouse.&lt;/p>
&lt;p>&lt;strong>Separate Redis, and the argument is not the one it looks like.&lt;/strong> There is no key collision by design: the gateway uses hash tags of the form &lt;code>{api_key:...}:requests&lt;/code> and an optional namespace, and Langfuse uses the BullMQ prefix plus its own prefix for its key cache. The decisive argument is another one: &lt;strong>Langfuse requires &lt;code>maxmemory-policy=noeviction&lt;/code>&lt;/strong> because its queues are data, not cache, while the gateway assumes a disposable cache. Sharing an instance means that a spike of rate limit keys can fill the memory and cause write errors in the ingestion queues instead of a harmless eviction.&lt;/p>
&lt;p>If it is shared anyway, two minimum measures: &lt;code>REDIS_KEY_PREFIX&lt;/code> in Langfuse, &lt;code>namespace&lt;/code> in the gateway, and different logical databases.&lt;/p>
&lt;p>And a reminder about surface area: Langfuse 4.x &lt;strong>does not start without ClickHouse or without an S3-compatible bucket&lt;/strong>. &lt;code>CLICKHOUSE_URL&lt;/code> and &lt;code>LANGFUSE_S3_EVENT_UPLOAD_BUCKET&lt;/code> are mandatory and have no default value (&lt;code>packages/shared/src/env.ts:120&lt;/code> and &lt;code>:271&lt;/code>). Its failure surface is far larger than the gateway&amp;rsquo;s, and that changes where to put the high availability effort.&lt;/p>
&lt;h2 id="ports-for-the-networkpolicy">Ports, for the NetworkPolicy&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Port&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM (API, interface and metrics)&lt;/td>
&lt;td>4000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse web&lt;/td>
&lt;td>3000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse worker&lt;/td>
&lt;td>3030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ClickHouse&lt;/td>
&lt;td>8123 HTTP, 9000 native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S3-compatible storage&lt;/td>
&lt;td>9000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>6379&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>4317 gRPC, 4318 HTTP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The pairs to allow under a default-deny policy, following the method from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening the stack&lt;/a>:&lt;/p>
&lt;pre tabindex="0">&lt;code>litellm → postgres:5432, redis:6379
litellm → collector:4318 (or langfuse-web:3000 if going direct)
litellm → vllm:8000
litellm → keycloak:8443 (interface and JWT flow only)
collector → langfuse-web:3000
langfuse-web → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web → keycloak:8443
ingress → litellm:4000, langfuse-web:3000
prometheus → litellm:4000
&lt;/code>&lt;/pre>&lt;p>The Langfuse worker needs no inbound traffic beyond the health probe.&lt;/p>
&lt;h2 id="the-glue-you-have-to-write">The glue you have to write&lt;/h2>
&lt;p>Put into a table, the real state of the tenant chain:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Segment&lt;/th>
&lt;th>Automatic?&lt;/th>
&lt;th>How&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Keycloak → LiteLLM team&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>team_ids_jwt_field&lt;/code> plus &lt;code>team_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → LiteLLM user&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>user_id_jwt_field&lt;/code> plus &lt;code>user_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse organisation&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Static assignment at sign-up only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM team → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>callback_vars&lt;/code> by hand, or an operator of your own&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak user → trace &lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>end_user_id_jwt_field&lt;/code>, or a contract with the client&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The minimum viable architecture without a licence, then, is this:&lt;/p>
&lt;ol>
&lt;li>One Langfuse project per tenant, created with the initialisation variables from GitOps.&lt;/li>
&lt;li>That project&amp;rsquo;s keys in a Kubernetes secret.&lt;/li>
&lt;li>A periodic job of your own that reads the teams through the gateway&amp;rsquo;s administration API and writes &lt;code>callback_vars&lt;/code> with the corresponding keys.&lt;/li>
&lt;li>People entering Langfuse through Keycloak, but assigned by hand to their organisation.&lt;/li>
&lt;li>The end user identifier resolved by contract with the clients, and validated.&lt;/li>
&lt;/ol>
&lt;p>That is about one hundred and fifty lines of your own code. It pays to budget for them from the start, because the gap does not close by itself.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># only with a licence; without one, virtual keys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_jwtauth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_ids_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end_user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;preferred_username&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_allowed_email_domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ejemplo.es&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admin_jwt_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm_proxy_admin&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the three that ship switched off&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_scope_based_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_requests_on_db_unavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the engine does not know who is calling&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">add_user_information_to_llm_headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the two that enable the validation that by default does not happen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_PUBLIC_KEY_URL&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_HOST&lt;/span>&lt;span class="o">=&lt;/span>http://langfuse-web.observabilidad.svc:3000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_PUBLIC_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SECRET_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Langfuse side:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_CLIENT_ID&lt;/span>&lt;span class="o">=&lt;/span>langfuse
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_USERNAME_PASSWORD&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_SIGNUP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ID&lt;/span>&lt;span class="o">=&lt;/span>plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ROLE&lt;/span>&lt;span class="o">=&lt;/span>VIEWER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ENCRYPTION_KEY&lt;/span>&lt;span class="o">=&lt;/span>... &lt;span class="c1"># 64 hexadecimal characters&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Define &lt;code>JWT_AUDIENCE&lt;/code> and &lt;code>JWT_ISSUER&lt;/code> on day one, or use the per-issuer configuration. Without that, neither audience nor issuer is validated.&lt;/li>
&lt;li>Set &lt;code>enforce_rbac&lt;/code>, &lt;code>enforce_scope_based_access&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code> to true, and test that a token from another client of the same realm is rejected.&lt;/li>
&lt;li>Decide where the end user of the traces comes from and write it into the design. If it is not decided, traces come out with no person.&lt;/li>
&lt;li>Separate Postgres. Separate Redis. If Redis is shared, different prefix and logical database, and review the memory policy.&lt;/li>
&lt;li>Check that the proxy starts with Postgres down only if that is what you want, and that the on-call team can tell an admission 503 from a credential 401.&lt;/li>
&lt;li>Alert on &lt;code>litellm_service_latency&lt;/code> per service, not on Redis availability, because the circuit breaker hides the problem.&lt;/li>
&lt;li>One Langfuse project per tenant with the initialisation variables, and the per-team keys in &lt;code>callback_vars&lt;/code>.&lt;/li>
&lt;li>Put a collector in the middle if you need an on-disk queue, tail sampling or attribute redaction.&lt;/li>
&lt;li>Check that the gateway version is above 1.83.14 because of the earlier critical vulnerabilities.&lt;/li>
&lt;li>Write into the risk analysis that virtual keys are credentials decoupled from the identity provider, and define the offboarding procedure.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>A properly signed JWT is not a JWT addressed to this service.&lt;/strong> Without the two environment variables, the audience is not checked.&lt;/p>
&lt;p>&lt;strong>&lt;code>enforce_rbac&lt;/code> set to false does not mean there is no authorisation&lt;/strong>, it means that authorisation comes only from team membership, if there is a team claim. With no team claim and without the flag, a valid token reaches &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The stale copy of the public keys lasts up to seventy minutes&lt;/strong>, adding the two TTLs. Revoking a key at the provider does not take effect immediately.&lt;/p>
&lt;p>&lt;strong>With a single key in the JWKS and no key identifier, the identifier is not checked.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>The trace user is set by the client.&lt;/strong> Any per-person spend report built on that field is a declaration, not a measurement.&lt;/p>
&lt;p>&lt;strong>Redis going down generates no errors&lt;/strong>, it generates per-pod limits. It is the most expensive of the failures that give no warning.&lt;/p>
&lt;p>&lt;strong>Langfuse does not start without ClickHouse or without an S3 bucket.&lt;/strong> Its failure surface is bigger than the gateway&amp;rsquo;s, and that changes how the high availability effort is shared out.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s per-project access control is paid for in self-hosted&lt;/strong>, as are audit logs. For an ENS file, that gets decided before building, not after.&lt;/p>
&lt;p>&lt;strong>Sharing Redis does not break through key collision, it breaks through memory policy.&lt;/strong> It is a failure that shows up under load and gets diagnosed badly.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s ingestion route is OTLP over HTTP only.&lt;/strong> A collector configured with the gRPC exporter points at somewhere that does not exist.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The five seams share a common pattern: they fail without warning. The token from another service gets in, the trace user comes from the client, the rate limit stops being global, the spend queue discards on reaching 64 MB and the Langfuse project does not create itself. None of those five things produces a visible error.&lt;/p>
&lt;p>Day 2 work, then, consists above all of turning silences into signals. Two environment variables so that the audience is validated, three flags so that authorisation is real, an alert on service latency instead of on Redis availability, and an explicit contract on where the end user&amp;rsquo;s identity comes from.&lt;/p>
&lt;p>And one decision worth taking early, before the budget is closed. The gateway&amp;rsquo;s JWT authentication and Langfuse&amp;rsquo;s per-project access control are both behind a licence. A sovereign platform can live perfectly well without either, with virtual keys and one project per tenant, but it is a different architecture and it has to be drawn as such from the start.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the seam between gateway and traces, with the four queues that discard.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>: the previous article in the track, with the size of each piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside and its position in the architecture.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second door, with its permissions and its privacy problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: the default-deny method that orders the port table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/authentication-and-sso">Autenticación y SSO en Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/scim-and-org-api">SCIM y API de organización&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración OpenTelemetry de Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/postgres">Postgres&lt;/a> y &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/cache">caché&lt;/a> en la documentación de self-hosting, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">Autenticación por token en LiteLLM&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">ajustes de configuración&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code> y registro de &lt;code>CVE-2026-35030&lt;/code> en NVD.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM's MCP gateway: the second front door, the tool catalogue nobody bills, and why filtering it can make selection worse</title><link>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</link><pubDate>Fri, 11 Sep 2026 01:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</guid><description>&lt;blockquote>
&lt;p>Sixth post in the operational track on the control layer. The previous five treat the gateway as the piece the tokens pass through: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>. This one is about the other door. Verified against LiteLLM 1.102.0, commit of 10 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The MCP gateway is not a module, it is a complete second surface.&lt;/strong> A native MCP endpoint with four route spellings, a proxy mode with three fixed tools, four REST routes, some thirty-five administration routes and a long set of OAuth discovery endpoints. It still lives under &lt;code>_experimental&lt;/code> and it is nearly 20,000 lines.&lt;/p>
&lt;p>&lt;strong>I correct what I said in the previous post: access is NOT open by default.&lt;/strong> In 1.102.0, a key with no declared permissions and no team resolves zero servers. What does open things up is a per-server flag, &lt;code>allow_all_keys&lt;/code>, and that flag &lt;strong>overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that is off by default.&lt;/p>
&lt;p>&lt;strong>Permissions are enforced on execution, not just on the listing.&lt;/strong> Calling a tool that did not appear in &lt;code>tools/list&lt;/code> returns 403. There are three chained checks before the call, including one on the allowed arguments. The same predicate governs listing and calling, by declared design.&lt;/p>
&lt;p>&lt;strong>The hierarchy is built out of intersections, with two escapes and a fail-open.&lt;/strong> The key&amp;rsquo;s access groups are additive on top of the key and team ceiling. The organisation ceiling replaces rather than intersects when there is nothing below it. And on an indeterminate error while resolving tool permissions, the code returns &amp;ldquo;unrestricted&amp;rdquo;. It is a deliberate decision and it has to be declared in the risk analysis.&lt;/p>
&lt;p>&lt;strong>Per-tool cost is worth zero and that is why its metric disappears.&lt;/strong> There are exactly two Prometheus metrics for MCP, and the spend one is only incremented if the cost is greater than zero. With the default configuration, that series never exists. There is no listing metric, no latency metric, no error metric, no server health metric.&lt;/p>
&lt;p>&lt;strong>The cost that does matter is not attributed to MCP anywhere.&lt;/strong> It is the tokens of the tool definitions, resent on every turn inside the &lt;code>prompt_tokens&lt;/code> of the model call. Five typical MCP servers add up to 58 tools and some 55,000 tokens. The tool&amp;rsquo;s spend row, with its zero cost, has nothing to do with that.&lt;/p>
&lt;p>&lt;strong>And the gateway&amp;rsquo;s semantic filter does not act on the MCP door.&lt;/strong> It only applies when it is LiteLLM calling the model. A client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not. And even if it did act, filtering per request rewrites the cached prefix of the prompt, which is the opposite of what Anthropic and OpenAI do with their own tool searches.&lt;/p>
&lt;p>&lt;strong>The good observability ships switched off.&lt;/strong> Without &lt;code>LITELLM_OTEL_V2&lt;/code>, a tool call comes out as a generic span with the model set to &lt;code>MCP: name&lt;/code>. With it enabled there is a dedicated span and correct attributes, and the arguments and the result stay hidden unless you opt in. And in neither case is trace context propagated towards the upstream MCP server: the trace is cut at the gateway.&lt;/p>
&lt;p>&lt;strong>Tool arguments are written, and they are written in the clear.&lt;/strong> They go in full into the &lt;code>metadata&lt;/code> column of the spend table, without passing through the switch that governs prompt storage nor through the redaction function, which only knows about &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. Whoever enabled redaction for GDPR probably believes they are covered.&lt;/p>
&lt;p>&lt;strong>The 1.83 branch concentrated four serious vulnerabilities, and one of them belongs to the MCP gateway itself.&lt;/strong> The endpoint for testing a connection before saving accepted the stdio transport&amp;rsquo;s command, args and environment in the request body. A low-privilege key got command execution on the proxy host.&lt;/p>
&lt;p>&lt;strong>And the specification moved underneath.&lt;/strong> The revision in force since July 2026 removed protocol sessions, the initialisation handshake and the session header. LiteLLM 1.102.0 advertises the June 2025 revision and its version enumeration does not even contemplate the two later ones.&lt;/p>
&lt;h2 id="you-are-here-the-door-nobody-chose">You are here: the door nobody chose&lt;/h2>
&lt;p>The gateway was chosen for the reasons in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the switchboard post&lt;/a>: licence, fit inside the cluster, maturity. What was evaluated was the inference traffic.&lt;/p>
&lt;p>Later, somebody connects an agent, the agent needs tools, and the same process that is already authenticated and deployed turns out to speak MCP too. The decision to turn it into the tool door is almost never taken: it is inherited. And that door has neither the same controls, nor the same observability, nor the same cost model.&lt;/p>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows&lt;/a> covered the identity of your own MCP servers. &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> covered the protocol and its instrumentation. This post covers the piece in between.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-that-also-hands-out-keys">The analogy: the switchboard that also hands out keys&lt;/h2>
&lt;p>An office building&amp;rsquo;s telephone switchboard. Its original job is to route calls: it decides who can call outside, how much each department spends, and it leaves a record of every conference call.&lt;/p>
&lt;p>Over time, the switchboard operator is also given the key cabinet. They are already at reception, they already know who everybody is, it looks like the natural place. And from that moment there are two services at the same desk with very different rules.&lt;/p>
&lt;p>Calls are priced by the minute and show up on the bill. Keys have no tariff, so on the department&amp;rsquo;s bill they come out at zero, and somebody might conclude that the key cabinet costs nothing. It does cost: it costs the operator&amp;rsquo;s time, it costs having to read the whole key catalogue every time somebody asks what is available, and it costs that nobody checks that the label on a key still says what it said the day its loan was authorised.&lt;/p>
&lt;p>The analogy ends at the interesting spot. When somebody takes a key, the record says which key it was and what they said they wanted it for. That record is simultaneously the evidence of the action and a piece of data that perhaps should not be stored in the clear.&lt;/p>
&lt;h2 id="the-real-routes">The real routes&lt;/h2>
&lt;p>The first thing that surprises you when you look at the code is how many spellings get you to the same place.&lt;/p>
&lt;h3 id="the-native-endpoint">The native endpoint&lt;/h3>
&lt;p>The MCP sub-application is mounted at &lt;code>/mcp&lt;/code> and inside it has four mounts: the root as a wildcard, &lt;code>/mcp&lt;/code>, &lt;code>/{name}/mcp&lt;/code> and &lt;code>/sse&lt;/code>.&lt;/p>
&lt;p>And there is also a hand-declared route for bare &lt;code>/mcp&lt;/code> on the main application, with a comment that explains why: the mount cannot match its own bare prefix, and the resulting 307 redirect breaks MCP clients sitting behind a proxy that terminates TLS. It is the kind of detail you discover in production on a Friday.&lt;/p>
&lt;p>&lt;code>/mcp/sse&lt;/code> holds a surprise. It does not use the classic SSE transport: it uses the same HTTP session manager in stateless mode. The &lt;code>SseServerTransport&lt;/code> object is constructed and is not referenced on any other line of the file. It is dead code, and the &lt;code>/mcp/sse/messages&lt;/code> endpoint that this object advertises does not exist as a working route.&lt;/p>
&lt;p>The &lt;code>/{name}/mcp&lt;/code> route resolves in a documented order: server alias, comma-separated list with a maximum of 16 elements, tool set, access group. If nothing matches, 404.&lt;/p>
&lt;p>And &lt;code>/mcp/proxy&lt;/code> exposes a fixed surface of three tools. It is the progressive disclosure pattern, and it will come up again further down because it is the most important mitigation in this post.&lt;/p>
&lt;h3 id="the-rest-routes">The REST routes&lt;/h3>
&lt;p>Four, under the &lt;code>/mcp-rest&lt;/code> prefix:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Route&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/list&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/call&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/connection&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/tools/list&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The variants without &lt;code>-rest&lt;/code> that appear in the internal types have no router registering them. The &lt;code>/mcp/*&lt;/code> wildcard absorbs them as JSON-RPC.&lt;/p>
&lt;p>The last two are the stars of the security section.&lt;/p>
&lt;h3 id="administration-and-discovery">Administration and discovery&lt;/h3>
&lt;p>Some thirty-five routes under &lt;code>/v1/mcp&lt;/code>, almost all of them with virtual key authentication. Three details worth knowing:&lt;/p>
&lt;p>&lt;code>GET /v1/mcp/registry.json&lt;/code> &lt;strong>carries no authentication dependency&lt;/strong>. It returns 404 if the public registry is not enabled, which is the real protection.&lt;/p>
&lt;p>&lt;code>POST /v1/mcp/server&lt;/code> requires the proxy administrator role. &lt;code>POST /v1/mcp/server/register&lt;/code> does not: it is the proposal path, which requires a key with a team, explicitly forbids the stdio transport and leaves the entry pending review.&lt;/p>
&lt;p>And on top of that there is a long set of OAuth &lt;code>.well-known&lt;/code> endpoints, with dynamic client registration, authorisation, token, revocation and introspection, plus their per-server variants.&lt;/p>
&lt;h3 id="configuration-and-transport">Configuration and transport&lt;/h3>
&lt;p>The &lt;code>mcp_servers&lt;/code> schema in &lt;code>config.yaml&lt;/code> has fifty-odd fields, all of them optional. A warning for anyone coming from earlier versions: &lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in 1.102.0.&lt;/p>
&lt;p>The default transport when omitted is &lt;code>http&lt;/code>, that is, streamable HTTP. The other two values are &lt;code>sse&lt;/code> and &lt;code>stdio&lt;/code>. The string &lt;code>streamable_http&lt;/code> is not valid in the configuration file, although it is accepted as a synonym when importing connector files.&lt;/p>
&lt;p>Tool prefixing uses the format &lt;code>{server}{separator}{tool}&lt;/code> with &lt;code>-&lt;/code> as the default separator, configurable through an environment variable read at import time. The prefix chosen is the alias, or the name, or the identifier, in that order. There is an optional short mode that derives three characters from the hash of the server identifier and resolves collisions by rehashing.&lt;/p>
&lt;h2 id="access-control-and-the-correction-to-the-previous-post">Access control, and the correction to the previous post&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents post&lt;/a> I closed the list of traps with this one: &amp;ldquo;access to MCP servers is open by default if no level defines a list&amp;rdquo;. Verified in 1.102.0, that is false.&lt;/p>
&lt;p>The function that resolves the allowed servers does the opposite. If the team declares nothing, it inherits from the key; if the key declares nothing either, the base set is empty and the scope is marked as unbounded with zero servers. There is no branch that expands to &amp;ldquo;the whole registry&amp;rdquo; other than the administration role one.&lt;/p>
&lt;p>What does open things up, and this is the important part, is something else.&lt;/p>
&lt;h3 id="allow_all_keys">&lt;code>allow_all_keys&lt;/code>&lt;/h3>
&lt;p>It is a per-server flag, in configuration, in the API and in the database, defaulting to false. When set to true, that server joins the set of open servers and any key can see it.&lt;/p>
&lt;p>The detail to know: &lt;strong>by default it overrides the key&amp;rsquo;s explicit scope&lt;/strong>. A key that declares access only to server A will also see server B if B has the flag. The key&amp;rsquo;s scope is only respected if you enable &lt;code>general_settings.mcp_allow_all_keys_respects_mcp_scope&lt;/code>, which is off by default.&lt;/p>
&lt;p>There is a second opening case: anonymous subjects, with no user identifier, role or key, additionally receive the servers configured with authentication delegation towards the upstream and the pass-through ones. It is consistent with the design of those two modes, and it is a surface you have to know about.&lt;/p>
&lt;h3 id="require_key_mcp_access_defined">&lt;code>require_key_mcp_access_defined&lt;/code>&lt;/h3>
&lt;p>It lives in &lt;code>general_settings&lt;/code>, defaults to false, and needs no licence. What it inverts is &lt;strong>only&lt;/strong> the empty-key inheritance towards the team: with the flag on, a key with no list of its own gets an empty set instead of inheriting the team&amp;rsquo;s.&lt;/p>
&lt;p>It does not affect keys that do declare servers, which still intersect with the team. And it does not close off the access group grants, which are additive and are the escape route you have to audit separately.&lt;/p>
&lt;p>There is a sibling, &lt;code>require_end_user_mcp_access_defined&lt;/code>, with the same shape and applied to the end user.&lt;/p>
&lt;h3 id="the-hierarchy">The hierarchy&lt;/h3>
&lt;p>The function&amp;rsquo;s docstring is normative and says that all the rules are intersections. In detail:&lt;/p>
&lt;ul>
&lt;li>Key and team: intersection when both declare. Inheritance in one direction when one is empty.&lt;/li>
&lt;li>Key access groups: &lt;strong>additive union&lt;/strong> on top of the previous result.&lt;/li>
&lt;li>End user: intersection if it declares anything.&lt;/li>
&lt;li>Agent: intersection.&lt;/li>
&lt;li>Internal user: ceiling that only narrows.&lt;/li>
&lt;li>Organisation: ceiling that intersects if there are restrictions below, and that &lt;strong>replaces&lt;/strong> by becoming the ceiling when there are none.&lt;/li>
&lt;/ul>
&lt;p>For subjects with no key, admitted by SSO or by the gateway, the model changes to a per-source union instead of an intersection.&lt;/p>
&lt;h3 id="the-fail-open">The fail-open&lt;/h3>
&lt;p>This is the point you have to take to the risk analysis and not leave in a technical note.&lt;/p>
&lt;p>On an indeterminate failure while resolving the tool permissions of a key or a JWT, the code returns &amp;ldquo;unrestricted&amp;rdquo;, that is, all tools allowed. The organisation ceiling is skipped in the same situation. For subjects with no key the behaviour is the opposite, fail-closed.&lt;/p>
&lt;p>There is a well-resolved intermediate case: when a permission is named but cannot be read, a specific error is raised and it is denied in both cases.&lt;/p>
&lt;p>The doctrine is documented in the class docstring, which is appreciated. And it remains a behaviour that, in a system under the ENS, you have to declare in writing instead of discovering it during an audit.&lt;/p>
&lt;h3 id="what-is-done-well">What is done well&lt;/h3>
&lt;p>Permissions are enforced on the call, not just on the listing. The execution path chains three checks before contacting the server: allowed or forbidden tools, per key and team tool permission, and validation of the arguments against the allowed ones. All three return 403.&lt;/p>
&lt;p>Listing and calling share the same predicate, and the docstring declares it as an explicit invariant. A client that calls a tool directly when it did not appear in its listing receives 403, it does not execute. The same over REST and over the responses API route.&lt;/p>
&lt;p>The &lt;code>x-mcp-servers&lt;/code> and &lt;code>x-mcp-access-groups&lt;/code> headers only narrow, and they fail closed if they do not resolve.&lt;/p>
&lt;p>Header forwarding towards the upstream is an explicit per-server allowlist, with a centralised decision about when to strip the caller&amp;rsquo;s &lt;code>Authorization&lt;/code>, and a protection against cross-forwarding: in a multi-server listing, the global header is withheld if more than one server would consume it. That is RFC 9700 applied.&lt;/p>
&lt;h2 id="the-bill-that-never-shows-up">The bill that never shows up&lt;/h2>
&lt;p>Here is, in my opinion, the most important thing in this post, and it has nothing to do with security.&lt;/p>
&lt;p>Every tool the gateway aggregates takes up space in the model&amp;rsquo;s context. Name, description and parameter schema travel in the request&amp;rsquo;s &lt;code>tools&lt;/code> block, &lt;strong>on every turn&lt;/strong>, and they are paid for as input tokens of the real model.&lt;/p>
&lt;p>The figures published by Anthropic in November 2025 are the best public reference there is. Five connected MCP servers add up to 58 tools and some 55,000 tokens. The breakdown: GitHub, 35 tools and some 26,000 tokens. Slack, 11 tools and some 21,000. Sentry, five and some 3,000. Grafana, five and some 3,000. Splunk, two and some 2,000.&lt;/p>
&lt;p>With tool search enabled, that set drops from some 72,000 to some 8,700 tokens, and the usable context goes from 122,800 to 191,300.&lt;/p>
&lt;p>The second cost of a large catalogue is not economic and is not billed: it is that the model chooses worse. It has its own section further down, because of the two it is the one that decides whether the agent is any good.&lt;/p>
&lt;h3 id="how-to-measure-it-with-what-is-already-there">How to measure it with what is already there&lt;/h3>
&lt;p>The gateway stores enough data, even if it does not cross-reference it for you.&lt;/p>
&lt;p>In the listing&amp;rsquo;s spend row, with call type &lt;code>list_mcp_tools&lt;/code>, it enriches the metadata with &lt;code>allowed_server_count&lt;/code>, &lt;code>tool_count_total&lt;/code>, &lt;code>per_server_tool_counts&lt;/code> and &lt;code>per_server_list_outcomes&lt;/code>.&lt;/p>
&lt;p>In the model call rows, the &lt;code>proxy_server_request&lt;/code> column keeps the request body with the &lt;code>tools&lt;/code> block inside it.&lt;/p>
&lt;p>The practical method: pair the listing rows with the LLM rows of the same session by &lt;code>session_id&lt;/code>, read the total number of tools from the first, and pass the &lt;code>tools&lt;/code> array of the second through &lt;code>litellm.token_counter(model=..., tools=...)&lt;/code>, which accepts that argument. Multiply by the number of turns, because the block is resent on all of them.&lt;/p>
&lt;p>For the relative cost, a quicker comparison: mean &lt;code>prompt_tokens&lt;/code> of keys with MCP enabled against those without, same model and same period.&lt;/p>
&lt;h2 id="tool-selection">Tool selection&lt;/h2>
&lt;p>The previous section treats the catalogue as a bill. This one treats it as what determines whether the agent gets it right. This is ground where the talk runs well ahead of the measurement, so it is worth separating what has been measured from what gets repeated.&lt;/p>
&lt;h3 id="how-many-tools-a-model-can-take">How many tools a model can take&lt;/h3>
&lt;p>Anthropic&amp;rsquo;s official figure is that Claude&amp;rsquo;s ability to pick the right tool degrades when going from 30 to 50 available tools. The same documentation gives the inverse criterion, which is the more useful one: the full catalogue with nothing else is the right option with fewer than ten tools, when all of them are used on every request, or when the definitions add up to fewer than a hundred tokens.&lt;/p>
&lt;p>There is also an administrative ceiling before the semantic one. VS Code with Copilot cuts off at &lt;strong>128 tools per request&lt;/strong> and returns an error when you exceed it.&lt;/p>
&lt;p>The full curve gets cited far more than it has been measured. The soundest primary data I found is from May 2025: varying the number of candidates from 1 to 11,100 over a registry of more than 4,400 servers, below some 30 tools the success rate is above 90 %, between 31 and 70 intermittent failures appear, and past the hundred mark the degradation becomes dominated by retrieval. It is a qualitative heat map, not a numeric table.&lt;/p>
&lt;p>The figure that circulates most (78 % with ten tools, 40 % with a hundred, 13.6 % with seven hundred) comes from a conference talk with no paper, no repository and no published methodology. Do not take it to a client presentation.&lt;/p>
&lt;p>And there is a warning that affects everything else. An audit of 496 tasks from four tool calling benchmarks found &lt;strong>18.5 % misalignment between the label and reality&lt;/strong>. In the BFCL v4 subset, 80 % of the failures assigned to the agent came from fragile state comparisons. In one of the benchmarks with an automatic judge, 23 identical re-runs gave a range from 57.9 % to 76.8 %, almost 19 points of spread, enough to reorder the ranking. Any absolute figure in this section deserves a generous margin of error.&lt;/p>
&lt;h3 id="how-it-fails">How it fails&lt;/h3>
&lt;p>The work with the cleanest taxonomy evaluates 36 servers, 220 tools and 1,000 tasks against twenty frontier models, with an average of 15.2 tools exposed per task of which 4.1 are relevant.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Failure mode&lt;/th>
&lt;th>Share&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Not using a tool when one was needed&lt;/td>
&lt;td>10.5 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Choosing the wrong tool&lt;/td>
&lt;td>9.0 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Malformed parameters&lt;/td>
&lt;td>6.9 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not recovering from an error&lt;/td>
&lt;td>10.3 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cognitive failures, not tool failures&lt;/td>
&lt;td>63.3 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The interesting part is in the per-model breakdown: &lt;strong>the dominant failure mode changes with the model, not with the size of the catalogue&lt;/strong>. In one of them, 40.1 % of the failures are simply not calling any tool. In others the malformed parameter dominates. In another, the wrong tool. There is no canonical failure to attack.&lt;/p>
&lt;p>On tools that do the same thing, which is the real case of a gateway with fifteen servers, there is a good measurement. A study with ten groups of five functionally equivalent tools and a thousand query pairs puts the selection bias between 0.3 and 0.4, that is, &lt;strong>you would have to redistribute between 30 and 40 % of the probability mass for equivalent tools to be chosen equally&lt;/strong>. The same work shows that a biased continued training takes the selection of a specific endpoint from 0.6 % to 12.8 %.&lt;/p>
&lt;p>That study contributes another figure that reorders priorities: shuffling a tool&amp;rsquo;s &lt;strong>description&lt;/strong> moves the selection distribution substantially, whereas &lt;strong>changing only the name has minimal and inconsistent effects&lt;/strong>. Applied to the gateway&amp;rsquo;s &lt;code>server-tool&lt;/code> prefixing: it costs tokens and probably does not change which tool the model goes to. There is no study isolating the effect of name spacing, and it is a real gap in the literature.&lt;/p>
&lt;p>If the description is what carries weight, the state of the estate is the problem. An audit of 856 tools from 103 MCP servers found undeclared limitations in 89.8 %, missing usage guidance in 89.3 % and opaque parameters in 84.3 %. &lt;strong>Only 2.9 % were free of problems across all their components.&lt;/strong>&lt;/p>
&lt;h3 id="the-optimum-is-not-a-number">The optimum is not a number&lt;/h3>
&lt;p>The May 2026 work I cited above proposes a chance-corrected metric and uses it to size the list adaptively. Over 370 functions it reaches 90.3 % coverage while seeing an average of 7.4 tools, against 90.8 % seeing a fixed 50.&lt;/p>
&lt;p>But the same paper publishes its own counterexample, and it is honest to cite it: over another benchmark of 3,251 tools, &lt;strong>the fixed K of 5 wins in aggregate&lt;/strong>, 64.7 % against 61.9 %. The adaptive advantage concentrates in the hard queries, where the right tool sits between sixth and twentieth by similarity: there it finds 16.7 % of the cases where the fixed K finds 0 %.&lt;/p>
&lt;p>The March 2026 work over 121 tools puts the plateau at K=3, with 97.1 % that does not improve going up to K=5. With the caveat that it measures retrieval against labels, not model accuracy.&lt;/p>
&lt;p>And a July 2026 work gives the formulation that seems to me the right one: ranking by score is inconsistent with optimal acquisition when costs are heterogeneous, so &lt;strong>the optimal K is a function of cost, not a constant&lt;/strong>. Trimming from 7 to 4.4 exposed tools while keeping success is possible; fixing a universal number is not.&lt;/p>
&lt;h3 id="filtering-can-make-the-result-worse">Filtering can make the result worse&lt;/h3>
&lt;p>This is the part that appears in no product blog. A June 2026 benchmark with three registry sizes (25, 100 and 250 tools), six filtering methods, seven models and 26,460 runs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Success&lt;/th>
&lt;th>Visible tools&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expose the whole catalogue&lt;/td>
&lt;td>32.1 %&lt;/td>
&lt;td>125.00&lt;/td>
&lt;td>56,062&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-5&lt;/td>
&lt;td>22.1 %&lt;/td>
&lt;td>4.80&lt;/td>
&lt;td>3,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-10&lt;/td>
&lt;td>22.4 %&lt;/td>
&lt;td>9.54&lt;/td>
&lt;td>5,356&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>State-aware&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>25.95&lt;/td>
&lt;td>13,522&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full causal path&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>26.24&lt;/td>
&lt;td>13,697&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Four of the five filtering strategies perform worse than not filtering.&lt;/strong> The fifth, which does win comfortably, exposes 0.99 tools per step and requires hand-written precondition and effect contracts for every tool, plus a search of the causal path to the goal state. It is a planner with the goal given, not a retrieval filter, and it is not comparable with anything a gateway offers.&lt;/p>
&lt;p>There is more evidence in the same direction. A work over a corpus of 43,000 tools measured that the agent&amp;rsquo;s completion rate drops when using retrieved sets against hand-annotated sets: the filter introduces its own recall ceiling. And top-K breaks the tasks that need several tools: on a benchmark of 16,464 endpoints, a fine-tuned retriever reaches a Recall@3 of 68.6 % but only 39.7 % of the queries have their &lt;strong>complete&lt;/strong> tool set within the first three. Almost 29 points of gap between &amp;ldquo;the one I needed is there&amp;rdquo; and &amp;ldquo;all the ones I needed are there&amp;rdquo;.&lt;/p>
&lt;p>Filtering is not free on security either. A March 2026 work attacks the retrieval layer by injecting adversarial tools that saturate the top-k: with injection rates of 1.2 % it manages to dominate the top-k between 91 % and 97 % of the time. It is an attack surface that exposing the whole catalogue does not have.&lt;/p>
&lt;p>The most elegant counterpoint comes from Anthropic&amp;rsquo;s own numbers. With the same catalogue of more than fifty tools, the gain from tool search drops from 25 points on one model to 8.6 on the next, because the unfiltered baseline rises from 49 % to 79.5 %. &lt;strong>The margin left by filtering shrank to a third in one generation of models.&lt;/strong> Any architecture decision taken here has a short shelf life.&lt;/p>
&lt;h3 id="the-prompt-cache-trap">The prompt cache trap&lt;/h3>
&lt;p>Here is what makes a homemade top-K filter a bad idea even if it worked.&lt;/p>
&lt;p>Tool definitions do not go just anywhere in the prompt: they go at the beginning. Anthropic documents the order of the cacheable prefix as &lt;code>tools&lt;/code>, then &lt;code>system&lt;/code>, then &lt;code>messages&lt;/code>, with a hierarchy where each level rests on the previous one. And the invalidation rule is explicit: &lt;strong>modifying tool definitions, be it names, descriptions or parameters, invalidates the entire cache&lt;/strong>, and a change at one level invalidates that level and all subsequent ones.&lt;/p>
&lt;p>OpenAI says the same and adds a detail: the prefix must remain unchanged, and &lt;strong>order counts&lt;/strong>. The same set of tools served in a different order is a cache miss.&lt;/p>
&lt;p>With vLLM serving an open model there is no tool block of its own. The &lt;code>tools&lt;/code> parameter is serialised by the chat template inside the first system message, behind the system prompt text. For automatic prefix caching the effect is identical, because it is still near the absolute start of the prompt.&lt;/p>
&lt;p>The consequence is the one that matters. A filter that returns a different set of tools on the next turn does not just invalidate the tool block: it invalidates &lt;strong>everything behind it&lt;/strong>, which is the system prompt and the whole conversation, which is exactly the part that grows with the turns. At list prices, a prefix token goes from costing 0.1 times when read from cache to 1.25 times when written. An agent with sixty thousand tokens of stable prefix goes from some six thousand equivalent tokens per turn to some seventy-five thousand, plus the prefill that gets paid again in latency.&lt;/p>
&lt;p>The two big providers have separately arrived at the same solution, and it is the opposite of filtering in the prefix. Anthropic excludes deferred tools from the prefix and, when the model discovers one through search, injects it inline inside the conversation: &amp;ldquo;the prefix stays intact, so prompt caching is preserved&amp;rdquo;. OpenAI: &amp;ldquo;all tools are loaded at the end of the model&amp;rsquo;s context window (&amp;hellip;) this allows the cache to be preserved from one request to the next&amp;rdquo;. Anthropic&amp;rsquo;s operational recommendation is the stable core pattern: leave the three to five most used tools undeferred.&lt;/p>
&lt;p>The closest academic work, from January 2026, states the problem without measuring it and recommends exactly that, keeping a fixed set of reusable general-purpose functions and implementing dynamic capability through code generation.&lt;/p>
&lt;p>The July 2026 MCP specification picks up both halves of the matter. Servers &amp;ldquo;&lt;strong>should&lt;/strong> return the tools from &lt;code>tools/list&lt;/code> in a deterministic order to allow caching in the client and improve the hit rate of the model&amp;rsquo;s prompt cache&amp;rdquo;. And it adds mandatory &lt;code>ttlMs&lt;/code> and &lt;code>cacheScope&lt;/code> in listing results, so that the client can freeze the catalogue for a known window instead of re-querying it and propagating any variation to the prompt.&lt;/p>
&lt;p>Nobody has publicly measured the cost in euros of this effect. The only attempt I found is a self-published audit of ten thousand turns that puts it at 2.4 % to 3.5 % of cache misses, with non-reproducible methodology. It is a gap in the literature, and the order of magnitude in the previous paragraph comes from applying list prices, not from a measurement.&lt;/p>
&lt;h3 id="and-now-litellm">And now, LiteLLM&lt;/h3>
&lt;p>With all of the above in mind, the gateway&amp;rsquo;s levers read differently.&lt;/p>
&lt;p>&lt;strong>The semantic filter does not act on the MCP endpoint.&lt;/strong> It is registered as a generic LiteLLM callback, and its only dispatcher is the proxy&amp;rsquo;s precall hook, which additionally discards anything that is not &lt;code>completion&lt;/code>, &lt;code>acompletion&lt;/code> or &lt;code>aresponses&lt;/code>. The &lt;code>tools/list&lt;/code> path does not invoke that hook at any point; there is a comment in the code that admits it in passing, when explaining why the listing&amp;rsquo;s JWT signature is done separately. An external MCP client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Breaks the prompt cache&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Per-server &lt;code>allowed_tools&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Selection by route or header&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, if it is stable per agent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/mcp/proxy&lt;/code> and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, the surface is fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Yes, it rewrites the block per request&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>When it does act, the filter has details you need to know before enabling it. It embeds only each tool&amp;rsquo;s description, with the name as a fallback if it is missing; the parameter schema does not go in. Of the query it embeds &lt;strong>only the last user message&lt;/strong>: on turn twelve of an agent conversation, that might be &amp;ldquo;yes, go ahead&amp;rdquo;. The tool embeddings are cached and indexed at start-up; the query&amp;rsquo;s is computed on every request, with a synchronous call inside a coroutine.&lt;/p>
&lt;p>Its failure modes are almost all open, which is the reasonable thing: if the embedding model does not respond, if nothing passes the threshold, if the names do not match the catalogue, it returns all the tools. The exception is context overflow, which fails closed with a 400, and which if it happens while building the index during start-up is memoised and makes &lt;strong>all&lt;/strong> subsequent requests fail until you restart. The defaults are &lt;code>text-embedding-3-small&lt;/code>, &lt;code>top_k&lt;/code> of 10 and a threshold of 0.3, under &lt;code>litellm_settings.mcp_semantic_tool_filter&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>/mcp/proxy&lt;/code> does serve external clients&lt;/strong>, and that is where its real value lies. It replaces the catalogue with three tools with opaque identifiers. How &lt;code>search_tools&lt;/code> searches deserves a line, because it is not what you would assume: by default &lt;strong>it is neither a regular expression nor a semantic match, it is a keyword counter&lt;/strong>, how many tokens of the query appear as a substring in the concatenated name and description. It only moves to embeddings if you configure a model in &lt;code>mcp_tool_search&lt;/code>. The result cap is hard-coded at five in this mode, and the client cannot ask for more. In exchange, &lt;code>call_tool&lt;/code> goes through the same permission checks as a normal call plus a validation of the argument schema that the normal route does not do at that point.&lt;/p>
&lt;p>There is a related variant: with &lt;code>mcp_tool_search_enabled&lt;/code> in the key&amp;rsquo;s permissions, the catalogue is replaced by four virtual tools, and there the search one does expose &lt;code>top_k&lt;/code> to the client.&lt;/p>
&lt;p>&lt;strong>The catalogue order is not deterministic, and that breaks the client&amp;rsquo;s cache without anybody filtering anything.&lt;/strong> The list of allowed servers comes from iterating a Python set of strings, so with the random hash seed the order differs between processes, between uvicorn workers and between restarts. There is no sorting anywhere in the listing path, and there is no tool cache: every &lt;code>tools/list&lt;/code> queries the upstreams live, so the order within each server also depends on what the upstream returns that time. The gateway does not offer today the guarantee that the July specification recommends. The fix, sorting the catalogue before serving it, fits on one line.&lt;/p>
&lt;p>Nor is there any pagination towards the client. The catalogue is returned whole, always.&lt;/p>
&lt;p>&lt;strong>And the lever almost nobody uses is the static one.&lt;/strong> Every MCP server accepts &lt;code>tool_name_to_display_name&lt;/code> and &lt;code>tool_name_to_description&lt;/code>, and those overrides &lt;strong>really do replace what is sent to the client&lt;/strong> in &lt;code>tools/list&lt;/code>, not just what the administrator sees in the interface. The &lt;code>tools/call&lt;/code> routing translates from the new name to the real one before any permission check. With that you can trim a four-hundred-token description written by a third party, rename an ambiguous tool so it does not compete with a similar one, and do it without touching the prompt cache, because it is static and holds for all turns.&lt;/p>
&lt;p>It is also the rug pull mitigation left pending from the security section: if the description the model sees is the one the operator wrote, whatever the upstream server changes stops reaching the context. Two limits. They are configured through the database or the management API, &lt;strong>not through &lt;code>config.yaml&lt;/code>&lt;/strong>. And the reverse name translation is by exact equality and is not disambiguated between servers, so two servers with the same display name route unstably, made worse by the non-deterministic order of the previous paragraph.&lt;/p>
&lt;p>Alongside that, per-server &lt;code>allowed_tools&lt;/code> remains the blunt and effective instrument: it filters the listing the client sees, reduces tokens, and is static.&lt;/p>
&lt;h3 id="the-ladder-in-order">The ladder, in order&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Trim.&lt;/strong> Per-server &lt;code>allowed_tools&lt;/code>, plus a per-key allowlist. Cheap, static, does not break the cache and reduces the permission surface at the same time.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the overrides. That is where the evidence is, because the description carries weight and the name almost none, and along the way it pins the contract against the rug pull.&lt;/li>
&lt;li>&lt;strong>Split by route.&lt;/strong> Each agent to &lt;code>/{server}/mcp&lt;/code> or to a tool set, never to the aggregated endpoint. Stable per agent, so the cache survives.&lt;/li>
&lt;li>&lt;strong>Sort the catalogue&lt;/strong> before serving it if you control the deployment, while the gateway does not do it.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/proxy&lt;/code>&lt;/strong> when the catalogue goes past thirty or fifty tools. It is progressive disclosure and it works with external clients, which is more than you can say for the semantic filter.&lt;/li>
&lt;li>&lt;strong>The semantic filter&lt;/strong>, only when the one calling the model is LiteLLM. With a generous &lt;code>top_k&lt;/code>, knowing that it embeds only the last user message and that context overflow is a closed and sticky failure.&lt;/li>
&lt;li>&lt;strong>What not to do&lt;/strong>: a home-grown top-K filter that rewrites the &lt;code>tools&lt;/code> array on every turn. It costs the whole prefix and, according to four of the five measured strategies, probably makes selection worse.&lt;/li>
&lt;/ol>
&lt;h2 id="the-declared-cost-which-is-worth-zero">The declared cost, which is worth zero&lt;/h2>
&lt;p>The calculation has four precedence steps: cost set by a post-call hook, per-tool cost in &lt;code>tool_name_to_cost_per_query&lt;/code>, server default cost, and &lt;strong>0.0&lt;/strong>.&lt;/p>
&lt;p>Without configuring &lt;code>mcp_server_cost_info&lt;/code>, all MCP traffic is accounted at zero and consumes neither key nor team budget.&lt;/p>
&lt;p>There are exactly two Prometheus metrics for MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_mcp_tool_calls_total&lt;/code>&lt;/li>
&lt;li>&lt;code>litellm_mcp_tool_call_spend_metric&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Both with the same eight labels: tool name, server name, key hash, key alias, team, team alias, user and end user.&lt;/p>
&lt;p>And the second is only incremented if the cost is greater than zero. With the default configuration, &lt;strong>that time series never appears in Prometheus&lt;/strong>. It is not that it is worth zero: it is that it does not exist.&lt;/p>
&lt;p>Nor is there a &lt;code>tools/list&lt;/code> metric, or MCP latency, or MCP errors, or upstream server health. For that you are left with the generic request metrics.&lt;/p>
&lt;p>In the reports, &lt;code>/spend/calculate&lt;/code> has no MCP branch: it accepts a model and messages, or a completion response, and it always calls the LLM cost calculation. The per-team report does include MCP calls, grouped by the &lt;code>model&lt;/code> column, which for these rows is worth &lt;code>MCP: tool_name&lt;/code>. That is: every tool appears as if it were a model, with zero input and output tokens. Only the sessions endpoint separates the two call types explicitly.&lt;/p>
&lt;h2 id="observability-and-the-trace-that-gets-cut">Observability, and the trace that gets cut&lt;/h2>
&lt;p>There are two OpenTelemetry stacks in LiteLLM and which one runs depends on an environment variable.&lt;/p>
&lt;p>Without &lt;code>LITELLM_OTEL_V2=true&lt;/code>, the old logger runs, and it has no MCP handling: the only appearance of MCP in the whole file is the metadata key in a list. The tool call comes out as a generic &lt;code>litellm_request&lt;/code> span with the model set to &lt;code>MCP: name&lt;/code>.&lt;/p>
&lt;p>With v2 enabled there is a dedicated span, with a dedicated role, client kind and a parent in the proxy&amp;rsquo;s request span. The span name is &lt;code>tools/call tool-name&lt;/code>. The attributes are correct and follow the conventions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>&lt;code>execute_tool&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>&lt;code>tools/call&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session identifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Tool name&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.arguments&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.result&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>server.address&lt;/code>, &lt;code>server.port&lt;/code>&lt;/td>
&lt;td>From the upstream URL, redacted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm.cost.total&lt;/code>&lt;/td>
&lt;td>The cost that is probably worth zero&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Content capture is off by default. Without it you see neither the arguments nor the result in the trace.&lt;/p>
&lt;p>A note for anyone working with semantic conventions: &lt;code>mcp.tool.name&lt;/code> &lt;strong>does not exist&lt;/strong>. The MCP registry has exactly four attributes (&lt;code>mcp.method.name&lt;/code>, &lt;code>mcp.session.id&lt;/code>, &lt;code>mcp.protocol.version&lt;/code> and &lt;code>mcp.resource.uri&lt;/code>), all four in development, and the tool name goes in &lt;code>gen_ai.tool.name&lt;/code>. LiteLLM gets it right. And the GenAI conventions were extracted into their own repository which as of today &lt;strong>has no release at all&lt;/strong>, so there is no version number to cite.&lt;/p>
&lt;p>In Langfuse, the vendor mapper returns an empty dictionary for any span that is not an LLM call. An MCP call appears as a raw span with its attributes, with no native input, output or model fields.&lt;/p>
&lt;h3 id="the-trace-is-cut-at-the-gateway">The trace is cut at the gateway&lt;/h3>
&lt;p>Incoming context is read. The &lt;code>traceparent&lt;/code> the client puts in &lt;code>params._meta&lt;/code>, following the specification&amp;rsquo;s propagation proposal, is extracted and turned into a &lt;strong>link&lt;/strong> on the span, never into its parent. The reason is documented in the code: parenting to the remote trace would leave the span hanging off a trace whose root never reaches the backend. The client&amp;rsquo;s baggage is discarded on purpose to avoid forging identity attributes.&lt;/p>
&lt;p>Outgoing context is not propagated. There is no propagator injection call anywhere in the tree. The headers that go out towards the upstream MCP server are built with authentication and the authorised extra headers, and nothing else.&lt;/p>
&lt;p>The operational consequence: there is no end-to-end trace. If the upstream MCP server is instrumented, its spans live in another trace and there is no way to correlate them automatically.&lt;/p>
&lt;h2 id="auditing-and-the-conflict-with-privacy">Auditing and the conflict with privacy&lt;/h2>
&lt;p>A tool call leaves a normal spend row, with call type &lt;code>call_mcp_tool&lt;/code>, spend equal to the cost per query, model &lt;code>MCP: name&lt;/code>, zero tokens in the three token columns, a dedicated &lt;code>mcp_namespaced_tool_name&lt;/code> column, and the usual key, team, user and session fields.&lt;/p>
&lt;p>And in the &lt;code>metadata&lt;/code> column, in JSON, the complete structure of the call: name, &lt;strong>arguments&lt;/strong>, result, server, namespaced name, MCP session identifier, authentication mode and server resource with the URL redacted.&lt;/p>
&lt;h3 id="the-problem">The problem&lt;/h3>
&lt;p>That structure is written into the metadata column &lt;strong>without passing through the privacy switch&lt;/strong>. The assignment is direct and is not conditioned by the function that governs whether prompts and responses are stored, which does control the &lt;code>messages&lt;/code> and &lt;code>response&lt;/code> columns.&lt;/p>
&lt;p>And the redaction function does not cover it either: it only touches &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. A search for MCP in the redaction file returns nothing.&lt;/p>
&lt;p>The practical result: an operator who enables &lt;code>turn_off_message_logging&lt;/code> for GDPR keeps sending the full arguments of every tool to every logging destination they have configured. To the SIEM, to Langfuse, to S3.&lt;/p>
&lt;p>If an argument carries a national ID number, an address, a case number or a medical record, it has been exported.&lt;/p>
&lt;p>There is an aggravating factor that does not depend on LiteLLM. The revision of the specification in force introduces an extension that allows mirroring tool arguments into HTTP headers visible to load balancers, proxies and web application firewalls. The specification recommends not marking sensitive parameters that way, and it is a recommendation, and the upstream server decides.&lt;/p>
&lt;h3 id="the-fit-with-the-ens">The fit with the ENS&lt;/h3>
&lt;p>The conflict is real and has a familiar shape: activity logging (&lt;code>op.exp.8&lt;/code>) requires the call to be traced and non-repudiable; protection of personal data (&lt;code>mp.info.1&lt;/code>) requires that log not to become a secondary repository of personal data with a different purpose. The tool argument is simultaneously the evidence of the action and the data. Redacting it destroys non-repudiation; storing it in the clear creates the repository.&lt;/p>
&lt;p>The defensible way out you can build today: a hash of the argument in the audit log, the argument in the clear with encryption (&lt;code>mp.info.3&lt;/code>), separate retention and its own access control. The hash sustains non-repudiation and the clear text is only opened under procedure.&lt;/p>
&lt;p>There are two other measures this material touches directly. Change management (&lt;code>op.exp.5&lt;/code>) because registering an MCP server and, above all, changing the description of an already approved tool are changes to the security configuration. And maintenance and updates (&lt;code>op.exp.4&lt;/code>) because, as you can see in the next section, an old version of the gateway is a non-compliance with a name and a number.&lt;/p>
&lt;p>Two honesty warnings. The codes follow the numbering of Annex II of RD 311/2022 and you have to check them against the text in force before citing them in a compliance document. And on NIS2: as of the date of this post the directive &lt;strong>is still not transposed in Spain&lt;/strong>, with the draft bill on cybersecurity coordination and governance going through the process and a reasoned opinion from the Commission over the delay. What is enforceable today in the public sector and its suppliers is the ENS.&lt;/p>
&lt;h3 id="what-is-not-logged">What is not logged&lt;/h3>
&lt;p>An administrative gap: registering, modifying and deleting MCP servers &lt;strong>generates no audit log&lt;/strong>. There are three &lt;code>TODO&lt;/code> comments in the management code that say so, one of them with an &lt;code>if is_audit_logging_enabled(): pass&lt;/code>. In the same deletion block there are two more TODOs about not cleaning up orphaned permissions on keys and teams.&lt;/p>
&lt;h2 id="security">Security&lt;/h2>
&lt;h3 id="the-vulnerability-you-need-to-know-about">The vulnerability you need to know about&lt;/h3>
&lt;p>&lt;code>CVE-2026-42271&lt;/code>, published on 8 May 2026. Affects LiteLLM from 1.74.2 up to versions prior to 1.83.7. CVSS 3.1 of 8.8.&lt;/p>
&lt;p>The &lt;code>POST /mcp-rest/test/connection&lt;/code> and &lt;code>POST /mcp-rest/test/tools/list&lt;/code> endpoints, which exist to preview an MCP server before saving it, accepted the stdio transport&amp;rsquo;s &lt;code>command&lt;/code>, &lt;code>args&lt;/code> and &lt;code>env&lt;/code> in the request body. A low-privilege key executed arbitrary commands on the proxy host. It also affects Red Hat OpenShift AI on several branches.&lt;/p>
&lt;p>It is the story of this post condensed into one line: the gateway you put in to contain MCP was the hole.&lt;/p>
&lt;p>It does not come alone. The 1.83 branch concentrates three more serious ones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>CVE&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>CVSS&lt;/th>
&lt;th>Fixed in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>CVE-2026-42208&lt;/code>&lt;/td>
&lt;td>SQL injection in key validation, &lt;strong>with no prior authentication&lt;/strong>&lt;/td>
&lt;td>9.8&lt;/td>
&lt;td>1.83.7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-35030&lt;/code>&lt;/td>
&lt;td>OIDC userinfo cache keyed on &lt;code>token[:20]&lt;/code>, collision and impersonation&lt;/td>
&lt;td>9.1&lt;/td>
&lt;td>1.83.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-40217&lt;/code>&lt;/td>
&lt;td>Remote execution through bytecode rewriting in the guardrails test endpoint&lt;/td>
&lt;td>8.8&lt;/td>
&lt;td>no version recorded&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Plus two privilege escalations fixed in 1.83.10 and 1.83.14.&lt;/p>
&lt;p>The operational conclusion is short: &lt;strong>a deployment below 1.83.14 is exposed to remote execution or to an unauthenticated SQL injection&lt;/strong>. The current branch is 1.102.0.&lt;/p>
&lt;p>And a note for context: the whole MCP ecosystem has the same pattern. The unauthenticated remote execution in MCP Inspector, the command injection in &lt;code>mcp-remote&lt;/code> when connecting to an untrusted server, DNS rebinding disabled by default in the Python and TypeScript SDKs until late 2025, the directory escapes in the reference servers. Connecting to an untrusted MCP server compromises the client, not just the other way round.&lt;/p>
&lt;h3 id="the-rug-pull-nobody-covers">The rug pull nobody covers&lt;/h3>
&lt;p>Documented since April 2025: a server can change a tool&amp;rsquo;s description &lt;strong>after&lt;/strong> the client has approved it. The description is what the model reads to decide when and how to use it, so changing it is changing the behaviour without asking for permission again.&lt;/p>
&lt;p>The specification in force does not address it. It defines list change notification and cache freshness fields, which are recency mechanisms, not integrity ones. The closest normative warning is that clients must treat tool annotations as untrusted unless they come from trusted servers.&lt;/p>
&lt;p>LiteLLM does not cover it either. The only &amp;ldquo;pinning&amp;rdquo; in the code is of the configuration&amp;rsquo;s server identifier, so that it does not change when you edit the YAML. There is no hash or comparison of the description or the input schema between successive listings. A search for related terms in the MCP module: nothing functional.&lt;/p>
&lt;p>The control exists outside the gateway. &lt;code>mcp-scan&lt;/code>, from the same researchers who documented the attack, implements description hash pinning precisely to detect this.&lt;/p>
&lt;p>In ENS terms it is an unauthorised change to the security configuration that today goes undetected. And the home-made fix fits in a cron job: store the hash of the description and schema of every approved tool, compare against &lt;code>tools/list&lt;/code> periodically, alert on the difference.&lt;/p>
&lt;h3 id="three-more-holes-in-the-code">Three more holes in the code&lt;/h3>
&lt;p>&lt;strong>No URL validation towards the upstream.&lt;/strong> A new server&amp;rsquo;s &lt;code>url&lt;/code> field has no validator. The SSRF protection utilities exist in the tree and are used in OAuth discovery and in downloading OpenAPI specifications, but &lt;strong>not&lt;/strong> in the egress of &lt;code>tools/call&lt;/code>. A server pointing at a private range or at the cloud metadata IP is accepted as is.&lt;/p>
&lt;p>&lt;strong>The stdio transport is local execution on the proxy host.&lt;/strong> There is a command allowlist: &lt;code>npx&lt;/code>, &lt;code>uvx&lt;/code>, &lt;code>python&lt;/code>, &lt;code>python3&lt;/code>, &lt;code>node&lt;/code>, &lt;code>docker&lt;/code>, &lt;code>deno&lt;/code>, extendable through an environment variable. The code&amp;rsquo;s own comment admits the residual risk: the permitted runtimes can execute code through arguments. With &lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> in there, it is de facto arbitrary execution for any proxy administrator.&lt;/p>
&lt;p>&lt;strong>Two server modes skip the gateway&amp;rsquo;s authentication.&lt;/strong> Pass-through and authentication delegation to the upstream completely omit virtual key validation. They are well hardened (fail-closed if the target is mixed or unresolvable, strict comparison against truthy values, explicit blocking of the client credentials flow so as not to lend out the proxy&amp;rsquo;s service account), and the result is still that those servers have no gateway access control.&lt;/p>
&lt;h3 id="the-limit-that-truncates-in-silence">The limit that truncates in silence&lt;/h3>
&lt;p>The &lt;code>tools/list&lt;/code> pagination stops and returns what it has accumulated in three cases: repeated cursor, one thousand pages, or expiry of a global deadline. The docstring declares it: a slow or faulty upstream produces a partial catalogue instead of an error.&lt;/p>
&lt;p>The first two log a warning. The third, the deadline, exits &lt;strong>without logging anything at all&lt;/strong>. A slow server makes part of its tools disappear from the catalogue with no trace left behind.&lt;/p>
&lt;h3 id="correction-on-the-slot-leak">Correction on the slot leak&lt;/h3>
&lt;p>In the previous post I took issue 34534 at face value, according to which every MCP tool call acquires a &lt;code>max_parallel_requests&lt;/code> slot and does not release it. Tracing the 1.102.0 code, that diagnosis no longer holds: the release path exists, on both success and failure, and the limiter is registered as a litellm callback and not just as a proxy hook.&lt;/p>
&lt;p>With one honest caveat: I found no regression test covering the full cycle for MCP, and the definitive confirmation is a real proxy with &lt;code>max_parallel_requests: 2&lt;/code> and five MCP calls in a row. If anybody reproduces it today, I would like to know.&lt;/p>
&lt;h2 id="the-specification-moved-underneath">The specification moved underneath&lt;/h2>
&lt;p>The MCP revision in force is &lt;code>2026-07-28&lt;/code>, published on 28 July 2026, and in between there was another one, &lt;code>2025-11-25&lt;/code>. The changes in the latest one are not cosmetic:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Protocol sessions and the session header are removed.&lt;/strong> Cross-request state becomes an explicit identifier in the tool arguments.&lt;/li>
&lt;li>&lt;strong>The initialisation handshake is removed.&lt;/strong> Every request carries version and capabilities in &lt;code>_meta&lt;/code>. MCP becomes stateless.&lt;/li>
&lt;li>&lt;code>server/discover&lt;/code> appears as a mandatory call.&lt;/li>
&lt;li>Elicitation changes shape: out go server-initiated requests, in comes a multi-round pattern where the server returns an &amp;ldquo;input required&amp;rdquo; result and the client retries with the answers.&lt;/li>
&lt;li>&lt;code>ping&lt;/code>, &lt;code>logging/setLevel&lt;/code> and the roots change notification are removed. SSE stream resumption is removed.&lt;/li>
&lt;li>Standard HTTP headers are mandatory on POST, which lets a gateway or a web application firewall decide without parsing the JSON.&lt;/li>
&lt;li>On authorisation: mandatory issuer validation in the client, and &lt;strong>dynamic client registration is deprecated&lt;/strong> in favour of client identifier metadata documents.&lt;/li>
&lt;li>Roots, sampling and logging are deprecated, with a minimum window of twelve months.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM 1.102.0 advertises &lt;code>2025-06-18&lt;/code>. Its version enumeration has three values and none of them is later than that date. The literal is sent in the &lt;code>initialize&lt;/code> against the upstream.&lt;/p>
&lt;p>One nuance that is not entirely bad: the version the gateway advertises to &lt;strong>its&lt;/strong> clients is not hard-coded in LiteLLM, the SDK negotiates it. And there is partial implementation of the intermediate revision, because the elicitation and sampling handlers reference it in their docstrings. That is: partial implementation of one revision while advertising an earlier one.&lt;/p>
&lt;p>In practical interoperability it does not hurt yet, because the client ecosystem is still mostly on the 2025 revisions. It hurts in planning: July&amp;rsquo;s stateless redesign invalidates the gateway&amp;rsquo;s session model, and that is work ahead.&lt;/p>
&lt;p>Two ecosystem notes for anyone choosing. There are clients that speak remote streamable HTTP natively (Claude and Claude Code, ChatGPT with OAuth and dynamic registration, VS Code with Copilot, Cursor, Zed, Kiro) and others that still demand stdio or a local bridge with &lt;code>mcp-remote&lt;/code>, among them Windsurf, Cline and Continue.&lt;/p>
&lt;h2 id="tool-execution-from-the-proxy-itself">Tool execution from the proxy itself&lt;/h2>
&lt;p>Besides serving MCP outwards, the proxy can consume it itself. It is enabled with an &lt;code>mcp&lt;/code> block in the body, with &lt;code>server_url&lt;/code> equal to the &lt;code>litellm_proxy&lt;/code> sentinel, and it works both on &lt;code>/v1/responses&lt;/code> and on &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>The flow: it separates the gateway&amp;rsquo;s tools, lists them, filters by allowed, deduplicates, translates them into OpenAI function format and concatenates them. It calls the model. If there are tool calls and auto-execution is permitted, it runs them against the MCP servers. It builds the follow-up and makes &lt;strong>one&lt;/strong> second call.&lt;/p>
&lt;p>Two limits you need to know before designing on top of this.&lt;/p>
&lt;p>&lt;strong>The loop is a single hop.&lt;/strong> There is no multi-step iteration. The calls from the first response are executed and the follow-up is returned. A plan that chains tools requires the client to call again.&lt;/p>
&lt;p>&lt;strong>Auto-execution fails closed and as a block.&lt;/strong> It requires all MCP references to have approval set to &amp;ldquo;never&amp;rdquo;; a single one requiring approval disables auto-execution for the whole request.&lt;/p>
&lt;p>And a third one, which is the one that bites: &lt;strong>the multi-turn state of &lt;code>/v1/responses&lt;/code> is rehydrated from the spend table&lt;/strong>. The query is literal, it looks up the session of the previous &lt;code>request_id&lt;/code> and retrieves all the rows of that session ordered by time.&lt;/p>
&lt;p>With &lt;code>disable_spend_logs: true&lt;/code> nothing is written, the query comes out empty and the history is lost &lt;strong>in silence&lt;/strong>, with no 4xx error. The code itself acknowledges it: it does not expect retries because deployments that do not write spend logs have nothing to wait for. It is a coupling between a performance option and a conversation feature that nobody expects to find.&lt;/p>
&lt;h2 id="health-and-day-to-day-operation">Health and day-to-day operation&lt;/h2>
&lt;p>Health checks exist, they are on demand and they are not periodic. &lt;code>GET /v1/mcp/server/health&lt;/code> accepts repeated identifiers and returns per-server status. The implementation opens a client and runs an empty operation inside the session, with a ten-second timeout.&lt;/p>
&lt;p>It skips the check entirely if the server requires per-user authentication, and those always return unknown status.&lt;/p>
&lt;p>The surprising part: the status, last check and error columns have existed in the database since a May 2025 migration, and the check function &lt;strong>returns an object without writing to the database&lt;/strong>. Status is recalculated on every request. There is no periodic MCP health job.&lt;/p>
&lt;p>When an upstream server is down, the behaviour is to degrade and not block. The listing fans out in parallel and each failing server contributes an empty list plus a result classified into six categories. Those results reach the client in the response&amp;rsquo;s &lt;code>_meta&lt;/code> under a vendor key, without filtering the upstream&amp;rsquo;s text or URL. The reason for the design is in the code: an empty contribution with no signal makes a broken upstream indistinguishable from a healthy server with no tools.&lt;/p>
&lt;p>There is no cache of the previous list. A server that is down disappears from that turn&amp;rsquo;s tools.&lt;/p>
&lt;p>On registering and deregistering without restarting, there are two worlds. Database servers can be created, edited and deleted through the API, and every operation reloads the registry with an atomic swap. Propagation between replicas goes through Redis publication on the configuration change channel, if there is Redis, and through polling every 30 seconds if there is not.&lt;/p>
&lt;p>Servers declared in &lt;code>config.yaml&lt;/code> are loaded once, at start-up. There is no re-read. Changing them requires a restart.&lt;/p>
&lt;p>And there is a precedence trap between the two worlds, with two different warnings in the code. A database row with the same identifier as a configuration entry &lt;strong>hides the configuration one entirely&lt;/strong>. And the other way round, a configuration identifier that matches the name or alias of a database server captures its permissions, because identifiers are resolved before names. Both cases emit a log warning, once.&lt;/p>
&lt;h2 id="a-hardened-configyaml">A hardened &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Access is not open by default, but this closes the empty-key&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># inheritance towards the team, which is the one that surprises people.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_end_user_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Without this, allow_all_keys overrides the key&amp;#39;s explicit scope.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_allow_all_keys_respects_mcp_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Does not cover MCP arguments. See the auditing section.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://mcp-inventario.interna.svc/mcp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bearer_token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/MCP_INVENTARIO_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Explicit allowlist. Also enforced on tools/call.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_tools&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">buscar_articulo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">consultar_stock&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Never true on a server with data.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">access_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;operaciones&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_concurrent_requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Inventario de almacen, solo lectura&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Without this, MCP spend is zero and its metric does not exist.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_server_cost_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">default_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0005&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tool_name_to_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">consultar_stock&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And in the process environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Without this there is no MCP span, only a generic litellm_request.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_OTEL_V2&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Full block hashes, not truncated, if correlating&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># with engine events.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_CLIENT_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">45&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_TOOL_LISTING_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For clients, the rule is not to use the aggregated endpoint. Each agent points at &lt;code>/{server}/mcp&lt;/code> or at a tool set, or at the three-tool proxy mode if the catalogue is large.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Check the version. Below 1.83.14 there is remote execution and an unauthenticated SQL injection with an assigned CVE.&lt;/li>
&lt;li>Inventory which servers have &lt;code>allow_all_keys&lt;/code> and enable the setting that makes it respect the key&amp;rsquo;s scope.&lt;/li>
&lt;li>Audit the access groups separately, because they are additive on top of the key and team ceiling.&lt;/li>
&lt;li>Declare in writing the fail-open behaviour on tool permission resolution errors.&lt;/li>
&lt;li>Measure the real cost: number of tools per server from the listing row, and &lt;code>tools&lt;/code> block tokens from the stored request body.&lt;/li>
&lt;li>Choose a catalogue strategy before connecting the third server: three-tool proxy mode, semantic filter, or selection by route. Not the aggregated endpoint.&lt;/li>
&lt;li>Declare &lt;code>mcp_server_cost_info&lt;/code> even with a token value, or the spend metric will not exist.&lt;/li>
&lt;li>Enable the OTel v2 logger, and decide deliberately whether to enable content capture.&lt;/li>
&lt;li>Assume the trace is cut at the gateway and plan correlation with the upstream by other means.&lt;/li>
&lt;li>Treat the spend table&amp;rsquo;s metadata column as a personal data repository: encryption, its own retention and separate access control.&lt;/li>
&lt;li>Set up the rug pull check, because nobody does it: hash of the description and schema of every approved tool, periodic comparison against the listing.&lt;/li>
&lt;li>Restrict egress towards MCP servers at the network level, because the gateway does not validate the destination URL.&lt;/li>
&lt;li>Do not enable the stdio transport unless you need it, and if you do, review what is on the command allowlist.&lt;/li>
&lt;li>If you use &lt;code>/v1/responses&lt;/code> with multi-turn state, do not enable &lt;code>disable_spend_logs&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP access is not open by default in 1.102.0.&lt;/strong> I got that wrong in the previous post. What opens things up is per-server &lt;code>allow_all_keys&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>allow_all_keys&lt;/code> overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that ships disabled.&lt;/li>
&lt;li>&lt;strong>&lt;code>require_key_mcp_access_defined&lt;/code> only closes key-to-team inheritance.&lt;/strong> Access group grants remain additive.&lt;/li>
&lt;li>&lt;strong>On an indeterminate failure resolving tool permissions, the code fails open&lt;/strong> for keys and JWTs.&lt;/li>
&lt;li>&lt;strong>The default cost of an MCP call is 0.0&lt;/strong>, and the spend metric is only emitted if the cost is greater than zero, so the series does not exist.&lt;/li>
&lt;li>&lt;strong>MCP calls appear in the per-team report as if they were models&lt;/strong>, named &lt;code>MCP: tool&lt;/code> and with zero tokens.&lt;/li>
&lt;li>&lt;strong>The OTel logger with MCP support is off by default.&lt;/strong> Without the environment variable there is no MCP span.&lt;/li>
&lt;li>&lt;strong>Trace context is not propagated towards the upstream MCP server.&lt;/strong> There is no end-to-end trace.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.name&lt;/code> does not exist&lt;/strong> in the semantic conventions. The name goes in &lt;code>gen_ai.tool.name&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Message redaction does not cover tool arguments.&lt;/strong> They are written in the clear into the spend table.&lt;/li>
&lt;li>&lt;strong>Registering, modifying and deleting MCP servers generates no audit log.&lt;/strong> There are three TODOs in the code.&lt;/li>
&lt;li>&lt;strong>The semantic tool filter is not applied on &lt;code>tools/list&lt;/code>.&lt;/strong> Only on &lt;code>/chat/completions&lt;/code> and &lt;code>/v1/responses&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The catalogue order is not deterministic&lt;/strong>, because it comes from iterating a set of strings. It breaks the client&amp;rsquo;s prompt cache even though nobody filters anything.&lt;/li>
&lt;li>&lt;strong>&lt;code>search_tools&lt;/code> in proxy mode counts keywords by default&lt;/strong>, it uses neither regular expressions nor embeddings, and its cap of five results is hard-coded.&lt;/li>
&lt;li>&lt;strong>Description overrides really do replace what the client sees&lt;/strong>, and they are the only catalogue lever that does not break the cache. They are only configurable through the database or the API.&lt;/li>
&lt;li>&lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in the configuration schema.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/sse&lt;/code> does not use the SSE transport&lt;/strong>, and the messages endpoint its object advertises is dead code.&lt;/li>
&lt;li>&lt;strong>Listing pagination truncates in silence when the deadline expires&lt;/strong>, without leaving a single log line.&lt;/li>
&lt;li>&lt;strong>A database row hides a configuration entry with the same identifier&lt;/strong>, and the other way round a configuration identifier can capture the permissions of a database server.&lt;/li>
&lt;li>&lt;strong>Servers declared in &lt;code>config.yaml&lt;/code> require a restart.&lt;/strong> Only the database ones reload hot.&lt;/li>
&lt;li>&lt;strong>The health check does not write its result to the database&lt;/strong> even though the columns exist, and there is no periodic job.&lt;/li>
&lt;li>&lt;strong>The gateway does not validate the upstream MCP server&amp;rsquo;s URL.&lt;/strong> The anti-SSRF utilities exist and are not used on that path.&lt;/li>
&lt;li>&lt;strong>&lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> are on the stdio command allowlist.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The &lt;code>/v1/responses&lt;/code> tool loop is a single hop&lt;/strong>, and &lt;code>disable_spend_logs: true&lt;/code> breaks its multi-turn behaviour with no error.&lt;/li>
&lt;li>&lt;strong>LiteLLM advertises the June 2025 revision of the protocol&lt;/strong>, two behind the one in force.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>There is a thread running through the six posts in this track that is more visible here than anywhere else. The gateway is chosen for one job and ends up doing five, and each new job arrives with its own defaults, which are almost never the ones you would have chosen.&lt;/p>
&lt;p>In the case of MCP the asymmetry is twofold. On one side, the part that looks dangerous is better built than you would assume: access control is enforced on execution and not just on the listing, the hierarchy is built out of intersections, header forwarding is an allowlist, and default access is closed, contrary to what I myself wrote two days ago. On the other, the part nobody looks at is worse: tool arguments come out in the clear bypassing the privacy switch, spend is worth zero and its metric never gets to exist, the trace is cut, and a change to the description of an approved tool goes undetected by anybody in the whole chain, neither the specification nor the gateway.&lt;/p>
&lt;p>The architecture decision that comes out of this is that the MCP door has to be treated as what it is, an interconnection of systems with third parties, and not as one more function of the LLM proxy. That means its own inventory, formal registration, change control over every tool&amp;rsquo;s description, network isolation towards the upstream and a log designed knowing that it is going to contain personal data.&lt;/p>
&lt;p>And it means accepting that the cost you really pay for connecting fifteen MCP servers is in no column of the spend table. It is spread across the &lt;code>prompt_tokens&lt;/code> of every model call, on every turn, and across the accuracy you lose when the catalogue is too large for the model to choose well. That number can be computed with what the gateway already stores. The first step is wanting to compute it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the other face of the same problem, when the agent is a traffic class and not an identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — the sibling post, where the correction is about what the gateway does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — the identity hierarchy these permissions rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the trace path that MCP spans reach, or do not reach.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers, upstream of this gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol, the six primitives and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a> — the network isolation this post assumes is necessary and does not explain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for the ENS, 42001 and the AI Act&lt;/a> — the compliance framework that activity logging and change management fit into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Secret hardening in a sovereign LLM stack&lt;/a> — the upstream credentials the gateway holds.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a> — what a tool loop that half fails costs you.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other face of the tool catalogue, the one paid in proxy CPU when the token counter serialises it on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the seams with the identity provider and with the trace backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the two standards the MCP specification requires and the authorisation server does not implement.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the resource side: why the official SDKs do not validate the audience and what replaces token exchange.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>MCP Gateway&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/mcp">https://docs.litellm.ai/docs/mcp&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>auth/user_api_key_auth_mcp.py&lt;/code>, &lt;code>cost_calculator.py&lt;/code>, &lt;code>faults/list_outcomes.py&lt;/code>, &lt;code>litellm/proxy/management_endpoints/mcp_management_endpoints.py&lt;/code>, &lt;code>litellm/proxy/spend_tracking/spend_tracking_utils.py&lt;/code>, &lt;code>litellm/litellm_core_utils/redact_messages.py&lt;/code>, &lt;code>litellm/integrations/otel/&lt;/code>, &lt;code>litellm/responses/litellm_completion_transformation/session_handler.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en llamadas MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42271&lt;/code>, ejecución de comandos por los endpoints de prueba de MCP: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42271">https://nvd.nist.gov/vuln/detail/CVE-2026-42271&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42208&lt;/code>, inyección SQL sin autenticación en la validación de clave: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42208">https://nvd.nist.gov/vuln/detail/CVE-2026-42208&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-35030&lt;/code>, colisión de clave de caché de userinfo OIDC: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-35030">https://nvd.nist.gov/vuln/detail/CVE-2026-35030&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-49596&lt;/code>, ejecución remota en MCP Inspector: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-49596">https://nvd.nist.gov/vuln/detail/CVE-2025-49596&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-6514&lt;/code>, inyección de comandos en &lt;code>mcp-remote&lt;/code>: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-6514">https://nvd.nist.gov/vuln/detail/CVE-2025-6514&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-66416&lt;/code>, protección de DNS rebinding desactivada por defecto en el SDK de Python: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-66416">https://nvd.nist.gov/vuln/detail/CVE-2025-66416&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>MCP Security Notification: Tool Poisoning Attacks&lt;/em> (tool poisoning, rug pull, shadowing): &lt;a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks">https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>Introducing mcp-scan&lt;/em> (fijado de herramientas por hash): &lt;a href="https://invariantlabs.ai/blog/introducing-mcp-scan">https://invariantlabs.ai/blog/introducing-mcp-scan&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">https://modelcontextprotocol.io/specification/2026-07-28/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2025-11-25: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog">https://modelcontextprotocol.io/specification/2025-11-25/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;em>Security Best Practices&lt;/em> (confused deputy, indicadores de recurso, validación de audiencia): &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices&lt;/a>.&lt;/li>
&lt;li>NSA, &lt;em>Model Context Protocol: Security Design Considerations for AI-Driven Automation&lt;/em>, mayo de 2026: &lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF&lt;/a>.&lt;/li>
&lt;li>CISA y agencias aliadas, &lt;em>Careful Adoption of Agentic AI Services&lt;/em>, mayo de 2026: &lt;a href="https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services">https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Advanced tool use&lt;/em> (coste en tokens de las definiciones, búsqueda de herramientas, precisión de selección): &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use">https://www.anthropic.com/engineering/advanced-tool-use&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Code execution with MCP&lt;/em>: &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp">https://www.anthropic.com/engineering/code-execution-with-mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Prompt caching&lt;/em> (jerarquía &lt;code>tools&lt;/code> → &lt;code>system&lt;/code> → &lt;code>messages&lt;/code> e invalidación): &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Tool search tool&lt;/em> (herramientas diferidas fuera del prefijo, umbral de 30 a 50 herramientas): &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Prompt caching&lt;/em> (el orden de las herramientas cuenta): &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching">https://developers.openai.com/api/docs/guides/prompt-caching&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Tool search&lt;/em> (las herramientas descubiertas se cargan al final del contexto): &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html">https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>Microsoft, &lt;em>Use tools in chat&lt;/em> (límite de 128 herramientas por petición en VS Code): &lt;a href="https://code.visualstudio.com/docs/copilot/agents/agent-tools">https://code.visualstudio.com/docs/copilot/agents/agent-tools&lt;/a>.&lt;/li>
&lt;li>&lt;em>RAG-MCP&lt;/em>, prueba de estrés de 1 a 11.100 servidores candidatos, mayo de 2025: &lt;a href="https://arxiv.org/abs/2505.03275">https://arxiv.org/abs/2505.03275&lt;/a>.&lt;/li>
&lt;li>&lt;em>Benchmarking the Benchmarks&lt;/em>, desalineación del 18,5 % entre etiqueta y realidad, junio de 2026: &lt;a href="https://arxiv.org/html/2607.02577v1">https://arxiv.org/html/2607.02577v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP-Atlas&lt;/em>, taxonomía de fallos sobre 220 herramientas y veinte modelos, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.00933v3">https://arxiv.org/html/2602.00933v3&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307">https://arxiv.org/html/2510.00307&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP Tool Descriptions Are Smelly&lt;/em>, auditoría de 856 herramientas de 103 servidores, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.14878v1">https://arxiv.org/html/2602.14878v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolMenuBench&lt;/em>, seis estrategias de filtrado y 26.460 ejecuciones, junio de 2026: &lt;a href="https://arxiv.org/html/2606.15508">https://arxiv.org/html/2606.15508&lt;/a>.&lt;/li>
&lt;li>&lt;em>Retrieval Models Aren&amp;rsquo;t Tool-Savvy&lt;/em> (ToolRet), corpus de 43.000 herramientas, marzo de 2025: &lt;a href="https://arxiv.org/abs/2503.01763">https://arxiv.org/abs/2503.01763&lt;/a>.&lt;/li>
&lt;li>&lt;em>Tools Are Not Islands&lt;/em>, brecha entre Recall@3 y conjunto completo, julio de 2026: &lt;a href="https://arxiv.org/html/2607.25718">https://arxiv.org/html/2607.25718&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación, marzo de 2026: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;li>&lt;em>Scores Are Not Decisions&lt;/em>, el K óptimo como función del coste, julio de 2026: &lt;a href="https://arxiv.org/html/2607.27083v1">https://arxiv.org/html/2607.27083v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Don&amp;rsquo;t Break the Cache&lt;/em>, caché de prompts en tareas agénticas de horizonte largo, enero de 2026: &lt;a href="https://arxiv.org/html/2601.06007v2">https://arxiv.org/html/2601.06007v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>How Many Tools Should an LLM Agent See? A Chance-Corrected Answer&lt;/em>, mayo de 2026: &lt;a href="https://arxiv.org/html/2605.24660v1">https://arxiv.org/html/2605.24660v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Semantic Tool Discovery for LLMs&lt;/em>, marzo de 2026: &lt;a href="https://arxiv.org/abs/2603.20313">https://arxiv.org/abs/2603.20313&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, convenciones semánticas de GenAI y MCP: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;li>IBM ContextForge, gateway y registro MCP: &lt;a href="https://github.com/IBM/mcp-context-forge">https://github.com/IBM/mcp-context-forge&lt;/a>.&lt;/li>
&lt;li>Docker MCP Gateway: &lt;a href="https://github.com/docker/mcp-gateway">https://github.com/docker/mcp-gateway&lt;/a>.&lt;/li>
&lt;li>agentgateway: &lt;a href="https://github.com/agentgateway/agentgateway">https://github.com/agentgateway/agentgateway&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Virtual keys, budgets and limits in LiteLLM: the layer that decides who gets the GPU, and the four things the documentation gets wrong</title><link>https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/</link><pubDate>Tue, 08 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/</guid><description>&lt;blockquote>
&lt;p>Third article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, and &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2 of the proxy&lt;/a> covered availability. Here it is governance: who can call, which model, how much and on what budget. The economic model behind those figures is in &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>On a platform running on your own GPUs there is no provider invoice to put consumption in order. Capacity is fixed, known and shared, so someone has to impose the allocation, and that someone is the gateway.&lt;/p>
&lt;p>&lt;strong>The hierarchy has six levels&lt;/strong>: organisation, team, team member, internal user, key and end customer, plus the project and tag scopes. Each level&amp;rsquo;s budgets are evaluated &lt;strong>independently and all of them have to pass&lt;/strong>. They neither add up nor cancel each other out, with one exception.&lt;/p>
&lt;p>&lt;strong>The exception is in the code and it is the most important governance trap in the system.&lt;/strong> A key that belongs to a team suppresses the check of its owner&amp;rsquo;s personal budget, unless &lt;code>apply_user_budget_to_team_keys&lt;/code> is enabled. A user with a hundred-euro cap who works with their team&amp;rsquo;s key spends whatever the team allows.&lt;/p>
&lt;p>&lt;strong>Rotating a key preserves its spend.&lt;/strong> &lt;code>/key/regenerate&lt;/code> does an UPDATE on the same row changing the hash, so &lt;code>spend&lt;/code>, &lt;code>max_budget&lt;/code> and the budget window remain intact. It accepts a grace period, stored in a table of its own, and if the period&amp;rsquo;s format is wrong it is ignored without blocking anything.&lt;/p>
&lt;p>&lt;strong>The v3 limiter is already the default&lt;/strong> in 1.100.0, and you go back to the previous one with an environment variable. Its window is sliding, sixty seconds, anchored to the first request, and the counters are incremented with Lua scripts inside Redis using Redis&amp;rsquo;s own timestamp to close the race between replicas.&lt;/p>
&lt;p>And four points where the official documentation does not match the code, verified against tag v1.100.0: &lt;strong>an exhausted budget returns 429&lt;/strong>, not 400; &lt;strong>the audited actions are six&lt;/strong>, not three; &lt;code>/key/info&lt;/code> and &lt;code>/key/list&lt;/code> are &lt;strong>GET&lt;/strong>, not POST; and the v3 limiter carries a docstring saying it is not production-ready while it is the one loaded by default.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-as-an-administrative-boundary">You are here: the gateway as an administrative boundary&lt;/h2>
&lt;p>Without this layer, an in-house inference platform has a single mode of operation: whoever knows the URL consumes. It works while there is one team. It stops working the day there are three, or the day someone connects an agent that fires twenty calls per interaction and wrecks everyone else&amp;rsquo;s time to first token.&lt;/p>
&lt;p>The two questions this layer answers are different and have to be kept apart. The &lt;strong>capacity&lt;/strong> one is instantaneous: how many requests and how many tokens per minute each consumer can push, so the fleet does not saturate and latency stays within the service agreement. The &lt;strong>cost&lt;/strong> one is cumulative: how much each consumer has spent this month against its budget. They are configured in the same place, applied at different moments and fail in different ways.&lt;/p>
&lt;h2 id="the-analogy-the-buildings-key-ring">The analogy: the building&amp;rsquo;s key ring&lt;/h2>
&lt;p>An office building hands out keys at three levels. The master opens everything and the building manager has it. The floor keys open a whole floor and each tenant company has one. And the office keys open one office.&lt;/p>
&lt;p>Three properties of that system are the ones that matter here. A key is replaced without changing the lock of the whole building, and the tenant keeps their parking space and their electricity meter: that is rotation preserving the spend. A lost key is cancelled from the central panel without collecting anything physical: that is blocking from the interface. And the record of who took out each key and when lives in a separate book, which is what you show an inspector: that is the audit table.&lt;/p>
&lt;p>The analogy breaks at one point, and that point is where this system&amp;rsquo;s governance flaw lives. In the building, having a floor key does not cancel the office&amp;rsquo;s consumption limit. In LiteLLM, it does.&lt;/p>
&lt;h2 id="the-hierarchy-and-the-line-that-breaks-it">The hierarchy, and the line that breaks it&lt;/h2>
&lt;p>The levels and their management endpoints:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Endpoints&lt;/th>
&lt;th>Budget&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Organisation&lt;/td>
&lt;td>&lt;code>/organization/new&lt;/code>, &lt;code>/organization/member_add&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Team&lt;/td>
&lt;td>&lt;code>/team/new&lt;/code>, &lt;code>/team/member_add&lt;/code>, &lt;code>/team/block&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Team member&lt;/td>
&lt;td>&lt;code>/team/member_add&lt;/code> with &lt;code>max_budget_in_team&lt;/code>&lt;/td>
&lt;td>Its own row in the budgets table&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal user&lt;/td>
&lt;td>&lt;code>/user/new&lt;/code>, &lt;code>/user/update&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Key&lt;/td>
&lt;td>&lt;code>/key/generate&lt;/code>, &lt;code>/key/update&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>, &lt;code>soft_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>End customer&lt;/td>
&lt;td>&lt;code>/customer/new&lt;/code>, and the parallel &lt;code>/end_user/*&lt;/code> set&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default behaviour is the right one for a multi-tenant platform: each scope carries its own counter and the check of each one is independent of the rest. A request passes if all of them pass.&lt;/p>
&lt;p>And then there is this condition, which lives in the authorisation checks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">is_team_key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">team_object&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">team_object&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">team_id&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">is_team_key&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">general_settings&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;apply_user_budget_to_team_keys&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Translated: when the key belongs to a team, the check of the budget of the user who owns that key &lt;strong>returns without evaluating anything&lt;/strong>. The personal cap exists in the database, is visible in the interface, and is not applied.&lt;/p>
&lt;p>For a deployment where personal budgets are decorative, it makes no difference. For one where the per-person cap is the control that was shown to someone as a guarantee that a user cannot overrun the platform, it is a hole. The line that closes it is one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">apply_user_budget_to_team_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="virtual-keys-the-complete-life-cycle">Virtual keys: the complete life cycle&lt;/h2>
&lt;p>The endpoints all exist and some of them are not where you expect:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>Method&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/key/generate&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Create&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/update&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Modify&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/info&lt;/code>&lt;/td>
&lt;td>&lt;strong>GET&lt;/strong>&lt;/td>
&lt;td>Query one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/list&lt;/code>&lt;/td>
&lt;td>&lt;strong>GET&lt;/strong>&lt;/td>
&lt;td>List&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/delete&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/regenerate&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Rotate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/block&lt;/code>, &lt;code>/key/unblock&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Suspend and reactivate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/health&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Check that key&amp;rsquo;s callbacks&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The two marked in bold are GET, and several examples in the documentation suggest otherwise. &lt;code>/key/health&lt;/code> is not what its name suggests either: it does not validate the key or its budget, it checks the logging callbacks associated with it.&lt;/p>
&lt;p>There are many creation parameters, and these are the ones that govern:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;https://gateway.interno/key/generate&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Authorization: Bearer sk-...&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;key_alias&amp;#34;: &amp;#34;equipo-datos-notebooks&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;t-datos&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;llama-70b&amp;#34;, &amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_budget&amp;#34;: 250,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;budget_duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tpm_limit&amp;#34;: 400000,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;rpm_limit&amp;#34;: 600,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_parallel_requests&amp;#34;: 8,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;duration&amp;#34;: &amp;#34;90d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tags&amp;#34;: [&amp;#34;produccion&amp;#34;, &amp;#34;notebooks&amp;#34;]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Alongside those there are controls that are rarely used and solve specific problems: &lt;code>model_max_budget&lt;/code> to set a per-model cap within the same key, &lt;code>model_tpm_limit&lt;/code> and &lt;code>model_rpm_limit&lt;/code> for the same thing with capacity, &lt;code>enforced_params&lt;/code> to require certain keys to always arrive with given fields, &lt;code>allowed_routes&lt;/code> so an application key cannot touch the management endpoints, and &lt;code>blocked&lt;/code> to be born suspended.&lt;/p>
&lt;h3 id="how-they-are-stored">How they are stored&lt;/h3>
&lt;p>A key&amp;rsquo;s hash is a single-pass hexadecimal SHA-256, with no salt and no derivation function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">hashed_token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">hashlib&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hexdigest&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The row lives in &lt;code>LiteLLM_VerificationToken&lt;/code>, with the hash as primary key, and it contains the accumulated spend, the limits, the expiry, the allowed models and the rest of the configuration.&lt;/p>
&lt;p>Two consequences to be clear about before an audit. The first is that this hash is not designed to resist a dictionary attack against the database, and the code itself acknowledges it in a comment about encryption key derivation, where it admits that a single-pass unsalted SHA-256 is not a derivation function and that moving to HKDF would be more defensible in an audit. The real mitigation is that virtual keys are generated with enough entropy, not the strength of the hash. The second is that &lt;code>LITELLM_SALT_KEY&lt;/code> &lt;strong>plays no part here&lt;/strong>: that variable encrypts the stored provider credentials, not the virtual keys.&lt;/p>
&lt;p>There is a trap around that variable that takes down a whole deployment. If it is not defined, it &lt;strong>falls back to the master key&lt;/strong>. Rotating &lt;code>LITELLM_MASTER_KEY&lt;/code> without having first set your own &lt;code>LITELLM_SALT_KEY&lt;/code> leaves every stored provider credential unreadable, and the failure does not block: it logs an error and returns null, so the symptom shows up as models that stop authenticating with no explanation. Setting &lt;code>LITELLM_SALT_KEY&lt;/code> on day one, and never touching it, is one of the things that saves the most grief.&lt;/p>
&lt;h3 id="rotation">Rotation&lt;/h3>
&lt;p>&lt;code>/key/regenerate&lt;/code> does not create a new key and delete the old one. It updates the same row changing the hash, so &lt;strong>the accumulated spend, the maximum budget and the reset window are preserved&lt;/strong>. For a monthly chargeback this is exactly what you want: rotating mid-month does not zero the counter.&lt;/p>
&lt;p>It also accepts a grace period, so the old key remains valid while consumers update:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;https://gateway.interno/key/sk-vieja/regenerate&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Authorization: Bearer sk-...&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;grace_period&amp;#34;: &amp;#34;24h&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The old hash is stored in a separate table with its revocation date. Without that parameter, revocation is immediate, and the default value can be set with &lt;code>LITELLM_KEY_ROTATION_GRACE_PERIOD&lt;/code>.&lt;/p>
&lt;p>A detail with teeth: if the period&amp;rsquo;s format is wrong, the system logs a warning and &lt;strong>continues with no grace period&lt;/strong>. The rotation completes, the old key dies on the spot, and all that is left is a log line. In an automated rotation procedure, that combination of &amp;ldquo;carry on&amp;rdquo; and &amp;ldquo;warn in the log&amp;rdquo; is what produces the outage at three in the morning.&lt;/p>
&lt;h2 id="what-exactly-happens-when-a-budget-runs-out">What exactly happens when a budget runs out&lt;/h2>
&lt;p>The code raises &lt;code>BudgetExceededError&lt;/code> with &lt;strong>status 429&lt;/strong>. The documentation says 400 in one place, and there the code wins. Its being a 429 has a practical implication: OpenAI clients retry automatically on a 429, so an exhausted budget turns into a retry storm against a gateway that is going to keep rejecting. Internal clients should distinguish the reason, which travels in the message.&lt;/p>
&lt;p>The messages are templates and state the scope, the spend and the cap:&lt;/p>
&lt;pre tabindex="0">&lt;code>Budget has been exceeded! Key=&amp;lt;clave&amp;gt; Current cost: &amp;lt;gasto&amp;gt;, Max budget: &amp;lt;tope&amp;gt;
Budget has been exceeded! Team=&amp;lt;equipo&amp;gt; Current cost: &amp;lt;gasto&amp;gt;, Max budget: &amp;lt;tope&amp;gt;
ExceededBudget: User=&amp;lt;usuario&amp;gt; over budget. Spend=&amp;lt;gasto&amp;gt;, Budget=&amp;lt;tope&amp;gt;
ExceededBudget: End User=&amp;lt;cliente&amp;gt; over budget. Spend=&amp;lt;gasto&amp;gt;, Budget=&amp;lt;tope&amp;gt;
Budget has been exceeded! Organization=&amp;lt;org&amp;gt; ...
Budget has been exceeded! Tag=&amp;lt;etiqueta&amp;gt; ...
&lt;/code>&lt;/pre>&lt;p>Two clarifications about the comparison. It is &lt;code>spend &amp;gt;= cap&lt;/code>, with the equals sign included, so landing exactly on the cap already blocks. And the soft budget, &lt;code>soft_budget&lt;/code>, &lt;strong>blocks nothing&lt;/strong>: it only writes a log line when it is crossed. It serves to warn, not to contain, and building an alert on top of it is the right way to use it.&lt;/p>
&lt;p>The rest of the rejections, with their codes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Situation&lt;/th>
&lt;th>Code&lt;/th>
&lt;th>Behaviour&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Budget exhausted&lt;/td>
&lt;td>429&lt;/td>
&lt;td>Message with scope, spend and cap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Expired key&lt;/td>
&lt;td>401&lt;/td>
&lt;td>It also deletes the cache entry, so the expiry takes effect across the whole fleet without waiting for the TTL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model not allowed&lt;/td>
&lt;td>403&lt;/td>
&lt;td>Different error type depending on the scope: key, team, user, organisation or project&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Blocked key&lt;/td>
&lt;td>Generic exception&lt;/td>
&lt;td>&lt;code>Key is blocked. Update via /key/unblock if you're an admin.&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row is an inconsistency in the project worth knowing about: blocking raises an exception with no explicit type or code, while expiry and model access do carry them. Any alert that discriminates by error type will not see blocks the way it sees the rest.&lt;/p>
&lt;p>A useful note about allowed models: the check accepts both the alias and the underlying model, so a key authorised for &lt;code>llama-70b&lt;/code> can call it by its alias or by its real name with no additional configuration.&lt;/p>
&lt;h2 id="capacity-limits-the-v3-limiter">Capacity limits: the v3 limiter&lt;/h2>
&lt;p>This is the most significant silent change of the year in this layer. In 1.100.0, the limiter loaded by default is the third version. Going back to the previous one has to be asked for explicitly:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LEGACY_MULTI_INSTANCE_RATE_LIMITING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is no flag to turn it on, only to turn it off. And it carries a contradiction worth knowing before trusting it: the file itself still opens with a comment saying it is under development and not production-ready, while it is the one running. The design is avowedly inspired by Envoy&amp;rsquo;s.&lt;/p>
&lt;p>&lt;strong>The window is sliding and lasts sixty seconds&lt;/strong>, configurable with &lt;code>LITELLM_RATE_LIMIT_WINDOW_SIZE&lt;/code>. It is not an exact rolling log nor a clock-aligned window: it anchors to the first request and resets once the window size has elapsed from that moment. For one-minute limits, the difference from a calendar window shows up at the edges and rarely matters.&lt;/p>
&lt;p>What does matter is Redis. The counters are incremented with Lua scripts executed inside Redis, using Redis&amp;rsquo;s own timestamp and not the pod&amp;rsquo;s, precisely so window resets are deterministic across replicas and to close the race between checking and counting. Without Redis, &lt;strong>each pod counts on its own and the effective limit is multiplied by the number of pods&lt;/strong>. A limit of 600 requests per minute with five replicas is a limit of 3,000.&lt;/p>
&lt;p>There is an inherent imprecision in the token limits that Redis does not solve. The number of output tokens is not known until the response finishes, so the limiter counts with an estimate before the call and reconciles afterwards. The possible excess is, per in-flight request, the difference between the estimate and the real figure. With high concurrency and long responses, that allows the cap to be exceeded within a window. To protect the fleet, the concurrent request limit (&lt;code>max_parallel_requests&lt;/code>) is a more direct instrument than tokens per minute.&lt;/p>
&lt;h3 id="what-the-client-sees">What the client sees&lt;/h3>
&lt;p>When limited, a 429 with the message giving the scope, the current limit, what is left and when it resets. And three headers: &lt;code>retry-after&lt;/code> with the window size, that is &lt;code>60&lt;/code> by default, plus &lt;code>rate_limit_type&lt;/code> and &lt;code>reset_at&lt;/code>.&lt;/p>
&lt;p>On the success path, the informational headers use a nested format of their own:&lt;/p>
&lt;pre tabindex="0">&lt;code>x-ratelimit-api_key-remaining-requests
x-ratelimit-api_key-limit-tokens
x-ratelimit-team-remaining-requests
&lt;/code>&lt;/pre>&lt;p>&lt;strong>They are not the standard names&lt;/strong> &lt;code>x-ratelimit-limit-requests&lt;/code> that OpenAI and compatibles emit, so a client expecting the usual format will find nothing. There is also an open issue about these headers being lost on streaming responses, which is most of an assistant&amp;rsquo;s traffic.&lt;/p>
&lt;h2 id="identity-beyond-the-static-key">Identity: beyond the static key&lt;/h2>
&lt;p>Handing out keys by hand works with three teams and stops working with thirty. The two routes for connecting the gateway to corporate identity:&lt;/p>
&lt;p>&lt;strong>JWT.&lt;/strong> It is enabled with &lt;code>enable_jwt_auth&lt;/code> and configured with the &lt;code>litellm_jwtauth&lt;/code> block, which maps token fields to LiteLLM entities: the team comes from &lt;code>team_id_jwt_field&lt;/code>, the user from &lt;code>user_id_jwt_field&lt;/code>, the organisation from &lt;code>org_id_jwt_field&lt;/code>. It supports dot notation for nested claims, automatic creation of users and teams with &lt;code>user_id_upsert&lt;/code> and &lt;code>team_id_upsert&lt;/code>, role-based access control with &lt;code>enforce_rbac&lt;/code>, per-team model restriction with &lt;code>enforce_team_based_model_access&lt;/code>, and several issuers at once. The issuer&amp;rsquo;s public key is cached with a TTL of 600 seconds by default.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_jwtauth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_email_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;email&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>OIDC for the admin interface.&lt;/strong> With Keycloak you use the generic provider, and the variables are &lt;code>PROXY_BASE_URL&lt;/code>, &lt;code>GENERIC_CLIENT_ID&lt;/code>, &lt;code>GENERIC_CLIENT_SECRET&lt;/code>, &lt;code>GENERIC_AUTHORIZATION_ENDPOINT&lt;/code>, &lt;code>GENERIC_TOKEN_ENDPOINT&lt;/code>, &lt;code>GENERIC_USERINFO_ENDPOINT&lt;/code>, plus a set of attributes to map the identifier, the email, the name and the role, and &lt;code>GENERIC_ROLE_MAPPINGS_*&lt;/code> to translate Keycloak groups into LiteLLM roles. Login is rate-limited and sessions have a TTL of their own.&lt;/p>
&lt;p>For automatic provisioning there is SCIM in the enterprise version, with base &lt;code>/scim/v2&lt;/code>, and deprovisioning a user blocks their keys and takes them out of the authentication cache. That part is not in the open tree, so it cannot be verified against the code.&lt;/p>
&lt;p>This fits with the identity work already covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a> and in the article on &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">MCP authentication with Keycloak&lt;/a>, which uses the same issuer.&lt;/p>
&lt;h2 id="audit-what-gets-recorded">Audit: what gets recorded&lt;/h2>
&lt;p>The table is &lt;code>LiteLLM_AuditLog&lt;/code> and its columns are what you would expect from a change log:&lt;/p>
&lt;pre tabindex="0">&lt;code>id, updated_at, changed_by, changed_by_api_key,
action, table_name, object_id, before_value, updated_values
&lt;/code>&lt;/pre>&lt;p>It stores the previous value and the new one, and attributes the change to an actor and to the key it was made with.&lt;/p>
&lt;p>Three clarifications the documentation does not capture well.&lt;/p>
&lt;p>&lt;strong>The actions are six, in the past tense&lt;/strong>: &lt;code>created&lt;/code>, &lt;code>updated&lt;/code>, &lt;code>deleted&lt;/code>, &lt;code>blocked&lt;/code>, &lt;code>unblocked&lt;/code> and &lt;code>rotated&lt;/code>. The documentation mentions three. That blocking, unblocking and rotation are actions in their own right is exactly what is needed to reconstruct a credential&amp;rsquo;s life cycle for an auditor.&lt;/p>
&lt;p>&lt;strong>The scope is wider than advertised.&lt;/strong> Besides teams, users, keys and models, changes to the proxy configuration and to the SSO configuration are audited. A change of identity issuer is recorded.&lt;/p>
&lt;p>&lt;strong>The default depends on the licence.&lt;/strong> The resolution order is &lt;code>litellm_settings.store_audit_logs&lt;/code>, then the variable &lt;code>LITELLM_STORE_AUDIT_LOGS&lt;/code>, and if neither is set, it stays &lt;strong>enabled in enterprise and disabled in the open version&lt;/strong>. An OSS deployment that assumes there is an audit trail does not have one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">store_audit_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two more pieces for a serious chain of evidence. The actor attribution can be passed with the &lt;code>LiteLLM-Changed-By&lt;/code> header, and its use is restricted by the key&amp;rsquo;s or the team&amp;rsquo;s configuration, so any caller cannot forge it. And the events can be shipped out with &lt;code>audit_log_callbacks&lt;/code> and its S3 parameters, which is what allows depositing them in an immutable store with object lock.&lt;/p>
&lt;p>That last part is what turns the record into evidence. The Postgres table is mutable by anyone with database access, and as of September 2026 it has no indexes, so querying it by date range over a long history will not be comfortable either.&lt;/p>
&lt;h2 id="what-this-demonstrates-to-an-auditor-and-what-it-does-not">What this demonstrates to an auditor, and what it does not&lt;/h2>
&lt;p>This is the part that decides whether the setup is good enough for a regulated customer. With everything above configured, the platform sustains without difficulty:&lt;/p>
&lt;p>&lt;strong>Access control via corporate identity&lt;/strong>, with onboarding and offboarding in the identity provider and not in the gateway, and with the role determining which models are reachable. &lt;strong>Segregation between tenants&lt;/strong>, with independent counters per organisation, team and person. &lt;strong>Traceability of administrative changes&lt;/strong>, with actor, time, previous value and new value, exportable to an immutable store. And &lt;strong>demonstrable consumption limits&lt;/strong>, with the evidence of the rejection in the spend log.&lt;/p>
&lt;p>What it does not cover, and has to be solved elsewhere:&lt;/p>
&lt;p>&lt;strong>The record of the interactions&lt;/strong> is not this. The audit table records configuration changes, not model calls. The calls are in the spend log and in the traces, and traces are best-effort, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">first article in the series&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The integrity of the record&lt;/strong> is not something Postgres provides. Without export to immutable storage, the evidence is only as strong as the database permissions.&lt;/p>
&lt;p>&lt;strong>The content of the prompts&lt;/strong> is a separate problem, with its own personal-data treatment and its own decision about whether it is stored, masked or not logged at all.&lt;/p>
&lt;p>And a warning about the control that tends to be shown first: while &lt;code>apply_user_budget_to_team_keys&lt;/code> is not enabled, the per-person cap is not being applied to team keys. Showing that field in the interface as proof of a control that is not executed is the kind of finding a competent auditor discovers. The complete control mapping is in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>apply_user_budget_to_team_keys: true&lt;/code> if personal budgets have to be enforced.&lt;/li>
&lt;li>&lt;code>LITELLM_SALT_KEY&lt;/code> set on day one, different from the master key and never rotated lightly.&lt;/li>
&lt;li>&lt;code>store_audit_logs: true&lt;/code> explicitly, without trusting the default.&lt;/li>
&lt;li>&lt;code>audit_log_callbacks&lt;/code> towards an immutable store if an audit has to be sustained.&lt;/li>
&lt;li>Redis mandatory if there is more than one replica, or the limits get multiplied by the number of pods.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> per key, on top of the per-minute limits, to protect the fleet from a runaway agent.&lt;/li>
&lt;li>&lt;code>allowed_routes&lt;/code> on application keys, so they cannot reach the management endpoints.&lt;/li>
&lt;li>&lt;code>soft_budget&lt;/code> with an alert on top, understanding that it does not block.&lt;/li>
&lt;li>Rotation with a verified &lt;code>grace_period&lt;/code>, because an invalid format revokes on the spot.&lt;/li>
&lt;li>JWT against Keycloak as soon as there are more than a handful of teams, with &lt;code>enforce_rbac&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code>.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>A user&amp;rsquo;s cap is not enforced.&lt;/strong> They are using a team key and &lt;code>apply_user_budget_to_team_keys&lt;/code> is missing.&lt;/p>
&lt;p>&lt;strong>Clients retry in a loop against an exhausted budget.&lt;/strong> The code is 429 and the OpenAI libraries retry 429s automatically.&lt;/p>
&lt;p>&lt;strong>The soft budget has stopped nothing.&lt;/strong> It does not block, it only logs.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced at double or triple.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The client does not see the limit headers.&lt;/strong> The names are nested and proprietary, and in streaming there is an open issue where they get lost.&lt;/p>
&lt;p>&lt;strong>There is no audit log.&lt;/strong> It is an OSS deployment and the default there is disabled.&lt;/p>
&lt;p>&lt;strong>The models stop authenticating after rotating the master key.&lt;/strong> There was no fixed &lt;code>LITELLM_SALT_KEY&lt;/code>, and the encrypted provider credentials can no longer be decrypted.&lt;/p>
&lt;p>&lt;strong>The rotation cut the service on the spot.&lt;/strong> The &lt;code>grace_period&lt;/code> carried a format the system did not understand and it carried on without it.&lt;/p>
&lt;p>&lt;strong>Blocking a key does not fire the alert.&lt;/strong> It is raised as a generic exception, without the type that expiry and model access do carry.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The keys and budgets layer is the one that turns a shared URL into a platform with tenants. It is well solved in LiteLLM and has more depth than the documentation suggests, with per-model budgets inside a key, multiple budget windows, per-tag limits and rotation that preserves the history.&lt;/p>
&lt;p>Out of the whole article, two things take the attention. The &lt;code>apply_user_budget_to_team_keys&lt;/code> line, because it is the difference between having a control and believing you have one. And setting &lt;code>LITELLM_SALT_KEY&lt;/code>, because its default turns a routine rotation of the master key into an outage that is hard to diagnose. Both are one line of configuration and both are discovered late.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — what happens to these limits when the caller is an agentic loop, and why &lt;code>max_parallel_requests&lt;/code> is only applied on the key.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — where the traces of all this end up, and why the audit record cannot lean on them.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the Redis taken as given here, and the Postgres arithmetic that sustains the spend table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — where the figures that go into &lt;code>max_budget&lt;/code> come from, and the difference between showback and chargeback.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the conversion that gives meaning to a budget in euros on your own GPU.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — the complete mapping, of which this article covers the access control and administrative traceability part.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign stack&lt;/a> — the management of &lt;code>LITELLM_SALT_KEY&lt;/code> and of the rest of the deployment&amp;rsquo;s secrets.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the same identity issuer, applied to the platform&amp;rsquo;s other front door.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the alternative to these keys, and why a long-lived key decouples identity from the provider.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Virtual Keys&lt;/em> (parámetros de creación, ciclo de vida, rotación): &lt;a href="https://docs.litellm.ai/docs/proxy/virtual_keys">https://docs.litellm.ai/docs/proxy/virtual_keys&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em> (jerarquía, presupuestos por nivel, clientes finales): &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>JWT Token Auth&lt;/em> (bloque &lt;code>litellm_jwtauth&lt;/code> y todos sus campos): &lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">https://docs.litellm.ai/docs/proxy/token_auth&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Audit Logs&lt;/em> y administración múltiple (la cabecera &lt;code>LiteLLM-Changed-By&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/multiple_admins">https://docs.litellm.ai/docs/proxy/multiple_admins&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>SCIM&lt;/em> (aprovisionamiento automático, versión enterprise): &lt;a href="https://docs.litellm.ai/docs/tutorials/scim_litellm">https://docs.litellm.ai/docs/tutorials/scim_litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/auth/auth_checks.py&lt;/code> en la etiqueta v1.100.0 (la condición de &lt;code>apply_user_budget_to_team_keys&lt;/code> y los mensajes de presupuesto agotado): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/auth/auth_checks.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/auth/auth_checks.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/management_endpoints/key_management_endpoints.py&lt;/code> en v1.100.0 (rutas, rotación sobre la misma fila, periodo de gracia): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/management_endpoints/key_management_endpoints.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/management_endpoints/key_management_endpoints.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code> y &lt;code>litellm/proxy/hooks/__init__.py&lt;/code> en v1.100.0 (el limitador v3 como valor por defecto, la ventana, los scripts Lua): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/hooks/parallel_request_limiter_v3.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/exceptions.py&lt;/code> en v1.100.0 (&lt;code>BudgetExceededError&lt;/code> con código 429): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/exceptions.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/exceptions.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/common_utils/encrypt_decrypt_utils.py&lt;/code> en v1.100.0 (&lt;code>LITELLM_SALT_KEY&lt;/code> cayendo en la clave maestra, y el comentario sobre la derivación de claves): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/common_utils/encrypt_decrypt_utils.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/common_utils/encrypt_decrypt_utils.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>schema.prisma&lt;/code> en v1.100.0 (&lt;code>LiteLLM_VerificationToken&lt;/code>, &lt;code>LiteLLM_AuditLog&lt;/code>, la tabla de claves deprecadas): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/schema.prisma">https://github.com/BerriAI/litellm/blob/v1.100.0/schema.prisma&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #27748 — cabeceras &lt;code>x-ratelimit-*&lt;/code> perdidas en respuestas en streaming con el limitador v3: &lt;a href="https://github.com/BerriAI/litellm/issues/27748">https://github.com/BerriAI/litellm/issues/27748&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Hardening and secrets in the sovereign LLM stack: defence in depth</title><link>https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/</link><pubDate>Thu, 11 Jun 2026 11:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/</guid><description>&lt;blockquote>
&lt;p>Part of the operational series on squeezing a generic on-premise 4×H100 SXM 80GB LLM cluster. The sibling pieces: the &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion from PDF to indexed chunk&lt;/a> that fills the vector database, the &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">embeddings and rerankers service with TEI in production&lt;/a> that feeds it, and, the most directly coupled to this post, the &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps of the inference stack with Flux&lt;/a>, because GitOps and secrets share a chicken-and-egg problem we solve here. Assembling all of this into a conversational assistant (LibreChat + LiteLLM + RAG) is covered by another post in the series, still in draft.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A sovereign LLM inference stack is, at minimum, six services: a gateway (LiteLLM or equivalent), an inference engine (vLLM), an embeddings/rerankers service (TEI), a vector database, a state database (conversations, users) and a front end (LibreChat). Six services are six attack surfaces, and the assistant &amp;ldquo;works&amp;rdquo; as soon as the gateway returns tokens, long before it is hardened. This post walks through defence in depth layer by layer:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Secrets&lt;/strong>: never in the clear in git. &lt;code>sealed-secrets&lt;/code> (asymmetric encryption, controller in the cluster) vs SOPS/age (file encryption, simple, pure GitOps) vs External Secrets Operator + Vault (dynamic secrets, native rotation). GitOps&amp;rsquo;s chicken-and-egg problem.&lt;/li>
&lt;li>&lt;strong>Network&lt;/strong>: default-deny &lt;code>NetworkPolicy&lt;/code> + explicit allow; L3/L4 with the standard NetworkPolicy, L7 and DNS-based egress with Cilium (eBPF); egress control so the data does not leave the perimeter; internal mTLS.&lt;/li>
&lt;li>&lt;strong>Pod security&lt;/strong>: &lt;code>runAsNonRoot&lt;/code>, &lt;code>readOnlyRootFilesystem&lt;/code>, dropped capabilities, seccomp &lt;code>RuntimeDefault&lt;/code>, restricted Pod Security Standards, nothing privileged.&lt;/li>
&lt;li>&lt;strong>Supply chain&lt;/strong>: pin by digest, scan with Trivy, sign with cosign, admission control that rejects anything unsigned.&lt;/li>
&lt;li>&lt;strong>AuthN/Z&lt;/strong>: LiteLLM virtual keys, OIDC/LDAP in the front end, Kubernetes RBAC with least privilege.&lt;/li>
&lt;li>&lt;strong>Runtime&lt;/strong>: detection and, optionally, enforcement with Tetragon.&lt;/li>
&lt;li>&lt;strong>Data at rest&lt;/strong>: storage encryption, credentials for the vector database and the state database.&lt;/li>
&lt;/ol>
&lt;p>The thesis: hardening reduces the blast radius, it does not eliminate it. That is why you prioritise by impact, and the first job on a 4×H100 is the gateway↔vector store pair and egress-deny.&lt;/p>
&lt;h2 id="the-analogy-the-sovereign-office">The analogy: the sovereign office&lt;/h2>
&lt;p>Picture an office holding sensitive documentation: the corpus that feeds the RAG, the users&amp;rsquo; conversations, the engine&amp;rsquo;s credentials. It is not enough for the street door to lock. Real security is &lt;strong>defence in depth&lt;/strong>: several layers, each assuming the previous one can fail.&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Defence in depth as concentric layers: perimeter, access control, fire partitions, safe, cameras, data">
&lt;defs>&lt;marker id="hd1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#888"/>&lt;/marker>&lt;/defs>
&lt;rect x="20" y="20" width="720" height="480" rx="14" fill="none" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="380" y="44" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="#3b82f6">Perimeter — egress control (the data does not leave)&lt;/text>
&lt;rect x="58" y="58" width="644" height="404" rx="12" fill="none" stroke="#22c55e" stroke-width="2"/>
&lt;text x="380" y="80" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#22c55e">Access control at every door — default-deny NetworkPolicy&lt;/text>
&lt;rect x="100" y="94" width="560" height="332" rx="10" fill="none" stroke="#f59e0b" stroke-width="2"/>
&lt;text x="380" y="114" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#f59e0b">Fire partitions — Pod Security restricted + namespaces&lt;/text>
&lt;rect x="142" y="128" width="476" height="262" rx="9" fill="none" stroke="#888" stroke-width="1.6"/>
&lt;text x="380" y="148" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">Doorman who checks credentials — admission control (cosign)&lt;/text>
&lt;rect x="184" y="162" width="392" height="194" rx="8" fill="none" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="182" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">Cameras — Tetragon/Falco (logging and detection)&lt;/text>
&lt;rect x="248" y="208" width="264" height="120" rx="10" fill="#ef4444" fill-opacity="0.12" stroke="#ef4444" stroke-width="2"/>
&lt;text x="380" y="238" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="#ef4444">Safe&lt;/text>
&lt;text x="380" y="262" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">encrypted secrets + rotated key&lt;/text>
&lt;text x="380" y="284" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">RAG corpus · conversations&lt;/text>
&lt;text x="380" y="306" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">engine credentials&lt;/text>
&lt;text x="380" y="490" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#888">Each layer assumes the outer one can fall. Crossing them all is expensive; that cost is the security.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The mapping is direct:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The building&amp;rsquo;s perimeter&lt;/strong> is &lt;strong>egress control&lt;/strong>: making sure a leaked secret or a compromised process cannot exfiltrate the corpus to an external server. It is the most underrated layer and the first one I put in place in a sovereign deployment.&lt;/li>
&lt;li>&lt;strong>Access control at every interior door&lt;/strong> is the default-deny &lt;code>NetworkPolicy&lt;/code>: the front end does not talk to the vector database directly because it has no reason to; only the strictly necessary pairs are open.&lt;/li>
&lt;li>&lt;strong>The fire partitions&lt;/strong> are pod isolation (&lt;code>restricted&lt;/code>) and namespaces: if one service catches fire, the fire does not jump to the one next door.&lt;/li>
&lt;li>&lt;strong>The doorman who checks credentials&lt;/strong> is the admission control that verifies each image&amp;rsquo;s &lt;strong>cosign signature&lt;/strong> before letting it into the cluster.&lt;/li>
&lt;li>&lt;strong>The cameras&lt;/strong> are Tetragon/Falco: they record what each process did and detect (or kill) anything anomalous.&lt;/li>
&lt;li>&lt;strong>The safe with a rotated key&lt;/strong> is the secrets: encrypted at rest, out of git, and rotated so a stolen key expires.&lt;/li>
&lt;/ul>
&lt;p>No layer is sufficient on its own. The building is secure because crossing them all is expensive.&lt;/p>
&lt;h2 id="layer-1--secrets-gitopss-chicken-and-egg-problem">Layer 1 — Secrets: GitOps&amp;rsquo;s chicken-and-egg problem&lt;/h2>
&lt;p>The stack is deployed by GitOps: the sibling piece on &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">Flux&lt;/a> reconciles the state declared in git against the cluster. That is excellent for manifests, but there is a foundational problem: the inference engine needs the Hugging Face token to download the model, the state database needs its password, the gateway needs its master key. &lt;strong>Those secrets cannot go into git in the clear.&lt;/strong> Anyone with read access to the repo, and in an organisation with generous &lt;code>read&lt;/code> that is a lot of people, plus every repo backup, plus every fork, would read them.&lt;/p>
&lt;p>The chicken and the egg: GitOps wants all state to be in git, but secrets cannot be in git in the clear. The solution comes in three families:&lt;/p>
&lt;h3 id="family-a--sealed-secrets-asymmetric-encryption-controller-in-the-cluster">Family A — sealed-secrets (asymmetric encryption, controller in the cluster)&lt;/h3>
&lt;p>&lt;a href="https://github.com/bitnami-labs/sealed-secrets">Bitnami Sealed Secrets&lt;/a> is a controller running in the cluster plus a client CLI, &lt;code>kubeseal&lt;/code>. It uses asymmetric cryptography: there is a key pair. The public one is held by developers and is used to encrypt; the private one lives only in the cluster&amp;rsquo;s controller and is used to decrypt. The flow:&lt;/p>
&lt;ol>
&lt;li>The developer takes a normal &lt;code>Secret&lt;/code> and encrypts it with &lt;code>kubeseal&lt;/code>, which obtains the controller&amp;rsquo;s public key. Result: a &lt;code>SealedSecret&lt;/code> resource.&lt;/li>
&lt;li>That &lt;code>SealedSecret&lt;/code> &lt;strong>is&lt;/strong> committed to git in the clear — it is encrypted, it is not readable.&lt;/li>
&lt;li>The controller in the cluster detects it, decrypts it with its private key and creates the real Kubernetes &lt;code>Secret&lt;/code> in the target namespace.&lt;/li>
&lt;/ol>
&lt;p>The key property: because the private key &lt;strong>never leaves the cluster&lt;/strong>, neither the developer nor anyone with access to git can decrypt. And the encryption includes the namespace name: a &lt;code>SealedSecret&lt;/code> sealed for &lt;code>inferencia&lt;/code> cannot be moved to &lt;code>front&lt;/code> and decrypted there — it behaves as if each namespace had its own key. The controller also manages rotation of the sealing keys, labelling them as &lt;code>active&lt;/code> or &lt;code>compromised&lt;/code>.&lt;/p>
&lt;p>Advantage: it fits pure GitOps perfectly, the encrypted secret lives with the rest of the state. Limitation: the secret, once decrypted, ends up as a normal Kubernetes &lt;code>Secret&lt;/code> — it sits in &lt;code>etcd&lt;/code>, and &lt;code>etcd&lt;/code> has to be encrypted separately (we will see that in layer 7).&lt;/p>
&lt;h3 id="family-b--sops--age-file-encryption-simple">Family B — SOPS + age (file encryption, simple)&lt;/h3>
&lt;p>&lt;a href="https://github.com/getsops/sops">SOPS&lt;/a> encrypts the whole YAML/JSON file (or only its values) and leaves it in git encrypted, decrypting it only at deployment time. It supports cloud KMS (AWS/GCP/Azure), PGP and, the relevant one for on-premise sovereignty, age, a simple serverless offline encryption scheme. The GitOps operator (Flux brings native SOPS integration) decrypts on reconciliation using the age key stored in the cluster.&lt;/p>
&lt;p>Advantage: simple, scriptable, with no server to maintain; the operational weight falls on safeguarding the age key. It is the usual recommendation for small teams or for getting started. Limitation: rotation is manual (re-encrypt everything with the new key) and there are no dynamic secrets.&lt;/p>
&lt;h3 id="family-c--external-secrets-operator--vault-dynamic-secrets-native-rotation">Family C — External Secrets Operator + Vault (dynamic secrets, native rotation)&lt;/h3>
&lt;p>The &lt;a href="https://external-secrets.io/">External Secrets Operator (ESO)&lt;/a> &lt;strong>keeps no secrets in git at all&lt;/strong>. All that goes into git is an &lt;code>ExternalSecret&lt;/code>: a reference saying &amp;ldquo;the &lt;code>password&lt;/code> field comes from path &lt;code>secret/data/vectordb&lt;/code> in such-and-such a &lt;code>SecretStore&lt;/code>&amp;rdquo;. The operator reads from an external store, typically &lt;a href="https://developer.hashicorp.com/vault">HashiCorp Vault&lt;/a>, and synchronises the Kubernetes &lt;code>Secret&lt;/code>. Vault keeps the secrets in its own encrypted backend and serves them over an authenticated API, so that they &lt;strong>never reside in git&lt;/strong> and, with its dynamic engines, it can issue short-lived database credentials that expire on their own.&lt;/p>
&lt;p>Advantage: native rotation, dynamic secrets, centralised auditing, a single point of governance. Limitation: you have to operate Vault (sealing/unsealing, high availability, access policy), which is real work.&lt;/p>
&lt;h3 id="how-to-choose-and-rotation">How to choose, and rotation&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>sealed-secrets&lt;/th>
&lt;th>SOPS + age&lt;/th>
&lt;th>ESO + Vault&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Secret in git&lt;/td>
&lt;td>encrypted&lt;/td>
&lt;td>encrypted&lt;/td>
&lt;td>reference only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Extra server&lt;/td>
&lt;td>lightweight controller&lt;/td>
&lt;td>none&lt;/td>
&lt;td>Vault (heavy)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rotation&lt;/td>
&lt;td>automatic sealing keys&lt;/td>
&lt;td>manual (re-encrypt)&lt;/td>
&lt;td>native / dynamic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dynamic (short-lived) secrets&lt;/td>
&lt;td>no&lt;/td>
&lt;td>no&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fit with pure GitOps&lt;/td>
&lt;td>excellent&lt;/td>
&lt;td>excellent&lt;/td>
&lt;td>good (references)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Operational curve&lt;/td>
&lt;td>low&lt;/td>
&lt;td>very low&lt;/td>
&lt;td>high&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For a sovereign 4×H100 getting started, &lt;strong>SOPS/age or sealed-secrets&lt;/strong> cover 90% at minimal cost. When the system grows and requirements for frequent rotation and auditing appear, typical in ENS High Category scenarios, you migrate to &lt;strong>ESO + Vault&lt;/strong>. The rotation rule: &lt;strong>a secret that never rotates is a secret that, once leaked, is leaked forever&lt;/strong>. Rotation does not prevent the leak; it bounds its window of validity. We will come back to that when we talk about blast radius.&lt;/p>
&lt;h2 id="layer-2--network-default-deny-egress-and-the-arithmetic-of-the-surface">Layer 2 — Network: default-deny, egress and the arithmetic of the surface&lt;/h2>
&lt;h3 id="the-arithmetic-of-communication-pairs">The arithmetic of communication pairs&lt;/h3>
&lt;p>Without a network policy, in Kubernetes &lt;strong>every pod can talk to every pod&lt;/strong>. That is the default posture, and it is the worst one for a sovereign system. With $N$ services, the number of possible ordered communication pairs (who-can-call-whom) is:&lt;/p>
$$P_{\text{abierto}} = N \cdot (N-1) \approx N^2$$
&lt;p>With our $N = 8$ components (gateway, vLLM, TEI, vector database, state database, front end, plus the secrets controller and the observability one), that is $8 \cdot 7 = 56$ possible directed pairs. Fifty-six paths along which a compromised service could pivot laterally.&lt;/p>
&lt;p>Now we apply a default-deny &lt;code>NetworkPolicy&lt;/code>: nothing talks to anything except what is explicitly allowed. The real whitelist of an LLM stack is small. The strictly necessary pairs:&lt;/p>
&lt;ul>
&lt;li>front end → gateway&lt;/li>
&lt;li>gateway → vLLM&lt;/li>
&lt;li>gateway → TEI&lt;/li>
&lt;li>gateway → vector database&lt;/li>
&lt;li>gateway → state database&lt;/li>
&lt;li>front end → state database (sessions/users)&lt;/li>
&lt;/ul>
&lt;p>That is $E = 6$ edges. The communication surface falls from $56$ to $6$:&lt;/p>
$$\frac{E}{P_{\text{abierto}}} = \frac{6}{56} \approx 0.107$$
&lt;p>Almost &lt;strong>89% of the possible paths are closed&lt;/strong>. The front end can no longer touch vLLM or TEI or the vector database directly; if somebody compromises the front end, they have no network route to the corpus. This is the difference between $\sim N^2$ and a whitelist $E \ll N^2$.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Service graph with default-deny: allowed edges in green, blocked ones in dotted red">
&lt;text x="360" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Default-deny model — green allowed, red blocked&lt;/text>
&lt;!-- nodes -->
&lt;circle cx="120" cy="90" r="34" fill="#3b82f6" fill-opacity="0.15" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="120" y="94" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">front&lt;/text>
&lt;circle cx="360" cy="90" r="34" fill="#3b82f6" fill-opacity="0.15" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="360" y="94" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">gateway&lt;/text>
&lt;circle cx="600" cy="60" r="34" fill="#3b82f6" fill-opacity="0.15" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="600" y="64" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">vLLM&lt;/text>
&lt;circle cx="600" cy="160" r="34" fill="#3b82f6" fill-opacity="0.15" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="600" y="164" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">TEI&lt;/text>
&lt;circle cx="360" cy="270" r="34" fill="#3b82f6" fill-opacity="0.15" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="360" y="267" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="600" fill="currentColor">vector&lt;/text>
&lt;text x="360" y="280" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="600" fill="currentColor">DB&lt;/text>
&lt;circle cx="120" cy="270" r="34" fill="#3b82f6" fill-opacity="0.15" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="120" y="267" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="600" fill="currentColor">state&lt;/text>
&lt;text x="120" y="280" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="600" fill="currentColor">DB&lt;/text>
&lt;!-- allowed edges (green) -->
&lt;path d="M154,90 L326,90" stroke="#22c55e" stroke-width="2.4" fill="none"/>
&lt;path d="M392,78 L568,66" stroke="#22c55e" stroke-width="2.4" fill="none"/>
&lt;path d="M392,102 L568,150" stroke="#22c55e" stroke-width="2.4" fill="none"/>
&lt;path d="M372,122 L360,236" stroke="#22c55e" stroke-width="2.4" fill="none"/>
&lt;path d="M340,120 L140,238" stroke="#22c55e" stroke-width="2.4" fill="none"/>
&lt;path d="M120,124 L120,236" stroke="#22c55e" stroke-width="2.4" fill="none"/>
&lt;!-- blocked edges (red dashed) -->
&lt;path d="M146,116 L334,250" stroke="#ef4444" stroke-width="1.6" stroke-dasharray="5 4" fill="none"/>
&lt;path d="M150,108 L572,150" stroke="#ef4444" stroke-width="1.6" stroke-dasharray="5 4" fill="none"/>
&lt;path d="M154,96 L566,62" stroke="#ef4444" stroke-width="1.6" stroke-dasharray="5 4" fill="none"/>
&lt;path d="M388,256 L572,170" stroke="#ef4444" stroke-width="1.6" stroke-dasharray="5 4" fill="none"/>
&lt;text x="360" y="350" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#22c55e">6 allowed edges&lt;/text>
&lt;text x="360" y="366" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#ef4444">~50 remaining paths closed&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="standard-l3l4-l7-and-egress-with-cilium">Standard L3/L4, L7 and egress with Cilium&lt;/h3>
&lt;p>Kubernetes&amp;rsquo; native &lt;code>NetworkPolicy&lt;/code> operates at L3/L4: it selects pods by label and allows/denies by port and protocol. That covers the bulk of the whitelist. But it has limits: it does not understand DNS or HTTP. This is where &lt;a href="https://docs.cilium.io/en/stable/network/kubernetes/policy/">Cilium&lt;/a> comes in, applying policies in the kernel via eBPF and extending them with &lt;code>CiliumNetworkPolicy&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Egress by FQDN/DNS&lt;/strong>: instead of pinning IPs (which change), allow &lt;code>egress&lt;/code> only to &lt;code>huggingface.co&lt;/code> so vLLM can download the model and block the rest. Critical for egress control.&lt;/li>
&lt;li>&lt;strong>L7&lt;/strong>: allow only certain HTTP methods/paths between the gateway and an internal service.&lt;/li>
&lt;li>&lt;strong>Enforcement modes&lt;/strong>: in the default mode, an endpoint with no policy selecting it has everything open; in &lt;em>always&lt;/em> mode, &lt;strong>everything is denied&lt;/strong> until a policy explicitly opens it. For a sovereign system, &lt;em>always&lt;/em> + default-deny is the target posture.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Egress control is the sovereign piece par excellence.&lt;/strong> An internal service being unable to open connections to the internet except to a handful of explicit destinations means that, even if vLLM or the gateway is compromised, &lt;strong>the corpus cannot leave the perimeter&lt;/strong>. The RAG is the asset of value; egress-deny is what stops it leaking out.&lt;/p>
&lt;h3 id="internal-mtls">Internal mTLS&lt;/h3>
&lt;p>The &lt;code>NetworkPolicy&lt;/code> says &lt;em>who&lt;/em> can talk to whom, but it neither encrypts nor authenticates east-west traffic. For that, mTLS (mutual TLS: client and server authenticate each other). Cilium offers native mutual authentication; alternatives such as Linkerd or Istio give you a full service mesh. For a six-service stack, Istio&amp;rsquo;s full mesh is usually over-engineering; Cilium&amp;rsquo;s mutual authentication or Linkerd (lighter) is proportionate. The effect: an attacker on the cluster network cannot impersonate the gateway or eavesdrop on internal traffic in the clear.&lt;/p>
&lt;h2 id="layer-3--pod-security-restricted-and-nothing-privileged">Layer 3 — Pod security: restricted, and nothing privileged&lt;/h2>
&lt;p>The &lt;a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/">Pod Security Standards&lt;/a> define three profiles: &lt;code>privileged&lt;/code> (no restrictions), &lt;code>baseline&lt;/code> (the reasonable minimum) and &lt;code>restricted&lt;/code> (hardened). The target for the whole LLM stack is &lt;code>restricted&lt;/code>, applied via Pod Security Admission with a namespace label in &lt;code>enforce&lt;/code> mode. The &lt;code>restricted&lt;/code> profile requires:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>runAsNonRoot: true&lt;/code>&lt;/strong> and a &lt;code>runAsUser&lt;/code> other than 0. No container runs as root.&lt;/li>
&lt;li>&lt;strong>&lt;code>readOnlyRootFilesystem: true&lt;/code>&lt;/strong>. The root filesystem is read-only; anything that needs to write goes to an &lt;code>emptyDir&lt;/code> or an explicit volume. An attacker cannot leave persistent binaries in the container.&lt;/li>
&lt;li>&lt;strong>&lt;code>drop: [&amp;quot;ALL&amp;quot;]&lt;/code>&lt;/strong> of Linux capabilities. No capabilities that are not needed.&lt;/li>
&lt;li>&lt;strong>seccomp &lt;code>RuntimeDefault&lt;/code>&lt;/strong>, which applies the runtime&amp;rsquo;s profile and blocks dangerous syscalls.&lt;/li>
&lt;li>&lt;strong>&lt;code>allowPrivilegeEscalation: false&lt;/code>&lt;/strong>, nothing &lt;code>privileged&lt;/code>, no host namespaces.&lt;/li>
&lt;/ul>
&lt;p>The nuance with GPUs: pods using an H100 load NVIDIA&amp;rsquo;s device plugin, which historically tempted people to relax the &lt;code>securityContext&lt;/code>. You do not need to run the inference container as privileged in order to use the GPU; device access is managed by the plugin, and the vLLM pod can and should run &lt;code>restricted&lt;/code>. This is a fire partition: if vLLM is compromised, the attacker has a non-root process, with no capabilities, a read-only FS and trimmed syscalls — a terrible starting point for escalation.&lt;/p>
&lt;h2 id="layer-4--image-supply-chain">Layer 4 — Image supply chain&lt;/h2>
&lt;p>The container image is third-party code running with access to your data. Three composed controls:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Pin by digest, not by tag.&lt;/strong> &lt;code>vllm/vllm-openai:latest&lt;/code> is a moving target; &lt;code>@sha256:...&lt;/code> is immutable. Pinning the digest guarantees that what was deployed is exactly what was audited.&lt;/li>
&lt;li>&lt;strong>Scanning with &lt;a href="https://github.com/aquasecurity/trivy">Trivy&lt;/a>.&lt;/strong> Before promoting an image, Trivy enumerates its CVEs and composes the SBOM (component inventory). The pipeline fails if there are unmitigated critical vulnerabilities.&lt;/li>
&lt;li>&lt;strong>Signing with &lt;a href="https://docs.sigstore.dev/cosign/signing/signing_with_containers/">cosign&lt;/a> and verification at admission.&lt;/strong> The Sigstore project (cosign for signing, Fulcio as a CA for ephemeral certificates via OIDC, Rekor as an immutable transparency log) allows the image to be signed. And Sigstore&amp;rsquo;s &lt;a href="https://github.com/sigstore/policy-controller">policy-controller&lt;/a>, or Kyverno, is an &lt;strong>admission controller&lt;/strong> that verifies the signature before admitting the pod: an image without a valid signature does not enter the cluster.&lt;/li>
&lt;/ol>
&lt;p>This is the doorman from the analogy: he checks each image&amp;rsquo;s credential (signature) at the door. A poisoned image uploaded to a registry, or a hijacked tag, stays outside because it carries no signature from the trusted issuer.&lt;/p>
&lt;h2 id="layer-5--authnz-virtual-keys-oidc-and-minimal-rbac">Layer 5 — AuthN/Z: virtual keys, OIDC and minimal RBAC&lt;/h2>
&lt;p>Three identity control points, from the outside in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Front end (OIDC/LDAP).&lt;/strong> The human user authenticates against the corporate identity provider. The front end does not invent its own user system; it delegates to OIDC. This gives SSO, MFA and centralised revocation.&lt;/li>
&lt;li>&lt;strong>Gateway (LiteLLM virtual keys).&lt;/strong> The gateway issues &lt;strong>virtual keys&lt;/strong>: each team, application or user has its own key with a budget, a &lt;em>rate limit&lt;/em> and permitted models. The gateway&amp;rsquo;s master key, and the real API keys towards the engine, are never seen by the client; clients only handle their virtual key, revocable individually. If a virtual key leaks, that one and only that one is revoked.&lt;/li>
&lt;li>&lt;strong>Kubernetes RBAC with least privilege.&lt;/strong> Each service&amp;rsquo;s &lt;code>ServiceAccount&lt;/code> has exactly the permissions it needs. The vLLM pod does not need to list &lt;code>Secret&lt;/code> objects in other namespaces or create pods. Restrictive RBAC means a stolen service account token opens very little.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-6--runtime-the-cameras-with-tetragon">Layer 6 — Runtime: the cameras with Tetragon&lt;/h2>
&lt;p>The previous layers are preventive. What is missing is detection: what if something, despite everything, runs where it should not? &lt;a href="https://tetragon.io/">Tetragon&lt;/a> is observable security and runtime enforcement over eBPF, &lt;em>Kubernetes-aware&lt;/em>. It hooks kernel events — &lt;code>process_exec&lt;/code>, &lt;code>tcp_connect&lt;/code>, &lt;code>security_file_open&lt;/code> — with typical overhead below 1%, and it can move from observing (recording the event) to enforcement (killing the process or cutting the connection in the kernel, &lt;code>Sigkill&lt;/code>).&lt;/p>
&lt;p>The operational rule, which I set out in detail in the &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">bubblewrap + Tetragon runbook&lt;/a>: &lt;strong>adopt first, block later&lt;/strong>. First you deploy in observation mode to build a baseline of the stack&amp;rsquo;s normal behaviour — which binaries vLLM executes, what the gateway connects to — without false positives. Only afterwards do you promote the clear rules to enforcement: kill any process that tries to read secret paths, or cut every &lt;code>tcp_connect&lt;/code> to destinations outside the egress whitelist. &lt;a href="https://falco.org/">Falco&lt;/a> is the detection-only alternative over eBPF; Tetragon adds enforcement. These cameras also produce the audit evidence: what each service ran and which attempt was blocked.&lt;/p>
&lt;h2 id="layer-7--data-at-rest">Layer 7 — Data at rest&lt;/h2>
&lt;p>The last thing we protect is data standing still:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Storage encryption.&lt;/strong> The persistent volumes, where the vector database and the state database live, on top of disk encryption (LUKS/dm-crypt) or Ceph-level encryption. A disk stolen from the datacenter does not reveal the corpus.&lt;/li>
&lt;li>&lt;strong>&lt;code>etcd&lt;/code> encryption.&lt;/strong> Remember that Kubernetes &lt;code>Secret&lt;/code> objects, once decrypted by sealed-secrets or ESO, are normal objects in &lt;code>etcd&lt;/code>. You have to enable &lt;code>etcd&lt;/code> &lt;em>encryption at rest&lt;/em>, or the secret sits in the clear in the control plane.&lt;/li>
&lt;li>&lt;strong>Database credentials.&lt;/strong> The vector database&amp;rsquo;s password and the state database&amp;rsquo;s are first-class secrets (layer 1), never embedded in the manifest or in environment variables in the clear in git.&lt;/li>
&lt;/ul>
&lt;h2 id="exposure-table-service--port--external-egress">Exposure table: service × port × external egress?&lt;/h2>
&lt;p>This table is the input for writing the &lt;code>NetworkPolicy&lt;/code> resources. The egress column is the one that decides what leaves the perimeter.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Service&lt;/th>
&lt;th>Internal port&lt;/th>
&lt;th>Permitted callers&lt;/th>
&lt;th>External egress?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>front end&lt;/td>
&lt;td>3080&lt;/td>
&lt;td>(human ingress via OIDC)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>gateway (LiteLLM)&lt;/td>
&lt;td>4000&lt;/td>
&lt;td>front end&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>8000&lt;/td>
&lt;td>gateway&lt;/td>
&lt;td>Only &lt;code>huggingface.co&lt;/code> for the initial download; zero in operation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TEI (embeddings/reranker)&lt;/td>
&lt;td>8080&lt;/td>
&lt;td>gateway&lt;/td>
&lt;td>Only model download; zero in operation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vector database&lt;/td>
&lt;td>6333&lt;/td>
&lt;td>gateway&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>state database&lt;/td>
&lt;td>5432&lt;/td>
&lt;td>gateway, front end&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>secrets controller&lt;/td>
&lt;td>—&lt;/td>
&lt;td>(control plane)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>observability&lt;/td>
&lt;td>9090&lt;/td>
&lt;td>internal scraping&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading: &lt;strong>no service needs external egress in normal operation&lt;/strong>. vLLM and TEI need it once to pull the model, and that permission can be temporary or restricted by FQDN to &lt;code>huggingface.co&lt;/code>. Everything else is total egress-deny. If a service starts attempting outbound connections this table does not contemplate, Tetragon records it and, in enforcement, cuts it.&lt;/p>
&lt;h2 id="blast-radius-a-leaked-secret-with-and-without-defence">Blast radius: a leaked secret, with and without defence&lt;/h2>
&lt;p>The blast radius measures how much damage a compromise does. Let us model it for the realistic worst case: the state database&amp;rsquo;s credential leaks.&lt;/p>
&lt;p>&lt;strong>Without hardening&lt;/strong> (flat network, unrotated secret, no egress-deny, no detection):&lt;/p>
&lt;ul>
&lt;li>The secret gives access to the state database from any pod (flat network → 56 open pairs).&lt;/li>
&lt;li>The secret does not rotate → it is valid indefinitely; the exploitation window is $\infty$ until somebody notices.&lt;/li>
&lt;li>Without egress-deny, the attacker dumps the whole database to an external server.&lt;/li>
&lt;li>Without Tetragon, nobody finds out until the public incident.&lt;/li>
&lt;li>Blast radius: &lt;strong>the entire state database, exfiltrated, undetected, for an indefinite time&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>With hardening&lt;/strong> (default-deny, ESO + Vault with dynamic credentials, egress-deny, Tetragon in enforcement):&lt;/p>
&lt;ul>
&lt;li>The secret is only usable from the pod that has a network route to the state database (1-2 pairs, not 56).&lt;/li>
&lt;li>With Vault&amp;rsquo;s dynamic credentials, the secret expires — say within a window $T$ of hours; past $T$, it is worthless.&lt;/li>
&lt;li>Egress-deny prevents the dump to the internet: the attacker can read, but cannot take anything out.&lt;/li>
&lt;li>Tetragon records the anomalous access and, in enforcement, kills the process attempting the outbound connection.&lt;/li>
&lt;/ul>
&lt;p>The qualitative reduction is enormous, but let us be quantitative about the window. If a static secret is valid forever and a rotated one with period $T$ is valid for at most $T$, and compromises arrive at rate $\lambda$, the expected number of &lt;em>live and exploitable&lt;/em> secrets at any given instant goes from growing without bound to being capped at $\lambda \cdot T$. With rotation every 24 h ($T = 1$ day) against &amp;ldquo;never&amp;rdquo;, a given secret&amp;rsquo;s window falls from months to a day: a reduction of &lt;strong>one to two orders of magnitude&lt;/strong> in temporal exposure. Combined with the reduction in network pairs ($56 \to \sim 2$, a factor of $\sim 28\times$) and egress-deny (from exfiltration possible to impossible by the direct route), the blast radius is drastically reduced.&lt;/p>
&lt;p>&lt;strong>But let us be honest: it does not reach zero.&lt;/strong> The attacker with the credential &lt;em>can read&lt;/em> the state database during the window $T$ from the compromised pod. Hardening turned &amp;ldquo;indefinite, silent catastrophe&amp;rdquo; into &amp;ldquo;a bounded, detected incident with no exfiltration by the direct route&amp;rdquo;. That is exactly what defence in depth promises: not invulnerability, but that &lt;strong>the cost of a full compromise is high and its radius small&lt;/strong>.&lt;/p>
&lt;h2 id="the-ens--nis2-angle">The ENS / NIS2 angle&lt;/h2>
&lt;p>These layers are not voluntary hygiene: they implement specific controls. The mapping is developed in detail in the &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS × ISO 42001 × EU AI Act post&lt;/a>; here, the actionable summary:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Hardening layer&lt;/th>
&lt;th>ENS measure (RD 311/2022)&lt;/th>
&lt;th>NIS2 / framework link&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Encrypted secrets + rotation&lt;/td>
&lt;td>&lt;code>op.exp.11&lt;/code> (cryptographic keys), &lt;code>mp.info.3&lt;/code> (encryption)&lt;/td>
&lt;td>Credential management; in High Cat., HSM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Default-deny NetworkPolicy + segmentation&lt;/td>
&lt;td>&lt;code>mp.com.1&lt;/code> (perimeter), &lt;code>mp.com.4&lt;/code> (flow separation)&lt;/td>
&lt;td>NIS2 art. 21: network security measures&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Egress control&lt;/td>
&lt;td>&lt;code>mp.com.1&lt;/code> + &lt;code>op.mon.1&lt;/code>&lt;/td>
&lt;td>Exfiltration prevention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal mTLS&lt;/td>
&lt;td>&lt;code>mp.com.2-3&lt;/code> (confidentiality/integrity in transit)&lt;/td>
&lt;td>Mandatory TLS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Restricted Pod Security&lt;/td>
&lt;td>&lt;code>op.exp.2&lt;/code> (hardened configuration)&lt;/td>
&lt;td>Configuration hardening (CIS)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trivy + cosign + admission&lt;/td>
&lt;td>&lt;code>op.exp.6&lt;/code> (malicious code), &lt;code>op.ext.3&lt;/code> (supply chain)&lt;/td>
&lt;td>NIS2 supply chain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OIDC + virtual keys + RBAC&lt;/td>
&lt;td>&lt;code>op.acc.1-2-5&lt;/code> (identification, access, authentication)&lt;/td>
&lt;td>MFA in High Cat.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tetragon runtime&lt;/td>
&lt;td>&lt;code>op.mon.1&lt;/code> (intrusion detection)&lt;/td>
&lt;td>Monitoring and response&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Encryption at rest + etcd&lt;/td>
&lt;td>&lt;code>mp.info.3&lt;/code> (encryption), &lt;code>mp.si&lt;/code> (media)&lt;/td>
&lt;td>Data at rest&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The honest note for auditing: &lt;strong>hardening reduces risk, it does not eliminate it&lt;/strong>, and maturity is demonstrated by &lt;strong>prioritising by impact&lt;/strong>. A competent auditor does not want to see nine half-finished layers; they want to see that egress-deny on the critical asset (the corpus) and secrets management are solid before mTLS is perfect between low-sensitivity services. For the management and governance context, see also &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001 as an AIMS&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act mapping onto the architecture&lt;/a>.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>In a real deployment on 4×H100 SXM 80GB, you do not harden the nine layers at once. The order, prioritised by impact:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Egress-deny on vLLM, TEI and the vector database, first.&lt;/strong> It is the barrier that stops the corpus leaving the perimeter, and it goes in as soon as the services start. Allow egress by FQDN to &lt;code>huggingface.co&lt;/code> only during the model&amp;rsquo;s initial download; afterwards, zero. It is the layer with the highest return per hour invested.&lt;/li>
&lt;li>&lt;strong>Secrets for the inference engine and the databases, out of git.&lt;/strong> The Hugging Face token vLLM uses to download the model, the vector database&amp;rsquo;s and the state database&amp;rsquo;s passwords, the gateway&amp;rsquo;s master key: into sealed-secrets or, if there is already a rotation requirement, into ESO + Vault. Never in &lt;code>values.yaml&lt;/code> in the clear.&lt;/li>
&lt;li>&lt;strong>Default-deny NetworkPolicy + the 6-edge whitelist.&lt;/strong> The gateway and the vector database are the most exposed services — the gateway because it receives all the traffic, the vector database because it holds the embedded RAG. Closing everything that is not on the whitelist cuts lateral pivoting from $\sim N^2$ to the 6 real edges.&lt;/li>
&lt;li>&lt;strong>&lt;code>restricted&lt;/code> Pod Security in the inference namespace&lt;/strong>, including the vLLM pod with a GPU (it needs no privileges to use the H100).&lt;/li>
&lt;li>&lt;strong>cosign + admission&lt;/strong> so only signed images get in; &lt;strong>Trivy&lt;/strong> in the GitOps pipeline of &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">Flux&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Tetragon in observation&lt;/strong>, baseline, and then enforcement over the egress of the sensitive pods.&lt;/li>
&lt;li>&lt;strong>Encryption at rest&lt;/strong> for the databases&amp;rsquo; volumes and for &lt;code>etcd&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>The gateway and the vector database are the two I harden first inside the whitelist: the gateway for being the exposed face, the vector database for holding the asset that egress-deny protects. The rest is built on top, layer by layer, always assuming the previous one can fail.&lt;/p>
&lt;h2 id="what-hardening-does-not-solve">What hardening does NOT solve&lt;/h2>
&lt;p>To close honestly, what these layers do not cover and what needs other pieces:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prompt-level attacks&lt;/strong> (jailbreak, indirect injection via the RAG corpus): that is the job of &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>, not of NetworkPolicy.&lt;/li>
&lt;li>&lt;strong>Agents with legitimate permissions doing something harmful&lt;/strong>: the runtime isolation from the &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">agent isolation post&lt;/a> bounds what an agent can touch, but a granted permission is a usable permission.&lt;/li>
&lt;li>&lt;strong>The human factor&lt;/strong>: a well-managed secret shared over Slack is still leaked. Rotation bounds the window, it does not eliminate the mistake.&lt;/li>
&lt;li>&lt;strong>Zero-day vulnerabilities&lt;/strong> in the components themselves: Trivy detects what is known; the unknown gets through until the CVE is published.&lt;/li>
&lt;/ul>
&lt;p>Hardening is a multiplier on the cost of attacking, not an impassable wall. Its value lies in making an individual compromise &lt;strong>bounded, detected and with no way out&lt;/strong> — and that, for a sovereign system holding sensitive data, is exactly the goal.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps of the inference stack with Flux&lt;/a> — the sibling piece: GitOps reconciles the state, and shares with this post the chicken-and-egg problem of secrets.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS × ISO 42001 × EU AI Act&lt;/a> — the detailed mapping of each hardening layer to an ENS measure, a 42001 control and an AI Act article.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the AIMS for the on-premise LLM&lt;/a> — the management system that frames hardening as a documented control.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: mapping onto the on-premise LLM architecture&lt;/a> — the robustness and cybersecurity articles (Art. 15) these layers satisfy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: fundamentals&lt;/a> — the prompt/content-level security layer that infrastructure hardening does not cover.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the semantic WAF that complements the NetworkPolicy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from workstation to cluster&lt;/a> — the threat model of runtime isolation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">Runbook: caging the AI agent with bubblewrap and Tetragon&lt;/a> — the operational procedure for Tetragon (observe first, block later) referenced in layer 6.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">Model chain of trust (3/4): signature, provenance and AIBOM&lt;/a> — layer 4 of this post (image supply chain with cosign and Trivy) taken to the model artefact: signing the weights, SLSA provenance attestations and an AIBOM inventory.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">Model chain of trust (4/4): who serves the model and which machine you trust&lt;/a> — the step after layer 2&amp;rsquo;s mTLS: cryptographic workload identity with SPIFFE/SPIRE and isolation of the execution environment in a TEE.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a> — key hashing, the &lt;code>LITELLM_SALT_KEY&lt;/code> variable that falls back to the master key by default, and rotation with a grace period.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — layer 5 of this article developed: who can authenticate against the IdP, what costs a licence and what it does not solve.&lt;/li>
&lt;/ul></description></item><item><title>Ontologies and knowledge graphs in LLMOps: the Linnaean nomenclature that holds up the six pipeline stages</title><link>https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/</link><pubDate>Wed, 03 Jun 2026 03:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/</guid><description>&lt;blockquote>
&lt;p>This post runs through &lt;strong>the six stages of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline&lt;/a>&lt;/strong> from a cross-cutting perspective: the &lt;strong>common nomenclature&lt;/strong> that makes the stages share a vocabulary. It connects directly to &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> (the corpus is curated &lt;em>against&lt;/em> an ontology), &lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">embeddings&lt;/a> (the ontology enriches the embedding with typed metadata), &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a> (the KG is a fourth channel alongside dense/sparse/multi-vector), &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> (golden sets are stratified by ontological class), &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a> (JSON Schemas derive from OWL/SHACL), and the three frameworks of &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO 42001&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act&lt;/a> (each one &lt;em>is&lt;/em> a control ontology).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The conversation about ontologies and LLMs has swung between two equally wrong positions over the last three years: either &amp;ldquo;LLMs already extract knowledge on their own, ontologies belong to the last century&amp;rdquo;, or &amp;ldquo;all of RAG has to go and we should build a knowledge graph on top&amp;rdquo;. The operational reality of mid-2026 is more sober: &lt;strong>the ontology is not a replacement for RAG but its common nomenclature&lt;/strong>, without which the six stages of the LLMOps pipeline work with different vocabularies without knowing it. The corpus is curated without knowing which entity classes exist; the embeddings drill through documents without being enriched with typed metadata; the evals report a global accuracy that hides whole class gaps; the guardrail blocks on word lists instead of formal classification; incident response groups badly because each alert names &amp;ldquo;the affected asset&amp;rdquo; its own way; compliance cannot map its controls because ENS, ISO 42001 and the EU AI Act are three ontologies and the system has none. This post takes apart what an ontology is in practical terms (TBox and ABox, RDF and SPARQL, the four OWL 2 profiles: EL for enormous terminologies such as SNOMED, QL for OBDA, RL for rule-based reasoning, DL for the full description logic, plus SHACL for shape validation, SKOS for thesauri, JSON-LD as a viable serialisation), walks the &lt;strong>six LLMOps stages&lt;/strong> showing where the ontology changes operations, reviews the GraphRAG field in 2026 with verifiable data (&lt;strong>Microsoft GraphRAG v2 Oct 2025&lt;/strong>, &lt;strong>LightRAG&lt;/strong> dual-level with incremental updates, &lt;strong>HippoRAG 2&lt;/strong> with Personalized PageRank, &lt;strong>KAG on OpenSPG&lt;/strong> ontology-grounded), inventories the vertical ontologies actually deployed in production (FIBO, SNOMED CT, schema.org, IEC 81346, GS1, Wikidata, &lt;strong>ENS Annex I-II of RD 311/2022&lt;/strong>, &lt;strong>EU AI Act Annex III&lt;/strong>), settles the viable open source on-prem stack with its licensing caveats (&lt;strong>Neo4j Community is GPLv3 with AGPL implications in some features&lt;/strong>, &lt;strong>KuzuDB upstream archived Oct 2025&lt;/strong>, forks &lt;code>bighorn&lt;/code> and &lt;code>ryugraph&lt;/code>), describes the five LLM × ontology integration patterns and closes with seven operational traps. The rule of thumb: &lt;strong>the knowledge graph is not the answer; shared formalised nomenclature is&lt;/strong>.&lt;/p>
&lt;h2 id="the-analogy-carl-linnaeus-1735">The analogy: Carl Linnaeus, 1735&lt;/h2>
&lt;p>In 1735 Carl von Linné published the first edition of &lt;em>Systema Naturae&lt;/em>. Before Linnaeus, European naturalists had an operational problem: the same species could appear in five treatises under five different Latin names, each a polynomial description of the type &lt;em>&amp;ldquo;Felis cauda elongata cum maculis nigris in dorso et lateribus&amp;rdquo;&lt;/em>, and two naturalists exchanging letters took months to realise they were arguing about the same animal. Biology was a field of &lt;strong>lexicographic noise&lt;/strong>: impossible to compare observations, impossible to verify replication, impossible to build cumulative theory.&lt;/p>
&lt;p>Linnaeus did not discover biology. What he discovered was that the field needed &lt;strong>a common nomenclature with three properties&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hierarchy&lt;/strong>. Kingdom → Phylum → Class → Order → Family → Genus → Species. Each level is a class with well-defined subclasses. A property of Felis (a carnivorous diet) is inherited automatically by Felis catus and Felis silvestris without being redeclared.&lt;/li>
&lt;li>&lt;strong>Unambiguous naming&lt;/strong>. Each species has &lt;strong>a single binomial name&lt;/strong> (Genus + specific epithet) and a single type specimen anchored in a museum. &lt;em>&amp;ldquo;Felis silvestris&amp;rdquo;&lt;/em> means exactly the same thing in Madrid, Stockholm and Calcutta.&lt;/li>
&lt;li>&lt;strong>Priority rules&lt;/strong>. If two botanists publish the same genus under different names, the first to register it validly wins. The naming convention is not debated in every paper: there is an explicit meta-level of governance.&lt;/li>
&lt;/ol>
&lt;p>After Linnaeus, &lt;strong>comparative biology becomes possible&lt;/strong>. Mendel can talk about &lt;em>Pisum sativum&lt;/em> and a Polish botanist knows exactly which plant to grow to replicate him. Darwin can compare Galápagos finches with finches from other islands with no confusion about what &amp;ldquo;the same kind of bird&amp;rdquo; means. The change is not one of instrumentation; the microscope had existed since Hooke (1665). The change is one of &lt;strong>shared formal vocabulary&lt;/strong>.&lt;/p>
&lt;p>An ontology in computing is &lt;strong>exactly this&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Linnaeus (1735)&lt;/th>
&lt;th>Ontology (2026)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kingdom → … → Species hierarchy&lt;/td>
&lt;td>Class hierarchy (&lt;code>Person ⊑ Agent ⊑ Thing&lt;/code>), the &lt;strong>TBox&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Type specimen in a museum&lt;/td>
&lt;td>Instance anchored with a unique IRI, the &lt;strong>ABox&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Binomial name&lt;/td>
&lt;td>A unique IRI / URI per concept&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Priority rules&lt;/td>
&lt;td>Ontology axioms plus governance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;em>&amp;ldquo;Felis silvestris&amp;rdquo;&lt;/em> means the same in Madrid and Stockholm&lt;/td>
&lt;td>&lt;code>&amp;lt;http://example.org/ont/Felis_silvestris&amp;gt;&lt;/code> means the same in any system&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>When an LLMOps team today says &amp;ldquo;our corpus is curated, the embeddings are &lt;code>bge-m3&lt;/code> and the evals measure recall@5&amp;rdquo;, but the question &amp;ldquo;what proportion of queries about &lt;strong>high-category ENS assets&lt;/strong> are well covered?&amp;rdquo; has no answer, because no formal class &amp;ldquo;high-category ENS asset&amp;rdquo; exists in the system, the problem is &lt;strong>pre-Linnaean&lt;/strong>: the field has not yet given itself the nomenclature that makes each stage comparable.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ontology as shared nomenclature across the six LLMOps stages">
&lt;style>
.obox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.ohead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.oont{fill:#d8a8ff;stroke:#444;stroke-width:1.4;rx:8}
.oet1{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.oet2{fill:#a8d5a8;stroke:#444;stroke-width:1.4;rx:8}
.oet3{fill:#ffe18a;stroke:#444;stroke-width:1.4;rx:8}
.oet4{fill:#ffb86b;stroke:#444;stroke-width:1.4;rx:8}
.oet5{fill:#bce0fd;stroke:#444;stroke-width:1.4;rx:8}
.oet6{fill:#ffc4c4;stroke:#444;stroke-width:1.4;rx:8}
.oblt{font:600 13px sans-serif;fill:#222}
.osub{font:400 10.5px sans-serif;fill:#444}
.oarr{stroke:#666;stroke-width:1.3;fill:none;marker-end:url(#mo1)}
&lt;/style>
&lt;defs>&lt;marker id="mo1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="290" y="20" width="200" height="60" class="oont"/>
&lt;text x="390" y="44" text-anchor="middle" class="oblt">Ontology (TBox)&lt;/text>
&lt;text x="390" y="62" text-anchor="middle" class="osub">Classes, properties, axioms, SHACL&lt;/text>
&lt;text x="390" y="76" text-anchor="middle" class="osub">FIBO · SNOMED · ENS · schema.org&lt;/text>
&lt;rect x="20" y="140" width="140" height="60" class="oet1"/>
&lt;text x="90" y="164" text-anchor="middle" class="oblt">1. Data&lt;/text>
&lt;text x="90" y="180" text-anchor="middle" class="osub">curation by class&lt;/text>
&lt;text x="90" y="194" text-anchor="middle" class="osub">PII = ontology of types&lt;/text>
&lt;rect x="180" y="140" width="140" height="60" class="oet2"/>
&lt;text x="250" y="164" text-anchor="middle" class="oblt">2. Train / Adapt&lt;/text>
&lt;text x="250" y="180" text-anchor="middle" class="osub">stratified data&lt;/text>
&lt;text x="250" y="194" text-anchor="middle" class="osub">synthetic per class&lt;/text>
&lt;rect x="340" y="140" width="140" height="60" class="oet3"/>
&lt;text x="410" y="164" text-anchor="middle" class="oblt">3. Eval&lt;/text>
&lt;text x="410" y="180" text-anchor="middle" class="osub">metrics per class&lt;/text>
&lt;text x="410" y="194" text-anchor="middle" class="osub">ontological coverage&lt;/text>
&lt;rect x="500" y="140" width="140" height="60" class="oet4"/>
&lt;text x="570" y="164" text-anchor="middle" class="oblt">4. Deploy&lt;/text>
&lt;text x="570" y="180" text-anchor="middle" class="osub">semantic routing&lt;/text>
&lt;text x="570" y="194" text-anchor="middle" class="osub">typed tool calling&lt;/text>
&lt;rect x="100" y="240" width="180" height="60" class="oet5"/>
&lt;text x="190" y="264" text-anchor="middle" class="oblt">5. Observe&lt;/text>
&lt;text x="190" y="280" text-anchor="middle" class="osub">incident taxonomy&lt;/text>
&lt;text x="190" y="294" text-anchor="middle" class="osub">typed lineage in the KG&lt;/text>
&lt;rect x="320" y="240" width="180" height="60" class="oet6"/>
&lt;text x="410" y="264" text-anchor="middle" class="oblt">6. Govern&lt;/text>
&lt;text x="410" y="280" text-anchor="middle" class="osub">ENS · ISO 42001 · EU AI Act&lt;/text>
&lt;text x="410" y="294" text-anchor="middle" class="osub">are control ontologies&lt;/text>
&lt;rect x="540" y="240" width="200" height="60" class="obox"/>
&lt;text x="640" y="262" text-anchor="middle" class="oblt">W3C standards&lt;/text>
&lt;text x="640" y="278" text-anchor="middle" class="osub">RDF · OWL 2 (EL/QL/RL/DL)&lt;/text>
&lt;text x="640" y="292" text-anchor="middle" class="osub">SHACL · SKOS · JSON-LD · SPARQL&lt;/text>
&lt;rect x="200" y="330" width="380" height="40" class="ohead"/>
&lt;text x="390" y="354" text-anchor="middle" class="oblt">No shared nomenclature = pre-Linnaean pipeline&lt;/text>
&lt;path class="oarr" d="M390 80 L90 140"/>
&lt;path class="oarr" d="M390 80 L250 140"/>
&lt;path class="oarr" d="M390 80 L410 140"/>
&lt;path class="oarr" d="M390 80 L570 140"/>
&lt;path class="oarr" d="M390 80 Q260 170 190 240"/>
&lt;path class="oarr" d="M390 80 Q390 170 410 240"/>
&lt;/svg>
&lt;p style="text-align:center;font-size:0.9rem;color:#555;margin-top:0.5rem;">The ontology runs through the six stages as shared vocabulary. Without it, each stage has its own definition of "customer", "sensitive document" or "incident".&lt;/p>
&lt;/div>
&lt;h2 id="what-an-ontology-is-in-operational-terms">What an ontology is in operational terms&lt;/h2>
&lt;p>The word &amp;ldquo;ontology&amp;rdquo; carries an unavoidable philosophical family resemblance (Aristotle, Kant&amp;rsquo;s categories, Quine) that confuses at first. In LLM infrastructure it does not matter: an ontology is &lt;strong>a directed graph with types&lt;/strong>, formally described, over which you can reason, validate and query. What matters are six practical concepts.&lt;/p>
&lt;h3 id="tbox-and-abox">TBox and ABox&lt;/h3>
&lt;p>The distinction used every day. The &lt;strong>TBox&lt;/strong> (from &lt;em>terminology&lt;/em>) is the schema: classes, subclass hierarchy, properties, axioms. The &lt;strong>ABox&lt;/strong> (from &lt;em>assertions&lt;/em>) is the instances.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-turtle" data-lang="turtle">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># TBox — schema&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">rdfs:&lt;/span>&lt;span class="nt">subClassOf&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Agent&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Employee&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">rdfs:&lt;/span>&lt;span class="nt">subClassOf&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">worksFor&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">rdfs:&lt;/span>&lt;span class="nt">domain&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Employee&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">rdfs:&lt;/span>&lt;span class="nt">range&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Organization&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># ABox — instances&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">alice&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Employee&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">alice&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">worksFor&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">acme&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A reasoner checks that the ABox is consistent with the TBox: if you declare &lt;code>:alice :worksFor :acme&lt;/code> but &lt;code>:alice&lt;/code> is not an &lt;code>:Employee&lt;/code>, the reasoner flags the inconsistency. That is the lever: &lt;strong>automatic validation of knowledge&lt;/strong>, which no system based on dense embeddings alone can provide.&lt;/p>
&lt;h3 id="rdf-and-the-unit-of-information">RDF and the unit of information&lt;/h3>
&lt;p>The atomic unit of the Semantic Web is the &lt;strong>RDF triple&lt;/strong> &lt;code>(subject, predicate, object)&lt;/code>. Every piece of data is expressed as a collection of triples. This gives the paradigm&amp;rsquo;s most useful operational property: &lt;strong>two graphs merge trivially by union&lt;/strong>. If your system indexes the medical corpus with SNOMED CT and the legal corpus with FIBO, both in RDF, merging them for a query that crosses both domains is literally &lt;code>g1 ∪ g2&lt;/code>. In a property graph (Neo4j) this takes more surgery.&lt;/p>
&lt;h3 id="the-four-owl-2-profiles">The four OWL 2 profiles&lt;/h3>
&lt;p>People new to the field assume OWL is one thing. It is four profiles with different trade-offs, all W3C Recommendations:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>Expressiveness&lt;/th>
&lt;th>Reasoning cost&lt;/th>
&lt;th>Use cases&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>OWL 2 EL&lt;/strong>&lt;/td>
&lt;td>restricted (subclass, intersection, properties)&lt;/td>
&lt;td>polynomial in ontology size&lt;/td>
&lt;td>enormous terminologies — &lt;strong>SNOMED CT&lt;/strong> (350k+ concepts)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OWL 2 QL&lt;/strong>&lt;/td>
&lt;td>subset that maps to SQL/UCQ&lt;/td>
&lt;td>LOGSPACE in data&lt;/td>
&lt;td>&lt;strong>OBDA&lt;/strong> (ontology-based data access) over relational DBs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OWL 2 RL&lt;/strong>&lt;/td>
&lt;td>subset implementable as rules (Datalog)&lt;/td>
&lt;td>scalable, without full DL&lt;/td>
&lt;td>production reasoning with rule engines&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OWL 2 DL&lt;/strong>&lt;/td>
&lt;td>full SROIQ (the &amp;ldquo;full ontology&amp;rdquo;)&lt;/td>
&lt;td>decidable but NEXPTIME in the worst case&lt;/td>
&lt;td>academic ontologies, deep validation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Operational rule&lt;/strong>: if your team is not going to read a description logics paper every month, &lt;strong>do not use OWL 2 DL&lt;/strong>. Almost all the value is in EL/QL/RL. For large medical terminologies, EL. To reason over existing relational data, QL. For business rules, RL.&lt;/p>
&lt;h3 id="shacl--the-validation-you-actually-operate">SHACL — the validation you actually operate&lt;/h3>
&lt;p>OWL does &lt;strong>reasoning&lt;/strong> (&amp;ldquo;given these axioms, what can be deduced?&amp;rdquo;). SHACL does &lt;strong>validation&lt;/strong> (&amp;ldquo;given this concrete graph, does it satisfy these shapes?&amp;rdquo;). In production, SHACL wins because its semantics are closer to the type checking a developer already understands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-turtle" data-lang="turtle">&lt;span class="line">&lt;span class="cl">&lt;span class="nn">:&lt;/span>&lt;span class="nt">PersonShape&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">NodeShape&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">targetClass&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Person&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">property&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">nombre&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">minCount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">datatype&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">xsd:&lt;/span>&lt;span class="nt">string&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">nif&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">sh:&lt;/span>&lt;span class="nt">pattern&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;^[0-9]{8}[A-Z]$&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Validating an incoming graph against this shape catches &lt;code>:alice :nombre 42&lt;/code> (wrong type), &lt;code>:alice :nif &amp;quot;12345678X9&amp;quot;&lt;/code> (wrong format) or &lt;code>:alice a :Person&lt;/code> with no name (min count violated). It is &lt;strong>JSON Schema for graphs&lt;/strong>, conceptually. The SHACL 1.2 spec is a W3C draft from 2025; SHACL 1.0 has been in production since 2017.&lt;/p>
&lt;h3 id="skos--the-lightweight-thesaurus">SKOS — the lightweight thesaurus&lt;/h3>
&lt;p>Not all knowledge deserves OWL. For &lt;strong>controlled vocabularies&lt;/strong> (thesauri, taxonomies, glossaries) there is SKOS:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-turtle" data-lang="turtle">&lt;span class="line">&lt;span class="cl">&lt;span class="nn">:&lt;/span>&lt;span class="nt">Mamifero&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">skos:&lt;/span>&lt;span class="nt">Concept&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">skos:&lt;/span>&lt;span class="nt">prefLabel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;Mamífero&amp;#34;&lt;/span>&lt;span class="o">@&lt;/span>&lt;span class="ge">es&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;Mammal&amp;#34;&lt;/span>&lt;span class="o">@&lt;/span>&lt;span class="ge">en&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">skos:&lt;/span>&lt;span class="nt">broader&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Animal&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nn">skos:&lt;/span>&lt;span class="nt">narrower&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Felino&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nn">:&lt;/span>&lt;span class="nt">Canido&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SKOS does not express formal axioms: &lt;code>skos:broader&lt;/code> is not &lt;code>rdfs:subClassOf&lt;/code>. It serves to classify content with no pretension of reasoning, which covers 80% of corporate cases. &lt;strong>Start with SKOS&lt;/strong>: most enterprise &amp;ldquo;ontologies&amp;rdquo; are in fact thesauri that were promoted to OWL out of fashion and drag unnecessary complexity along.&lt;/p>
&lt;h3 id="json-ld-and-sparql--the-practical-surfaces">JSON-LD and SPARQL — the practical surfaces&lt;/h3>
&lt;p>&lt;strong>JSON-LD 1.1&lt;/strong> (W3C Rec 2020) is the serialisation that actually gets used in real systems: ordinary JSON with an &lt;code>@context&lt;/code> field mapping the keys to IRIs. The schema.org microformat on web pages is JSON-LD. For an LLMOps team, JSON-LD is the natural exchange format with tools and APIs.&lt;/p>
&lt;p>&lt;strong>SPARQL 1.1&lt;/strong> (W3C Rec 2013; 1.2 in draft 2025) is SQL for graphs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sparql" data-lang="sparql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span> &lt;span class="nv">?empleado&lt;/span> &lt;span class="nv">?empresa&lt;/span> &lt;span class="k">WHERE&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nv">?empleado&lt;/span> &lt;span class="k">a&lt;/span> &lt;span class="p">:&lt;/span>&lt;span class="nt">Employee&lt;/span> &lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">:&lt;/span>&lt;span class="nt">worksFor&lt;/span> &lt;span class="nv">?empresa&lt;/span> &lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">:&lt;/span>&lt;span class="nt">pais&lt;/span> &lt;span class="s">&amp;#34;España&amp;#34;&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nv">?empresa&lt;/span> &lt;span class="p">:&lt;/span>&lt;span class="nt">sector&lt;/span> &lt;span class="s">&amp;#34;fintech&amp;#34;&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Every modern triple store speaks it. The federation features let a single query touch several endpoints: SNOMED CT plus your own corporate ontology.&lt;/p>
&lt;h2 id="why-it-matters-for-an-llm-in-production">Why it matters for an LLM in production&lt;/h2>
&lt;p>The romantic promise of 2023-2024 was: &amp;ldquo;now that we have LLMs we do not need ontologies; the model understands natural language and extracts knowledge&amp;rdquo;. The operational reality of mid-2026 is more nuanced and rests on four observations anyone with a RAG in production has already made:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The LLM has semantic memory but no declared schema&lt;/strong>. If you ask &amp;ldquo;which entities of type &lt;code>Person&lt;/code> appear in this document?&amp;rdquo;, it answers something reasonable. If you ask &amp;ldquo;which people appear and which of them are employees of the customer?&amp;rdquo;, the answer depends on how the model &lt;em>interprets&lt;/em> &amp;ldquo;employee of the customer&amp;rdquo; in that context. Without an external schema saying &amp;ldquo;Employee is a subclass of Person and relates to Organization via worksFor&amp;rdquo;, coherence between two calls to the same LLM is not guaranteed.&lt;/li>
&lt;li>&lt;strong>Quality varies by domain without the system knowing why&lt;/strong>. Your RAG has a global accuracy of 78% but fails systematically on queries about derivative financial instruments. Since you have no formal classification of queries by category, the problem is invisible until a customer complains.&lt;/li>
&lt;li>&lt;strong>Compliance demands formal nomenclature&lt;/strong>. ENS classifies assets along five dimensions (Confidentiality, Integrity, Availability, Authenticity, Traceability) with three levels each. The EU AI Act lists eight high-risk areas in Annex III. Without a formal mapping between your assets and those categories, &lt;strong>you cannot audit what you cannot name&lt;/strong>. The auditor asks &amp;ldquo;which corpus chunks touch specially protected personal data?&amp;rdquo; and your system does not have that column.&lt;/li>
&lt;li>&lt;strong>Interoperability between components demands types&lt;/strong>. Your retrieval returns &amp;ldquo;relevant chunks&amp;rdquo;. Your reranker reorders them. Your guardrail filters out the sensitive ones. If each component has its own definition of what a &amp;ldquo;sensitive chunk&amp;rdquo; is, the chain breaks at every interface. A shared ontology is &lt;strong>the pipeline&amp;rsquo;s type contract&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>The operational consequence: &lt;strong>the ontology does not replace RAG&lt;/strong>. It &lt;em>types&lt;/em> it. It makes it auditable, comparable and debuggable. The right question is not &amp;ldquo;do I need a knowledge graph?&amp;rdquo; but &amp;ldquo;at which pipeline stages do I gain if I introduce a shared formal nomenclature?&amp;rdquo;.&lt;/p>
&lt;h2 id="the-six-llmops-stages--ontology">The six LLMOps stages × ontology&lt;/h2>
&lt;p>Let us walk the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six pipeline stages&lt;/a> asking what changes in each one when there is an ontology. This is the spine of the post: the lever is not &amp;ldquo;install Neo4j&amp;rdquo;, it is introducing types where there was plain text before.&lt;/p>
&lt;h3 id="stage-1--data">Stage 1 — Data&lt;/h3>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">Corpus curation&lt;/a> becomes &lt;strong>ontology-driven curation&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Each chunk is not just &amp;ldquo;text + embedding&amp;rdquo;, it also carries &lt;code>chunk:tipoDocumento&lt;/code>, &lt;code>chunk:nivelClasificacion&lt;/code>, &lt;code>chunk:categoriaENS&lt;/code>, &lt;code>chunk:contienePII&lt;/code>.&lt;/li>
&lt;li>These types come from an explicit corporate ontology, not from ad-hoc strings written by whichever data engineer was on the morning shift.&lt;/li>
&lt;li>Rule 4 of &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">corpus curation&lt;/a>, anti-contamination, benefits: chunks in the &lt;strong>golden eval set&lt;/strong> carry &lt;code>dataset:goldenEval=true&lt;/code> declared as a triple; any reindexing that filters on &lt;code>goldenEval=true&lt;/code> becomes trivial.&lt;/li>
&lt;li>The PII detector stops being a regular expression and becomes a classifier against the &lt;strong>personal data categories&lt;/strong> thesaurus: identifier, contact, financial, health, biometric. The &lt;code>chunk:pii&lt;/code> column is no longer boolean but a list of SKOS categories.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Ingestion with ontological typing&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">chunk&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;@context&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://ontology.fibercli.es/v1/context.jsonld&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;@id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;chunk:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;@type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Chunk&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;tipoDocumento&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ContratoComercial&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;nivelClasificacion&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ConfidencialMedio&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;categoriaENS&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Disponibilidad-M&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;Confidencialidad-A&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contienePII&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;IdentificadorFiscal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;Contacto&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;embedding&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;text&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Under the JSON-LD context, all those keys resolve to IRIs and are queryable via SPARQL.&lt;/p>
&lt;h3 id="stage-2--train--adapt">Stage 2 — Train / Adapt&lt;/h3>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> gain two levers:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Datasets stratified by class&lt;/strong>. When production feedback turns into a training dataset, each example comes labelled with the ontological class of the incident that produced it. This lets you sample &lt;code>n&lt;/code> examples &lt;em>per class&lt;/em> instead of &lt;code>n&lt;/code> examples globally, which corrects the model&amp;rsquo;s coverage gaps.&lt;/li>
&lt;li>&lt;strong>Ontology-guided synthetic generation&lt;/strong>. For classes with few examples in the real corpus, synthetic data is generated against the schema: &amp;ldquo;generate 50 questions about &lt;code>FIBO:DerivativeInstrument&lt;/code> that a trader might ask&amp;rdquo;. The output goes through structured output validated against the schema&amp;rsquo;s SHACL shape before entering the dataset.&lt;/li>
&lt;/ul>
&lt;h3 id="stage-3--eval">Stage 3 — Eval&lt;/h3>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> layer changes more than any other. Without an ontology, the eval reports a global accuracy that hides everything:&lt;/p>
&lt;pre tabindex="0">&lt;code>accuracy = 0.78
&lt;/code>&lt;/pre>&lt;p>With an ontology, it reports &lt;strong>a coverage matrix by class&lt;/strong>:&lt;/p>
&lt;pre tabindex="0">&lt;code> accuracy n_queries covered_in_corpus
ContratoComercial 0.82 142 yes
EmpleadoENS-Alto 0.31 18 partial
DerivadoFinanciero 0.74 67 yes
SOAP_3.0_Endpoint 0.05 9 no
&lt;/code>&lt;/pre>&lt;p>The &lt;code>EmpleadoENS-Alto&lt;/code> row with accuracy 0.31 exposes a problem that is invisible without stratification. The &lt;code>SOAP_3.0_Endpoint&lt;/code> row with accuracy 0.05 and &lt;code>covered_in_corpus=no&lt;/code> shows that the class has no corpus at all: before touching the model you have to touch ingestion. A single metric &lt;strong>hides&lt;/strong>; a per-class metric &lt;strong>drives action&lt;/strong>.&lt;/p>
&lt;p>This is the rule that &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> should always implement wherever an ontology exists: &lt;strong>the golden eval set is labelled by class and every metric is reported stratified&lt;/strong>.&lt;/p>
&lt;h3 id="stage-4--deploy">Stage 4 — Deploy&lt;/h3>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">LLM inference router&lt;/a> the ontology enables:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Semantic routing by class&lt;/strong>. Queries that, after a first classification, fall under &lt;code>FIBO:Securities&lt;/code> are routed to the adapter fine-tuned on finance; queries under &lt;code>SNOMED:ClinicalFinding&lt;/code> go to the medical adapter. Without an ontology, this routing rests on ad-hoc classifiers or fragile lexical heuristics.&lt;/li>
&lt;li>&lt;strong>Typed tool calling&lt;/strong>. The tools the agent can invoke declare their arguments against ontology classes. The &lt;code>cliente_id&lt;/code> argument is not a &lt;code>string&lt;/code>; it is a &lt;code>:ClienteCorporativo&lt;/code>. Before running the tool, the arguments are validated with SHACL. This sharply cuts errors from badly populated arguments.&lt;/li>
&lt;li>&lt;strong>Feature flags with a class&lt;/strong>. The &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary&lt;/a> becomes &amp;ldquo;the new model receives 10% of queries in class X&amp;rdquo; instead of an undifferentiated 10%: it isolates the blast radius.&lt;/li>
&lt;/ul>
&lt;h3 id="stage-5--observe">Stage 5 — Observe&lt;/h3>
&lt;p>This is where the absence of an ontology hurts fastest in operations. The &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a> require:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A formal incident taxonomy&lt;/strong>. &lt;code>IncidenteSeguridad ⊑ Incidente&lt;/code>, &lt;code>IncidenteIA ⊑ Incidente&lt;/code>, &lt;code>FugaDatos ⊑ IncidenteSeguridad&lt;/code>. Without this taxonomy, the five events from last month labelled &amp;ldquo;model issue&amp;rdquo;, &amp;ldquo;data drift&amp;rdquo;, &amp;ldquo;pii leak&amp;rdquo;, &amp;ldquo;prompt injection&amp;rdquo; and &amp;ldquo;hallucination&amp;rdquo; are neither groupable nor comparable. &lt;strong>Keep + Kafka apply deduplication against that taxonomy&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Typed lineage in the KG&lt;/strong>. &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">GPU observability plus tracing&lt;/a> emits spans with attributes. If those attributes are typed against the ontology (&lt;code>span.input.classification = :ConfidencialMedio&lt;/code>), finding every request that touched class &lt;code>ConfidencialAlto&lt;/code> in the last hour is a trivial SPARQL query; without an ontology, it is a grep over unstructured logs.&lt;/li>
&lt;/ul>
&lt;h3 id="stage-6--govern">Stage 6 — Govern&lt;/h3>
&lt;p>Where the ontology becomes unavoidable. Every regulatory framework &lt;strong>is an ontology&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>ENS RD 311/2022 Annex I&lt;/strong>: defines five dimensions (C, I, A, A, T) × three levels (Low, Medium, High). It is an asset classification scheme. &lt;strong>Annex II&lt;/strong> lists 73 control measures with an organisational / operational / protection hierarchy. The &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS technical controls&lt;/a> map each control to stack components, and that mapping &lt;em>is&lt;/em> a relational ontology.&lt;/li>
&lt;li>&lt;strong>ISO 42001 Annex A&lt;/strong>: lists grouped controls (A.5 policies, A.6 internal organisation, A.7 resources for AI, A.8 assessment, A.9 operation). The &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">AIMS over an on-premise LLM&lt;/a> formalises them.&lt;/li>
&lt;li>&lt;strong>EU AI Act Annex III&lt;/strong>: eight high-risk areas. The &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">technical file mappings&lt;/a> are a translation from the legal ontology to the system&amp;rsquo;s technical ontology.&lt;/li>
&lt;/ul>
&lt;p>Without an ontology mapping your inventory of assets, datasets, models and endpoints &lt;strong>to the classes of those three frameworks&lt;/strong>, compliance is manual, reactive and breaks with every change to the stack. With the ontology, a model change automatically triggers which controls are affected.&lt;/p>
&lt;h2 id="the-graphrag-field-in-2026">The GraphRAG field in 2026&lt;/h2>
&lt;p>GraphRAG is the generic name for a family of techniques that &lt;strong>build a knowledge graph from a corpus and use it as an additional retrieval layer&lt;/strong> complementary to the dense / sparse / multi-vector we saw in &lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">embeddings&lt;/a>. The motivation is that some queries (&amp;ldquo;what are the dominant themes in this corpus&amp;rdquo;, &amp;ldquo;which entities appear connected to customer X in the last six months&amp;rdquo;) are not answered well by cosine similarity between vectors.&lt;/p>
&lt;h3 id="microsoft-graphrag">Microsoft GraphRAG&lt;/h3>
&lt;p>&lt;code>microsoft/graphrag&lt;/code> (July 2024, v1.0 Dec 2024, &lt;strong>v2.x Oct 2025&lt;/strong>; any reference to v3 needs checking against GitHub releases before citing). The canonical pipeline:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Extraction&lt;/strong>. An LLM reads the corpus chunk by chunk and extracts entities and relations, so the TBox emerges from the data instead of being declared.&lt;/li>
&lt;li>&lt;strong>Graph construction&lt;/strong>. The extracted entities are disambiguated, merged and connected through the relations.&lt;/li>
&lt;li>&lt;strong>Community detection&lt;/strong> with the &lt;strong>Leiden&lt;/strong> algorithm. The graph is partitioned into hierarchical communities.&lt;/li>
&lt;li>&lt;strong>Per-community summaries&lt;/strong>. For each community, the LLM generates a summary.&lt;/li>
&lt;li>&lt;strong>Local vs global search&lt;/strong>. &lt;em>Local&lt;/em>: neighbourhood traversal for queries about specific entities. &lt;em>Global&lt;/em>: map-reduce over community summaries for thematic queries.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>The price&lt;/strong>: building the KG costs on the order of 5-20× more tokens than an embedding pass over the same corpus. For a corpus of 1 million chunks with &lt;code>bge-m3&lt;/code> embeddings (a day of compute on an RTX 4090), a pure GraphRAG typically requires 1-3 weeks of LLM-extractor compute (Qwen2.5-72B or similar). The &lt;strong>LazyGraphRAG&lt;/strong> variant (mid-2025) defers summary generation to query time and cuts the construction cost by an order of magnitude.&lt;/p>
&lt;h3 id="lightrag">LightRAG&lt;/h3>
&lt;p>&lt;code>HKUDS/LightRAG&lt;/code> (HKU, &lt;strong>arXiv:2410.05779&lt;/strong>, October 2024, EMNLP 2025). Practical improvements over canonical GraphRAG:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Dual-level retrieval&lt;/strong>. Each query generates both &lt;em>low-level keywords&lt;/em> (specific entities) and &lt;em>high-level keywords&lt;/em> (themes). The system searches by both and fuses the results. It captures factual and thematic questions in the same pipeline.&lt;/li>
&lt;li>&lt;strong>Incremental updates&lt;/strong>. Insertion of new chunks without rebuilding the whole graph. Canonical GraphRAG requires periodic rebuilds.&lt;/li>
&lt;li>&lt;strong>Reported cost&lt;/strong>: comparatively cheaper than GraphRAG for serving similar queries.&lt;/li>
&lt;/ul>
&lt;p>It is the &lt;strong>operationally most reasonable&lt;/strong> GraphRAG when the corpus mutates.&lt;/p>
&lt;h3 id="hipporag-2">HippoRAG 2&lt;/h3>
&lt;p>OSU-NLP-Group, &lt;strong>arXiv:2502.14802&lt;/strong> (Feb 2025; the original HippoRAG at NeurIPS'24). Inspired by the hippocampal indexing model of human memory:&lt;/p>
&lt;ul>
&lt;li>It builds an open KG and also keeps the original chunks.&lt;/li>
&lt;li>For each query it extracts entities and runs &lt;strong>Personalized PageRank&lt;/strong> over the graph seeded by those entities: the PageRank &amp;ldquo;marks&amp;rdquo; the relevant nodes and, transitively, the associated chunks.&lt;/li>
&lt;li>Reported &lt;strong>+7% on associative memory tasks&lt;/strong> over SOTA embedders, with &lt;strong>significantly lower indexing cost&lt;/strong> than GraphRAG, RAPTOR and LightRAG.&lt;/li>
&lt;/ul>
&lt;p>It is the most efficient GraphRAG for corpora where &amp;ldquo;which chunks are relevant to which entities&amp;rdquo; matters more than &amp;ldquo;what is the semantic structure of the corpus&amp;rdquo;.&lt;/p>
&lt;h3 id="kag--openspg">KAG / OpenSPG&lt;/h3>
&lt;p>Ant Group + OpenKG, &lt;strong>arXiv:2409.13731&lt;/strong> (Sep 2024). The key difference from the others: &lt;strong>KAG is ontology-grounded&lt;/strong>. It does not let the LLM invent the TBox; the TBox is declared by the domain (FIBO, SNOMED, corporate ontology) and the LLM only populates the ABox in accordance with that schema. Four pillars:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>LLM-friendly representation&lt;/strong> — the schema is exposed in a format the LLM can consume as context.&lt;/li>
&lt;li>&lt;strong>Mutual index between the KG and chunks&lt;/strong> — each KG node links to the chunks it appears in.&lt;/li>
&lt;li>&lt;strong>Hybrid logical-formal reasoning&lt;/strong> — it combines the LLM with a declarative rule engine.&lt;/li>
&lt;li>&lt;strong>Semantic alignment&lt;/strong> — entity disambiguation against the ontological catalogue.&lt;/li>
&lt;/ol>
&lt;p>Reported &lt;strong>+19.6% F1 on 2WikiMultiHopQA, +33.5% on HotpotQA&lt;/strong> over the RAG baseline. Deployed in Ant&amp;rsquo;s e-government and e-health Q&amp;amp;A in production.&lt;/p>
&lt;p>&lt;strong>KAG is the GraphRAG that works when the domain has a stable ontology&lt;/strong> (finance, health, government). Canonical GraphRAG wins when the corpus is exploratory and no prior TBox exists.&lt;/p>
&lt;h3 id="others-in-the-landscape">Others in the landscape&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>nano-GraphRAG&lt;/strong>: a lightweight Python port of GraphRAG; ideal for prototypes.&lt;/li>
&lt;li>&lt;strong>Think-on-Graph (ToG) / GraphReader&lt;/strong>: agents that &lt;em>plan hop traversals&lt;/em> over the KG instead of single-shot retrieval. Better at multi-hop QA.&lt;/li>
&lt;li>&lt;strong>Neo4j LLM Graph Builder&lt;/strong> plus LangChain integration: the path of least resistance for companies already running Neo4j.&lt;/li>
&lt;/ul>
&lt;h2 id="vertical-ontologies-that-really-get-used-in-production">Vertical ontologies that really get used in production&lt;/h2>
&lt;p>Three ontologies cover 90% of vertical cases in mid-2026:&lt;/p>
&lt;h3 id="fibo--financial-industry-business-ontology">FIBO — Financial Industry Business Ontology&lt;/h3>
&lt;p>EDM Council + OMG, &lt;strong>MIT license, OWL DL&lt;/strong>. The Q1/2026 production release contains &lt;strong>2,446 classes&lt;/strong> spread across Foundations, Business Entities, Securities, Derivatives, Loans and so on. Used in production for:&lt;/p>
&lt;ul>
&lt;li>KYC entity resolution: disambiguating legal organisations (&lt;code>fibo-be-le-fbo:FormalBusinessOrganization&lt;/code>).&lt;/li>
&lt;li>Classification of financial instruments (&lt;code>fibo-sec-sec-bsk:Basket&lt;/code>, &lt;code>fibo-der-drc-cds:CreditDefaultSwap&lt;/code>).&lt;/li>
&lt;li>Regulatory reporting: mapping fields against the canonical schema.&lt;/li>
&lt;/ul>
&lt;p>For a corporate RAG in finance, &lt;strong>FIBO is the type schema any extraction has to satisfy&lt;/strong>. Without FIBO, two chunks that talk about a &amp;ldquo;swap&amp;rdquo; might mean an interest rate swap or a currency one.&lt;/p>
&lt;h3 id="snomed-ct">SNOMED CT&lt;/h3>
&lt;p>IHTSDO/SNOMED International. Monthly releases (the May 2026 International Edition published on 15 May). Roughly &lt;strong>350,000+ active concepts&lt;/strong> in OWL 2 EL. &lt;strong>Free licence in member countries&lt;/strong> (Spain is a member through the CSI / Ministry of Health), commercial outside. In production:&lt;/p>
&lt;ul>
&lt;li>Assisted clinical coding: the LLM proposes SNOMED codes and the system validates them against the ontology.&lt;/li>
&lt;li>Cross-lingual search in records: &lt;code>Diabetes mellitus type 2&lt;/code> and &lt;code>Diabetes mellitus tipo 2&lt;/code> resolve to the same concept (&lt;code>73211009&lt;/code>).&lt;/li>
&lt;li>HIPAA / GDPR health compliance: traceability of what type of clinical data each component handles.&lt;/li>
&lt;/ul>
&lt;h3 id="schemaorg">schema.org&lt;/h3>
&lt;p>CC-BY-SA, ~800 types, native JSON-LD. The ontology of the web. Used in any RAG over public crawls to type &lt;code>Product&lt;/code>, &lt;code>Article&lt;/code>, &lt;code>Person&lt;/code>, &lt;code>Organization&lt;/code> from the microformats the corpus already carries embedded.&lt;/p>
&lt;h3 id="the-others-worth-keeping-on-the-radar">The others worth keeping on the radar&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Ontology&lt;/th>
&lt;th>Domain&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>When to use it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>IEC 81346&lt;/strong>&lt;/td>
&lt;td>industrial systems (designation =K1-Q1)&lt;/td>
&lt;td>IEC proprietary&lt;/td>
&lt;td>CMDB-as-graph, industrial plant&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GS1&lt;/strong>&lt;/td>
&lt;td>supply chain (GTIN, GLN, SSCC)&lt;/td>
&lt;td>membership; free web vocab&lt;/td>
&lt;td>EUDR traceability, retail&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NIEM&lt;/strong>&lt;/td>
&lt;td>US gov interoperability&lt;/td>
&lt;td>CC0&lt;/td>
&lt;td>gov-to-gov integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Wikidata&lt;/strong>&lt;/td>
&lt;td>universal KB (~115M items)&lt;/td>
&lt;td>CC0&lt;/td>
&lt;td>universal entity linking&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ENS RD 311/2022 Annex I-II&lt;/strong>&lt;/td>
&lt;td>ESP public sector security&lt;/td>
&lt;td>BOE public&lt;/td>
&lt;td>asset classification in any ENS deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>EU AI Act Annex III&lt;/strong>&lt;/td>
&lt;td>8 high-risk areas&lt;/td>
&lt;td>EU regulation&lt;/td>
&lt;td>EU compliance tagging&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For a Spanish public sector customer with AI systems, &lt;strong>the minimum ontology worth formalising is the union of ENS Annex I + EU AI Act Annex III + ISO 42001 Annex A&lt;/strong>. That mapping is generated once, kept as a versioned artefact in the AI governance repo and linked from the lineage of every deployed model.&lt;/p>
&lt;h2 id="open-source-on-prem-stack-2026">Open source on-prem stack 2026&lt;/h2>
&lt;p>The implementation landscape splits into RDF triple stores, property graphs and auxiliary tooling.&lt;/p>
&lt;h3 id="rdf--sparql-triple-stores">RDF / SPARQL triple stores&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stack&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Operational notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Apache Jena Fuseki&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>The open reference. TDB2 storage. Quarterly releases. The reasonable default.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Eclipse RDF4J&lt;/strong>&lt;/td>
&lt;td>EDL/BSD-like&lt;/td>
&lt;td>Java framework plus server (Sesame-derived). Mature.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Virtuoso Open Source&lt;/strong>&lt;/td>
&lt;td>GPLv2&lt;/td>
&lt;td>High performance. The Community edition does not include clustering.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Ontotext GraphDB Free&lt;/strong>&lt;/td>
&lt;td>proprietary EULA, free up to 2 concurrent queries&lt;/td>
&lt;td>Strong OWL 2 RL reasoning. &lt;strong>Operational cap on concurrency&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Stardog&lt;/strong>&lt;/td>
&lt;td>proprietary&lt;/td>
&lt;td>No genuine free production tier in 2026, only developer.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Blazegraph&lt;/strong>&lt;/td>
&lt;td>discontinued&lt;/td>
&lt;td>Wikidata is migrating to Qlever / others. &lt;strong>Do not start a new project on it&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="property-graphs-cypher--gremlin">Property graphs (Cypher / Gremlin)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stack&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Operational notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Neo4j Community Edition&lt;/strong>&lt;/td>
&lt;td>&lt;strong>GPLv3&lt;/strong> (with a historical Commons Clause on some artefacts); Enterprise closed&lt;/td>
&lt;td>Native vector index since 5.11. &lt;strong>Cypher 25&lt;/strong> adds the &lt;code>SEARCH&lt;/code> clause. &lt;strong>Cypher AI procedures&lt;/strong> (Dec 2025) integrate LLM calls and embedding generation into the query. &lt;strong>AGPL implication&lt;/strong>: if you redistribute a SaaS exposing Neo4j Community functionality it may require source disclosure — check with legal.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Memgraph&lt;/strong>&lt;/td>
&lt;td>BSL → Apache after 4 years&lt;/td>
&lt;td>In-memory, Cypher. Faster than Neo4j for query-intensive workloads.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NebulaGraph&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Distributed. For large sizes.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ArangoDB&lt;/strong>&lt;/td>
&lt;td>Apache 2.0 (Community); features moved to Enterprise post-3.12&lt;/td>
&lt;td>Multi-model (graph + document).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>KuzuDB&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>&lt;strong>Kùzu Inc. archived the upstream repo in Oct 2025&lt;/strong>. Community forks: &lt;code>bighorn&lt;/code> (Kineviz), &lt;code>ryugraph&lt;/code>. &lt;strong>Treat upstream as unmaintained.&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="hybrid-vector--graph">Hybrid vector + graph&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Neo4j 5.x with native HNSW&lt;/strong>: the vector as a node property, search from inside Cypher. The most integrated option.&lt;/li>
&lt;li>&lt;strong>Memgraph + pgvector&lt;/strong>: two stacks, two operating points.&lt;/li>
&lt;li>&lt;strong>Qdrant with a graph payload&lt;/strong>: not a real graph, but it allows basic k-hop style filters over the payload.&lt;/li>
&lt;/ul>
&lt;h3 id="editors-and-tooling">Editors and tooling&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Protégé&lt;/strong> (Stanford, BSD): the de facto ontology editor. A suite with the HermiT, Pellet and ELK reasoners.&lt;/li>
&lt;li>&lt;strong>TopBraid Composer&lt;/strong>: commercial; useful if it is already in the organisation.&lt;/li>
&lt;li>&lt;strong>Atomgraph&lt;/strong>: LGPL web editor.&lt;/li>
&lt;/ul>
&lt;h3 id="building-the-kg-with-an-llm">Building the KG with an LLM&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>GLiNER / GLiREL&lt;/strong> (Apache 2.0): zero-shot NER and relation extraction. &lt;strong>Far cheaper than an LLM-extractor&lt;/strong> (10-100× fewer tokens).&lt;/li>
&lt;li>&lt;strong>REBEL&lt;/strong> (MIT): joint entity + relation extraction based on BART. SOTA for years, today beaten by LLM-extractors but still reasonable as a baseline.&lt;/li>
&lt;li>&lt;strong>LLM-extractor with structured output&lt;/strong>: &lt;code>vLLM + XGrammar&lt;/code> or &lt;code>Outlines&lt;/code> enforcing a JSON Schema derived from SHACL. XGrammar has been the default backend in vLLM / SGLang / TensorRT-LLM since March 2026, with &amp;lt;40 µs/token of overhead.&lt;/li>
&lt;/ul>
&lt;h3 id="sparql-clients">SPARQL clients&lt;/h3>
&lt;p>&lt;code>rdflib&lt;/code> (Python, BSD), the Apache Jena CLI, &lt;strong>Comunica&lt;/strong> (MIT, JS, native SPARQL federation).&lt;/p>
&lt;h2 id="five-llm--ontology-integration-patterns">Five LLM × ontology integration patterns&lt;/h2>
&lt;p>Almost everything useful fits into five repeatable patterns.&lt;/p>
&lt;h3 id="1-schema-guided-extraction">1. Schema-guided extraction&lt;/h3>
&lt;p>The LLM emits &lt;strong>JSON conforming to a schema derived from the ontology&lt;/strong>, validated in the decoder with &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>. The output is typed ABox ready to insert as triples:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">schema&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">derive_json_schema_from_shacl&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;PersonShape.ttl&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The LLM can only emit tokens that keep the output valid.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">extracted&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llm&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">document&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">schema&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">schema&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_triples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">jsonld_to_rdf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">extracted&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cost: practically zero overhead per token with XGrammar; effective elimination of &amp;ldquo;outputs that do not validate&amp;rdquo;.&lt;/p>
&lt;h3 id="2-text-to-sparql-with-a-semantic-firewall">2. Text-to-SPARQL with a semantic firewall&lt;/h3>
&lt;p>The LLM generates SPARQL; &lt;strong>a semantic firewall&lt;/strong> validates every predicate and class against the TBox before running the query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">sparql_text&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llm&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user_query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ontology_summary&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">query&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sparql_text&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">predicate&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">predicates&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">predicate&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">ontology&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">declared_predicates&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">UnknownPredicate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">predicate&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">endpoint&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It catches the classic pattern of the LLM inventing a plausible predicate that does not exist in the ontology, &lt;strong>before touching the triple store&lt;/strong>.&lt;/p>
&lt;h3 id="3-hybrid-dense--sparse--kg-retrieval-with-rrf">3. Hybrid dense + sparse + KG retrieval with RRF&lt;/h3>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker and hybrid retrieval&lt;/a> is extended with a fourth channel: traversal in the KG seeded by the entities extracted from the query. The rankings of the four channels are fused with &lt;strong>Reciprocal Rank Fusion&lt;/strong>:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\text{RRF}(d) = \sum_{c \in \{\text{dense}, \text{sparse}, \text{colbert}, \text{kg}\}} \frac{1}{k + \text{rank}_c(d)}
&lt;/code>&lt;/pre>&lt;p>with &lt;code>k=60&lt;/code> typical. The KG channel covers exactly the queries that break the other three: queries with named entities that dense misreads or that appear rarely in the corpus.&lt;/p>
&lt;h3 id="4-reranking-by-graph-distance">4. Reranking by graph distance&lt;/h3>
&lt;p>Among the candidates from the first retrieval committee, &lt;strong>chunks whose entities lie within k hops in the KG&lt;/strong> of the query entities are preferred. A practical implementation: add a &lt;code>graph_distance&lt;/code> score and fuse it into the reranker:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">graph_distance_score&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query_entities&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunk_entities&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;entities&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">distances&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">shortest_path_length&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">kg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">qe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ce&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">qe&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">query_entities&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">ce&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">chunk_entities&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nb">min&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">distances&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="5-typed-tool-calling--stratified-evals">5. Typed tool calling + stratified evals&lt;/h3>
&lt;p>&lt;strong>Tools&lt;/strong> declare their arguments as ontology classes. Before invocation, the arguments go through SHACL validation. This avoids the classic bug of the agent calling &lt;code>buscar_cliente(cliente_id=&amp;quot;the customer who complained yesterday&amp;quot;)&lt;/code>, a free string where an IRI was expected.&lt;/p>
&lt;p>&lt;strong>Stratified evals&lt;/strong> by &lt;code>rdf:type&lt;/code> or &lt;code>skos:Concept&lt;/code>: each golden set query carries its ontological class as a label, the metrics are reported per class, and global accuracy is complemented by per-class coverage. It is the mechanism &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> recommends and the ontology makes operational.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The triple store or property graph &lt;strong>does not eat GPU&lt;/strong>: it runs on CPU plus NVMe. What does compete for GPU is the &lt;strong>LLM-extractor&lt;/strong> that builds and maintains the KG.&lt;/p>
&lt;h3 id="on-the-rtx-4090-24-gb">On the RTX 4090 (24 GB)&lt;/h3>
&lt;p>A reasonable setup for a PoC and small sites:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">GPU 24 GB ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ TEI bge-m3 (dense + sparse + colbert) │ ~6 GB VRAM
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ vLLM Qwen2.5-7B-Instruct AWQ Q4 (main LLM) │ ~8 GB VRAM
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ Occasional load: vLLM Qwen2.5-7B-Instruct for overnight extraction │ shares VRAM in another window
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">CPU/RAM ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ Apache Jena Fuseki (TBox + ABox of the corporate KG) │ ~2 GB RAM per M triples
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ Qdrant (dense + sparse + colbert) │ ~3 GB RAM per M chunks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ GLiNER + REBEL for fast batch extraction │ CPU-only
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For corpora of up to a few million chunks, an RTX 4090 does the job by combining GLiNER/REBEL on CPU for bulk extraction (cheap but less accurate) and the LLM on GPU for critical cases.&lt;/p>
&lt;h3 id="on-the-4h100-80-gb-cluster">On the 4×H100 80 GB cluster&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">H100 #1 (80 GB) ── vLLM Qwen3-72B-Instruct AWQ + Qwen2.5-7B speculative │ main LLM
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #2 (80 GB) ── vLLM gte-Qwen2-7B-instruct (embedding 32k ctx) │ large embedder
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #3 (80 GB) ── vLLM Qwen2.5-32B-Instruct (dedicated KG extractor) │ KG construction + maintenance
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #4 (80 GB) ── Hold-out for canary and offline evals │ see the canary post
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Apache Jena Fuseki cluster (3 CPU nodes + NVMe RAID)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ Corporate ontology (TBox)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ ABox (hundreds of millions of triples)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ FIBO / ENS / EU AI Act as named graphs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Qdrant cluster (3 CPU nodes + NVMe)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ Chunks indexed with triples in the payload
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ Lineage towards KG nodes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The H100 dedicated to the KG extractor is the real price of the GraphRAG approach. If the corpus is stable, that H100 can go to offline evals or speculative decoding. If the corpus mutates daily, it is busy keeping the graph up to date online.&lt;/p>
&lt;h2 id="the-seven-operational-traps">The seven operational traps&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Promoting SKOS to OWL DL out of academic ego&lt;/strong>. Most &amp;ldquo;corporate ontologies&amp;rdquo; are taxonomies that need no description logic reasoning. A SKOS with &lt;code>skos:broader&lt;/code>/&lt;code>skos:narrower&lt;/code> and &lt;code>skos:prefLabel&lt;/code> per language covers 80% of cases. OWL DL only makes sense when there are consistency axioms the reasoner has to verify. &lt;strong>Start with SKOS, move up to OWL EL/RL if you need to, avoid OWL DL unless the need is proven.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Building a KG over the whole corpus&lt;/strong>. Canonical GraphRAG applied to 100 million chunks costs as much as training a small model. The correct alternative is &lt;strong>HippoRAG 2 / LightRAG / KAG&lt;/strong> depending on the case, or &lt;strong>GraphRAG over the critical subset&lt;/strong> of the corpus only. The rule: if the construction cost exceeds the annual cost of serving the model, you have picked the wrong tool.&lt;/li>
&lt;li>&lt;strong>A TBox created by an LLM with no governance&lt;/strong>. Microsoft GraphRAG generates an emergent TBox from the data. For an exploratory corpus that works; for a regulated domain (finance, health, government) &lt;strong>the TBox is not discovered, it is declared&lt;/strong>: FIBO, SNOMED, ENS. KAG is the right choice in those cases.&lt;/li>
&lt;li>&lt;strong>Forgetting KG maintenance when the corpus changes&lt;/strong>. New chunks introduce new entities. Without an &lt;strong>entity reconciliation&lt;/strong> process (disambiguation, merging), the graph accumulates duplicates of the same entity under different IRIs and quality collapses quietly within six months. LightRAG has primitives for this; canonical GraphRAG requires periodic rebuilds.&lt;/li>
&lt;li>&lt;strong>A JSON Schema out of sync with the SHACL&lt;/strong>. If the ontology lives in RDF/SHACL and the structured outputs come from a hand-written JSON Schema, &lt;strong>they drift apart&lt;/strong>. The right approach is to &lt;strong>generate the JSON Schema from the SHACL&lt;/strong> with tools such as &lt;code>shacl-to-json-schema&lt;/code> and regenerate it in CI every time the shape changes.&lt;/li>
&lt;li>&lt;strong>Neo4j Community licensed badly&lt;/strong>. GPLv3 means any modification you distribute has to be released under the same licence. If you are going to redistribute a product embedding Neo4j Community, &lt;strong>check with legal or use an alternative with a more permissive licence&lt;/strong> (Memgraph BSL, Apache Jena for RDF, the Kùzu fork bighorn).&lt;/li>
&lt;li>&lt;strong>A compliance ontology not linked to the technical stack&lt;/strong>. Your ENS / ISO 42001 / EU AI Act mapping lives in a spreadsheet owned by the governance team. Your inventory of models, datasets and endpoints lives in another system. With no formal link between them, no stack change triggers the corresponding compliance review. &lt;strong>The mapping goes into the graph, not into the spreadsheet&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>An ontology is not an alternative to RAG; it is the nomenclature that makes its pieces comparable. Without it, the corpus is curated with ad-hoc categories, the embeddings drill through documents without being enriched, the evals measure the mean instead of the variance by class, the guardrail blocks on lists instead of types, incident response groups badly because each alert names things its own way, and compliance is a spreadsheet out of sync with the system. The six LLMOps stages &lt;strong>are all better when they share a vocabulary&lt;/strong>, and sharing a vocabulary means formalising a small corporate ontology, aligned with the relevant vertical frameworks (FIBO, SNOMED, schema.org, ENS, EU AI Act), serialised in JSON-LD so the code consumes it without friction, validated with SHACL at every interface and queried with SPARQL when reasoning is needed. GraphRAG in its 2026 variants (Microsoft v2, LightRAG, HippoRAG 2, KAG) is a complementary lever, not the main course: the main course is the shared formal nomenclature. The rest, Neo4j vs Jena, OWL DL vs SKOS, GLiNER vs LLM-extractor, are technical decisions that resolve better once there is clarity about which nomenclature is needed. Linnaeus discovered this in 1735 and biology has not gone back; the LLM field is discovering it in 2026 and will not go back either.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map of the six stages this post cuts across.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a> — curation becomes ontology-driven curation once a TBox is declared.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: the three families&lt;/a> — embeddings are enriched with typed metadata from the ontology.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — the KG is the fourth retrieval channel, fused via RRF alongside dense / sparse / multi-vector.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — the JSON Schemas used to build the KG from the LLM derive from SHACL.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a> — metrics stratified by ontological class are the operational lever the ontology enables.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — spans carry attributes typed against the TBox.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — a formal incident taxonomy enables Keep + Kafka deduplication.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">LLM inference router&lt;/a> — semantic routing by ontological class.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — a canary by class reduces the blast radius.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS × ISO 42001 × EU AI Act technical controls&lt;/a> — every regulatory framework &lt;em>is&lt;/em> an ontology and is mapped as one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: AIMS&lt;/a> — Annex A is a control hierarchy that can be formalised as SKOS.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical file&lt;/a> — Annex III is an enumerable classification mappable to the system&amp;rsquo;s classes.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>W3C. &lt;em>RDF 1.1 Concepts and Abstract Syntax&lt;/em>. &lt;a href="https://www.w3.org/TR/rdf11-concepts/">https://www.w3.org/TR/rdf11-concepts/&lt;/a>&lt;/li>
&lt;li>W3C. &lt;em>OWL 2 Profiles (EL, QL, RL, DL)&lt;/em>. &lt;a href="https://www.w3.org/TR/owl2-profiles/">https://www.w3.org/TR/owl2-profiles/&lt;/a>&lt;/li>
&lt;li>W3C. &lt;em>SHACL — Shapes Constraint Language&lt;/em>. &lt;a href="https://www.w3.org/TR/shacl/">https://www.w3.org/TR/shacl/&lt;/a>&lt;/li>
&lt;li>W3C. &lt;em>SKOS Reference&lt;/em>. &lt;a href="https://www.w3.org/TR/skos-reference/">https://www.w3.org/TR/skos-reference/&lt;/a>&lt;/li>
&lt;li>W3C. &lt;em>JSON-LD 1.1&lt;/em>. &lt;a href="https://www.w3.org/TR/json-ld11/">https://www.w3.org/TR/json-ld11/&lt;/a>&lt;/li>
&lt;li>W3C. &lt;em>SPARQL 1.1 Query Language&lt;/em>. &lt;a href="https://www.w3.org/TR/sparql11-query/">https://www.w3.org/TR/sparql11-query/&lt;/a>&lt;/li>
&lt;li>Edge et al. &lt;em>From Local to Global: A Graph RAG Approach to Query-Focused Summarization&lt;/em>. Microsoft Research, 2024. &lt;a href="https://arxiv.org/abs/2404.16130">https://arxiv.org/abs/2404.16130&lt;/a>&lt;/li>
&lt;li>Microsoft GraphRAG. &lt;a href="https://github.com/microsoft/graphrag">https://github.com/microsoft/graphrag&lt;/a>&lt;/li>
&lt;li>Guo et al. &lt;em>LightRAG: Simple and Fast Retrieval-Augmented Generation&lt;/em>. arXiv:2410.05779, 2024. &lt;a href="https://arxiv.org/abs/2410.05779">https://arxiv.org/abs/2410.05779&lt;/a>&lt;/li>
&lt;li>Gutiérrez et al. &lt;em>HippoRAG: Neurobiologically Inspired Long-Term Memory for Large Language Models&lt;/em>. NeurIPS 2024. &lt;a href="https://arxiv.org/abs/2405.14831">https://arxiv.org/abs/2405.14831&lt;/a>&lt;/li>
&lt;li>Gutiérrez et al. &lt;em>From RAG to Memory: Non-Parametric Continual Learning for Large Language Models&lt;/em> (HippoRAG 2). arXiv:2502.14802, 2025. &lt;a href="https://arxiv.org/abs/2502.14802">https://arxiv.org/abs/2502.14802&lt;/a>&lt;/li>
&lt;li>Liang et al. &lt;em>KAG: Boosting LLMs in Professional Domains via Knowledge Augmented Generation&lt;/em>. arXiv:2409.13731, 2024. &lt;a href="https://arxiv.org/abs/2409.13731">https://arxiv.org/abs/2409.13731&lt;/a>&lt;/li>
&lt;li>OpenSPG / KAG. &lt;a href="https://github.com/OpenSPG/openspg">https://github.com/OpenSPG/openspg&lt;/a>&lt;/li>
&lt;li>EDM Council. &lt;em>Financial Industry Business Ontology (FIBO)&lt;/em>. &lt;a href="https://spec.edmcouncil.org/fibo/">https://spec.edmcouncil.org/fibo/&lt;/a>&lt;/li>
&lt;li>SNOMED International. &lt;a href="https://www.snomed.org/">https://www.snomed.org/&lt;/a>&lt;/li>
&lt;li>schema.org. &lt;a href="https://schema.org/">https://schema.org/&lt;/a>&lt;/li>
&lt;li>Real Decreto 311/2022, de 3 de mayo, por el que se regula el Esquema Nacional de Seguridad. BOE-A-2022-7191. &lt;a href="https://www.boe.es/eli/es/rd/2022/05/03/311">https://www.boe.es/eli/es/rd/2022/05/03/311&lt;/a>&lt;/li>
&lt;li>&lt;em>Reglamento (UE) 2024/1689 (EU AI Act)&lt;/em>. &lt;a href="https://eur-lex.europa.eu/eli/reg/2024/1689">https://eur-lex.europa.eu/eli/reg/2024/1689&lt;/a>&lt;/li>
&lt;li>&lt;em>ISO/IEC 42001:2023 — Artificial Intelligence Management System&lt;/em>. &lt;a href="https://www.iso.org/standard/81230.html">https://www.iso.org/standard/81230.html&lt;/a>&lt;/li>
&lt;li>Apache Jena. &lt;a href="https://jena.apache.org/">https://jena.apache.org/&lt;/a>&lt;/li>
&lt;li>Neo4j Cypher and AI procedures. &lt;a href="https://neo4j.com/docs/">https://neo4j.com/docs/&lt;/a>&lt;/li>
&lt;li>Protégé. &lt;a href="https://protege.stanford.edu/">https://protege.stanford.edu/&lt;/a>&lt;/li>
&lt;li>GLiNER. &lt;a href="https://github.com/urchade/GLiNER">https://github.com/urchade/GLiNER&lt;/a>&lt;/li>
&lt;li>REBEL. &lt;a href="https://github.com/Babelscape/rebel">https://github.com/Babelscape/rebel&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Incident response runbooks for LLM inference: every alert to a concrete action with Kafka and Keep</title><link>https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/</link><pubDate>Tue, 02 Jun 2026 04:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/</guid><description>&lt;blockquote>
&lt;p>This post closes the observability trilogy opened by &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> (which metrics) and &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">Anatomy of the twelve DCGM and five vLLM metrics&lt;/a> (which documented anomaly per metric). Here each anomaly gets its concrete action and is fitted into the incident management machinery that compliance demands.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability&lt;/a> alerts are useless without a codified procedure for each one; the operator who interprets them by hand every time is operating on intuition. The right combination has &lt;strong>three indispensable pieces&lt;/strong>. (1) &lt;strong>Runbook catalogue&lt;/strong>: for each of the six critical alerts (&lt;code>GpuHbmNearOom&lt;/code>, &lt;code>GpuThermalOrPowerThrottle&lt;/code>, &lt;code>GpuXidErrorDetected&lt;/code>, &lt;code>GpuEccDoubleBit&lt;/code>, &lt;code>VllmKvCachePoolNearFull&lt;/code>, &lt;code>VllmTtftP95OutOfSlo&lt;/code>), a severity, an immediate mitigation, the evidence to capture &lt;strong>before&lt;/strong> remediating, a resolution action, a closure criterion and a postmortem trigger. (2) &lt;strong>Reproducible pipeline&lt;/strong>: Prometheus + DCGM → Alertmanager → &lt;strong>Kafka as event bus&lt;/strong> (topics &lt;code>gpu.alerts.enriched&lt;/code>, &lt;code>incidents.lifecycle&lt;/code>, &lt;code>audit.actions&lt;/code> with WORM retention) → &lt;strong>Keep as workflow engine&lt;/strong> (declarative YAML workflows versioned in git) → Kubernetes job / script / ChatOps executors. (3) &lt;strong>Formal fit into incident management&lt;/strong> under the regulatory corpus: &lt;strong>ISO/IEC 27035&lt;/strong> phases &lt;code>identify → report → assess → respond → learn&lt;/code>; &lt;strong>ENS&lt;/strong> controls &lt;code>op.exp.7&lt;/code> (incident management), &lt;code>op.exp.8&lt;/code> (activity logging), &lt;code>op.exp.10&lt;/code> (notification to users); &lt;strong>NIS2&lt;/strong> art. 23 with an early warning at &lt;strong>24 h&lt;/strong>, a formal notification at &lt;strong>72 h&lt;/strong> and a final report at &lt;strong>1 month&lt;/strong>; &lt;strong>EU AI Act&lt;/strong> art. 73 for a serious incident on a high-risk system, deadlines of &lt;strong>2 to 15 days&lt;/strong> depending on severity; &lt;strong>ISO/IEC 42001&lt;/strong> clause 10 (continual improvement of the AIMS). The action taxonomy is &lt;strong>immediate mitigation&lt;/strong> (drain, throttle, scale-down: contains the damage in seconds) → &lt;strong>diagnosis&lt;/strong> (evidence capture with &lt;code>nvidia-smi -q&lt;/code>, &lt;code>dmesg&lt;/code>, a vLLM &lt;code>/metrics&lt;/code> snapshot, the related OTel trace; without this the postmortem is not defensible) → &lt;strong>resolution&lt;/strong> (restart, reset, RMA, rollback) → &lt;strong>postmortem&lt;/strong> (5-whys RCA, prevention plan, runbook update). Kafka contributes the &lt;strong>immutable audit trail&lt;/strong> that ENS and the EU AI Act demand: every action executed by Keep or by a human is published as an event on &lt;code>audit.actions&lt;/code> with timestamp, actor, decision and evidence, retained WORM for at least 6 months. Keep contributes &lt;strong>workflows as code&lt;/strong>: this post includes three complete workflows (XID with drain plus Jira ticket, ECC DBE with immediate paging and node blocking in the scheduler, automatic canary rollback on TTFT P95 out of SLO). Four anti-patterns close the material: alerts without a runbook (most of them), a runbook without prior evidence capture (it perpetuates the incident because the root cause is lost), escalation by seniority rota instead of severity (a junior operator handling an ECC DBE), and the absence of a human gate for destructive actions (Keep running &lt;code>nvidia-smi --gpu-reset&lt;/code> without confirmation). Applicable to a generic 4×H100 SXM cluster with Kafka and Keep already deployed.&lt;/p>
&lt;h2 id="you-are-here-observe--deploy-incident-response-closes-the-loop">You are here: OBSERVE → DEPLOY (incident response closes the loop)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="incident response: Observe-Deploy loop">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;stroke-width:3}.semiactive{fill:#cfead0;stroke-width:2}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#rbm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#rbm)}.loop{stroke:#c33;stroke-width:1.8;fill:none;stroke-dasharray:5 3;marker-end:url(#rbmc)}&lt;/style>
&lt;defs>&lt;marker id="rbm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;marker id="rbmc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c33"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">Incident response: closes the loop from OBSERVE to DEPLOY (action)&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box semiactive"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="loop" d="M530,40 C500,5 480,5 460,40"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-a-nuclear-reactor-control-room">The analogy: a nuclear reactor control room&lt;/h2>
&lt;p>In a nuclear plant control room, the operator on shift &lt;strong>never decides what to do on seeing an alarm&lt;/strong>. The decision is pre-made and codified in a written procedure (SOP) covering every alarm on the panel: if X sounds, open book X, read steps 1-N, execute exactly, call the supervisor at step M, escalate to the plant director at step N+3. The reason is strict: critical alarms are rare but catastrophic if handled badly; an operator improvising in an emergency makes worse decisions than one applying a procedure reviewed by experts and validated by simulation.&lt;/p>
&lt;p>The reactor does not expect the operator to be a genius. It expects them to know the procedures to the letter and the operations management system to hand them the right procedure at the right moment. If the procedures are not written, not versioned, or not integrated with the alarms that fire them, the control room is operating on intuition. The difference between the two operations, procedural versus intuitive, is the difference between a plant that runs 30 years without incidents and one that ends up on a blacklist.&lt;/p>
&lt;p>Incident response on an LLM inference cluster works identically. The DCGM and vLLM alerts listed in the previous posts are the panel alarms. Each one needs its written SOP, versioned, integrated with the alert that fires it and reviewed after every incident. Without that codification, the operator on shift improvises in the middle of an ECC DBE failure at 4 in the morning; with it, they execute the nine steps of runbook 12 and the incident closes in 20 minutes.&lt;/p>
&lt;h2 id="the-incident-pipeline-architecture">The incident pipeline architecture&lt;/h2>
&lt;div class="diagram" style="max-width:840px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 840 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="incident response pipeline">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.src{fill:#dfe9f5;stroke:#356}.am{fill:#eef0d0;stroke:#7a3}.k{fill:#f4e3cf;stroke:#a63}.kp{fill:#ead8f5;stroke:#634}.ex{fill:#d8eecf;stroke:#373}.au{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.l{font:11px sans-serif;fill:#222}.n{font:italic 10px sans-serif;fill:#444}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#pim)}.dbl{stroke:#666;stroke-width:1.4;fill:none;stroke-dasharray:4 2;marker-end:url(#pim)}&lt;/style>
&lt;defs>&lt;marker id="pim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="420" y="20" text-anchor="middle" class="title">Pipeline: Prometheus → Alertmanager → Kafka → Keep → Executors · WORM audit in parallel&lt;/text>
&lt;rect x="20" y="45" width="140" height="60" class="b src"/>&lt;text x="90" y="65" text-anchor="middle" class="h">Prometheus&lt;/text>&lt;text x="90" y="82" text-anchor="middle" class="l">DCGM + vLLM&lt;/text>&lt;text x="90" y="98" text-anchor="middle" class="n">scrape 15s&lt;/text>
&lt;rect x="190" y="45" width="140" height="60" class="b am"/>&lt;text x="260" y="65" text-anchor="middle" class="h">Alertmanager&lt;/text>&lt;text x="260" y="82" text-anchor="middle" class="l">PrometheusRule&lt;/text>&lt;text x="260" y="98" text-anchor="middle" class="n">webhook → kafka&lt;/text>
&lt;rect x="360" y="45" width="160" height="60" class="b k"/>&lt;text x="440" y="65" text-anchor="middle" class="h">Kafka&lt;/text>&lt;text x="440" y="82" text-anchor="middle" class="l">gpu.alerts.enriched&lt;/text>&lt;text x="440" y="98" text-anchor="middle" class="n">incidents.lifecycle&lt;/text>
&lt;rect x="550" y="45" width="140" height="60" class="b kp"/>&lt;text x="620" y="65" text-anchor="middle" class="h">Keep&lt;/text>&lt;text x="620" y="82" text-anchor="middle" class="l">YAML workflows&lt;/text>&lt;text x="620" y="98" text-anchor="middle" class="n">git-versioned&lt;/text>
&lt;rect x="720" y="45" width="100" height="60" class="b ex"/>&lt;text x="770" y="65" text-anchor="middle" class="h">Executors&lt;/text>&lt;text x="770" y="82" text-anchor="middle" class="l">kubectl · API&lt;/text>&lt;text x="770" y="98" text-anchor="middle" class="n">ChatOps&lt;/text>
&lt;path class="arr" d="M160,75 L190,75"/>
&lt;path class="arr" d="M330,75 L360,75"/>
&lt;path class="arr" d="M520,75 L550,75"/>
&lt;path class="arr" d="M690,75 L720,75"/>
&lt;rect x="360" y="160" width="160" height="60" class="b au"/>&lt;text x="440" y="180" text-anchor="middle" class="h">audit.actions&lt;/text>&lt;text x="440" y="197" text-anchor="middle" class="l">WORM topic&lt;/text>&lt;text x="440" y="213" text-anchor="middle" class="n">retention 6 months+&lt;/text>
&lt;path class="dbl" d="M620,105 L520,160"/>
&lt;path class="dbl" d="M770,105 L520,168"/>
&lt;text x="420" y="252" text-anchor="middle" class="n">Every Keep or human action is published to audit.actions: WORM required by ENS op.exp.8 + EU AI Act art. 12.&lt;/text>
&lt;rect x="20" y="240" width="220" height="60" class="b kp"/>&lt;text x="130" y="260" text-anchor="middle" class="h">Compliance consumers&lt;/text>&lt;text x="130" y="277" text-anchor="middle" class="l">DPO · ENS audit · NIS2 reporting&lt;/text>&lt;text x="130" y="293" text-anchor="middle" class="n">read audit.actions read-only&lt;/text>
&lt;path class="arr" d="M360,180 L240,260"/>
&lt;rect x="600" y="240" width="220" height="60" class="b ex"/>&lt;text x="710" y="260" text-anchor="middle" class="h">Postmortem tooling&lt;/text>&lt;text x="710" y="277" text-anchor="middle" class="l">Jira · MLflow · Langfuse&lt;/text>&lt;text x="710" y="293" text-anchor="middle" class="n">enriched with timeline&lt;/text>
&lt;path class="arr" d="M520,180 L600,260"/>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Prometheus + DCGM.&lt;/strong> Collects the metrics described in the two previous posts. PrometheusRules define the six critical alerts with &lt;code>for: &amp;lt;duration&amp;gt;&lt;/code> to avoid noise.&lt;/p>
&lt;p>&lt;strong>Alertmanager.&lt;/strong> Receives raw alerts; deduplicates, groups by labels (&lt;code>{cluster, node, gpu, model}&lt;/code>), routes. Instead of sending straight to PagerDuty or Slack, it &lt;strong>sends to Kafka&lt;/strong> via a webhook receiver. That turns the alert into a bus event that multiple consumers process (Keep for action, the audit topic for compliance, dashboards for visualisation).&lt;/p>
&lt;p>&lt;strong>Kafka as event bus.&lt;/strong> Three canonical topics:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>gpu.alerts.enriched&lt;/code>&lt;/strong> — alerts with added context (tenant, model, version, namespace owner, effective severity). Retention: 7 days, replication factor 3.&lt;/li>
&lt;li>&lt;strong>&lt;code>incidents.lifecycle&lt;/code>&lt;/strong> — incident cycle events: &lt;code>incident.opened&lt;/code>, &lt;code>incident.acknowledged&lt;/code>, &lt;code>action.proposed&lt;/code>, &lt;code>action.executed&lt;/code>, &lt;code>incident.escalated&lt;/code>, &lt;code>incident.resolved&lt;/code>, &lt;code>postmortem.attached&lt;/code>. Retention: 90 days.&lt;/li>
&lt;li>&lt;strong>&lt;code>audit.actions&lt;/code>&lt;/strong> — an immutable record of every action executed (by Keep automatically or by a human confirming). Retention: &lt;strong>6 months minimum with compaction off plus tiered storage&lt;/strong>, WORM storage. This is the topic ENS &lt;code>op.exp.8&lt;/code>, EU AI Act art. 12 and NIS2 require you to keep.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Keep as workflow engine.&lt;/strong> Consumes from &lt;code>gpu.alerts.enriched&lt;/code>, fires YAML workflows versioned in git, executes actions (HTTP calls, kubectl jobs, Slack messages, Jira tickets) and publishes the result to &lt;code>incidents.lifecycle&lt;/code> plus &lt;code>audit.actions&lt;/code>. Choosing Keep over Alertmanager alone (or over PagerDuty alone) is deliberate: Keep separates &lt;strong>runbook declaration&lt;/strong> (readable, reviewable YAML) from &lt;strong>notification delivery&lt;/strong> (PagerDuty). The runbook is versioned code; notifications are operational details.&lt;/p>
&lt;p>&lt;strong>Executors.&lt;/strong> What actually moves the cluster:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Kubernetes jobs&lt;/strong>: &lt;code>kubectl drain&lt;/code>, &lt;code>kubectl cordon&lt;/code>, &lt;code>kubectl rollout undo&lt;/code>.&lt;/li>
&lt;li>&lt;strong>NVIDIA API&lt;/strong>: &lt;code>nvidia-smi --gpu-reset&lt;/code>, &lt;code>dcgmi diag -r &amp;lt;level&amp;gt;&lt;/code>.&lt;/li>
&lt;li>&lt;strong>ChatOps&lt;/strong>: human confirmations through Slack interactive messages before a destructive action runs.&lt;/li>
&lt;li>&lt;strong>External tooling&lt;/strong>: Jira ticket, PagerDuty notification, CMDB call.&lt;/li>
&lt;/ul>
&lt;h2 id="the-six-critical-alerts-and-their-runbooks">The six critical alerts and their runbooks&lt;/h2>
&lt;p>For each alert: severity, immediate mitigation (seconds), evidence to capture &lt;strong>before remediating&lt;/strong>, resolution action, closure criteria, postmortem trigger.&lt;/p>
&lt;h3 id="rb-01--gpuhbmnearoom--hbm--92--sustained">RB-01 · &lt;code>GpuHbmNearOom&lt;/code> — HBM &amp;gt; 92 % sustained&lt;/h3>
&lt;p>&lt;strong>Severity&lt;/strong>: WARNING. Risk of OOM on the next PagedAttention allocation.&lt;/p>
&lt;p>&lt;strong>Immediate mitigation.&lt;/strong> Reduce admission temporarily by lowering &lt;code>max_num_seqs&lt;/code> on the affected engine via hot reload (if the engine supports it) or a staggered restart of replicas. Trigger an additional scale-out via KEDA if there are free GPU nodes. There is no need to drain the node.&lt;/p>
&lt;p>&lt;strong>Evidence to capture.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>index,memory.used,memory.free,memory.total --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ROW_REMAPPER &lt;span class="p">|&lt;/span> grep -i pending
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl http://vllm-pod:8000/metrics &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;gpu_cache_usage|num_requests&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl logs &amp;lt;pod&amp;gt; --tail&lt;span class="o">=&lt;/span>&lt;span class="m">200&lt;/span> &lt;span class="p">|&lt;/span> grep -i &lt;span class="s2">&amp;#34;preempt\|swap&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Save the snapshot to &lt;code>audit.actions&lt;/code> with a timestamp and an &lt;code>incident_id&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Resolution.&lt;/strong> If the cause is a traffic spike: let the autoscaler scale to a stable regime, monitor for 30 min. If the cause is a model regression (canary v2 consumes more KV cache than v1): roll the canary back (see RB-06). If it is a leak (the metric grows without traffic growing): restart the pod with a heap dump capture.&lt;/p>
&lt;p>&lt;strong>Closure.&lt;/strong> &lt;code>gpu_cache_usage_perc &amp;lt; 80 %&lt;/code> sustained for 15 min AND &lt;code>num_requests_waiting == 0&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Postmortem.&lt;/strong> Not mandatory unless the incident lasted &amp;gt; 30 min or had SLO impact.&lt;/p>
&lt;h3 id="rb-02--gputhermalorpowerthrottle--bit--0-and-not-idle-in-clock_throttle_reasons">RB-02 · &lt;code>GpuThermalOrPowerThrottle&lt;/code> — bit ≠ 0 and not Idle in CLOCK_THROTTLE_REASONS&lt;/h3>
&lt;p>&lt;strong>Severity&lt;/strong>: WARNING (thermal) or CRITICAL (sustained HW Power Brake, PDU risk).&lt;/p>
&lt;p>&lt;strong>Immediate mitigation.&lt;/strong> Identify the bit (decode the bitmap). If it is &lt;strong>&lt;code>0x40 HW_THERMAL&lt;/code>&lt;/strong> or &lt;strong>&lt;code>0x20 SW_THERMAL&lt;/code>&lt;/strong>: drain the workload from the node to other replicas if the temperature does not drop within 2 min, and stop new pods landing on that node (&lt;code>kubectl cordon&lt;/code>). If it is &lt;strong>&lt;code>0x80 HW_POWER_BRAKE&lt;/code>&lt;/strong>: alert DC infrastructure immediately (likely an over-committed PDU, the Dell KB 000220508 / Lenovo HT514380 case) and lower the TDP of the rack&amp;rsquo;s GPUs via &lt;code>nvidia-smi -pl&lt;/code> to a lower value to relieve load on the breaker.&lt;/p>
&lt;p>&lt;strong>Evidence.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>index,temperature.gpu,temperature.memory,power.draw,clocks_throttle_reasons.active --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ipmitool sdr &lt;span class="p">|&lt;/span> grep -i &lt;span class="s2">&amp;#34;fan\|temp\|inlet&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># PDU data if they are instrumented (modbus / SNMP)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Resolution.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Thermal&lt;/strong>: review rack airflow, check the rear-door HX, T_inlet, DGX fans. An infra issue, not an engine one.&lt;/li>
&lt;li>&lt;strong>Power Brake&lt;/strong>: review the sizing of the PDU branch, the breaker, the 415 VAC distribution. Likely a redistribution of load to another branch or a temporary TDP cap.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Closure.&lt;/strong> &lt;code>CLOCK_THROTTLE_REASONS == 0x1&lt;/code> (Idle only) or &lt;code>0x0&lt;/code> for 30 min under normal load.&lt;/p>
&lt;p>&lt;strong>Postmortem.&lt;/strong> Mandatory if it was a HW Power Brake: that implicates the DC electrical infrastructure.&lt;/p>
&lt;h3 id="rb-03--gpuxiderrordetected--increasedcgm_fi_dev_xid_errors5m--0">RB-03 · &lt;code>GpuXidErrorDetected&lt;/code> — &lt;code>increase(DCGM_FI_DEV_XID_ERRORS[5m]) &amp;gt; 0&lt;/code>&lt;/h3>
&lt;p>&lt;strong>Severity&lt;/strong>: CRITICAL.&lt;/p>
&lt;p>&lt;strong>Immediate mitigation.&lt;/strong> &lt;code>kubectl cordon&lt;/code> the node (no more new pods). If the XID is 31/48/79/94/95 (hardware or cascade): drain the node&amp;rsquo;s existing pods. If the XID is 13/43 (possibly software): keep the pods but block new ones, capture the trace and the active workload.&lt;/p>
&lt;p>&lt;strong>Evidence.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The specific XID from dmesg&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dmesg &lt;span class="p">|&lt;/span> grep -i xid &lt;span class="p">|&lt;/span> tail -30
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ERROR
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d PCIE
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># State of the retired pages&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ROW_REMAPPER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Workload that was running&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get pods -o wide &lt;span class="p">|&lt;/span> grep &amp;lt;node&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl logs &amp;lt;pod&amp;gt; --previous --tail&lt;span class="o">=&lt;/span>&lt;span class="m">500&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Resolution.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>XID 13/43&lt;/strong> (software exception / channel verif): if it recurs only with one specific model, it is a workload bug; raise an issue with the model team. If it is transient, restarting the pod is enough.&lt;/li>
&lt;li>&lt;strong>XID 31&lt;/strong> (MMU fault): usually a cascade from a previous XID 48. Reset the GPU (&lt;code>nvidia-smi --gpu-reset -i &amp;lt;index&amp;gt;&lt;/code>) or reboot the node if the reset does not resolve it.&lt;/li>
&lt;li>&lt;strong>XID 48 / 95&lt;/strong> (DBE / uncontained ECC): see RB-04. The node goes into quarantine.&lt;/li>
&lt;li>&lt;strong>XID 79&lt;/strong> (fallen off the bus): reboot the node. If it recurs after the reboot, open an RMA for the GPU. ByteDance reports 43 % co-occurrence with PCIe errors, so check the slot and the cable too.&lt;/li>
&lt;li>&lt;strong>XID 94 / 145 / 149&lt;/strong>: catalogued in NVIDIA&amp;rsquo;s Xid Catalog with a specific procedure.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Closure.&lt;/strong> Node smoke test passed (&lt;code>dcgmi diag -r 3&lt;/code>), 24 h with no new XIDs, back into the pool.&lt;/p>
&lt;p>&lt;strong>Postmortem.&lt;/strong> &lt;strong>Mandatory&lt;/strong>. Include the specific XID, the distribution of XIDs across the cluster, an updated MTBE.&lt;/p>
&lt;h3 id="rb-04--gpueccdoublebit--dcgm_fi_dev_ecc_dbe_vol_total--0">RB-04 · &lt;code>GpuEccDoubleBit&lt;/code> — &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL &amp;gt; 0&lt;/code>&lt;/h3>
&lt;p>&lt;strong>Severity&lt;/strong>: CRITICAL, data corruption in progress.&lt;/p>
&lt;p>&lt;strong>Immediate mitigation.&lt;/strong> &lt;strong>Drain the node immediately without waiting for further evidence.&lt;/strong> Page (PagerDuty / OpsGenie) the primary ON-CALL. Mark the node &lt;code>unschedulable&lt;/code> and &lt;code>failed&lt;/code>. XID 48 has a &lt;strong>100 % probability of killing the running job&lt;/strong> according to the &lt;em>Story of Two GPUs&lt;/em> dataset; any inference already in flight is compromised.&lt;/p>
&lt;p>&lt;strong>Evidence (in parallel with the mitigation).&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ECC
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ROW_REMAPPER &lt;span class="c1"># Pending: Yes expected&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dmesg &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;Xid.*48|DBE|double-bit&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> tail -50
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Full capture of the GPU state&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dcgmi diag -r &lt;span class="m">4&lt;/span> -i &amp;lt;gpu_index&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Resolution.&lt;/strong> Full GPU reset (&lt;code>nvidia-smi --gpu-reset&lt;/code>) or a node reboot if the reset does not complete. The reset activates the row remap. After the reboot:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ROW_REMAPPER &lt;span class="c1"># Pending: No expected&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ECC &lt;span class="c1"># volatile counters at 0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>RETIRED_DBE &amp;gt; 8&lt;/code> pages after the remap: schedule a &lt;strong>GPU replacement&lt;/strong> in the next window, because silicon degradation is progressive. The published real case documents &lt;em>~19 hours of downtime&lt;/em> as typical.&lt;/p>
&lt;p>&lt;strong>Closure.&lt;/strong> Node back in the pool after 48 h with no new DBEs.&lt;/p>
&lt;p>&lt;strong>Postmortem.&lt;/strong> &lt;strong>Mandatory&lt;/strong>. If the incident affected a request carrying personal or classified data, evaluate notification to the DPO under GDPR art. 33 (it is not necessarily a breach, but it has to be assessed).&lt;/p>
&lt;h3 id="rb-05--vllmkvcachepoolnearfull--gpu_cache_usage_perc--95--sustained-3-min">RB-05 · &lt;code>VllmKvCachePoolNearFull&lt;/code> — &lt;code>gpu_cache_usage_perc &amp;gt; 95 %&lt;/code> sustained 3 min&lt;/h3>
&lt;p>&lt;strong>Severity&lt;/strong>: WARNING (risk of preempt-on-OOM, not of a real OOM).&lt;/p>
&lt;p>&lt;strong>Immediate mitigation.&lt;/strong> Activate autoscaler scale-out by lowering the KEDA threshold temporarily (from 0.85 to 0.75) for 30 min. In &lt;code>recompute&lt;/code> mode, preempts raise TTFT but do not break requests; acceptable in the short term. In &lt;code>swap&lt;/code> mode latency goes through the roof, so it is better to cut new traffic (return 503 from the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">router&lt;/a>) for 5 min.&lt;/p>
&lt;p>&lt;strong>Evidence.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl http://vllm-pod:8000/metrics &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;gpu_cache|num_requests|num_preemptions&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get hpa vllm-llama70b
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl logs &amp;lt;pod&amp;gt; --tail&lt;span class="o">=&lt;/span>&lt;span class="m">200&lt;/span> &lt;span class="p">|&lt;/span> grep -i preempt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Resolution.&lt;/strong> If it recurs regularly: revisit capacity planning, possibly lowering &lt;code>max_num_seqs&lt;/code> or raising the stable replica count. See &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Closure.&lt;/strong> Pool &amp;lt; 85 % sustained 30 min, no preempts in the last 15 min.&lt;/p>
&lt;p>&lt;strong>Postmortem.&lt;/strong> Not mandatory unless it recurs &amp;gt; 3 times / week.&lt;/p>
&lt;h3 id="rb-06--vllmttftp95outofslo--ttft-p95--15-s-for-5-min">RB-06 · &lt;code>VllmTtftP95OutOfSlo&lt;/code> — TTFT P95 &amp;gt; 1.5 s for 5 min&lt;/h3>
&lt;p>&lt;strong>Severity&lt;/strong>: CRITICAL (contractual SLO violation).&lt;/p>
&lt;p>&lt;strong>Immediate mitigation.&lt;/strong> Quick diagnosis of the regime (in order of likelihood):&lt;/p>
&lt;ol>
&lt;li>If a v2 canary is active and the ratio &lt;code>ttft_p95(v2)/ttft_p95(v1) &amp;gt; 1.30&lt;/code>: &lt;strong>automatic rollback&lt;/strong> of the canary via Argo Rollouts (&lt;code>argo rollouts abort vllm-llama70b&lt;/code>).&lt;/li>
&lt;li>If &lt;code>num_requests_waiting &amp;gt; 5&lt;/code>: scale out via KEDA.&lt;/li>
&lt;li>If &lt;code>DRAM_ACTIVE &amp;gt; 90 %&lt;/code> plus &lt;code>gpu_cache_usage_perc &amp;gt; 90 %&lt;/code>: HBM bottleneck, reach for the quantisation lever or a context reduction.&lt;/li>
&lt;li>If &lt;code>CLOCK_THROTTLE_REASONS != 0&lt;/code>: see RB-02.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Evidence.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Histogram snapshot&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl http://vllm-pod:8000/metrics &lt;span class="p">|&lt;/span> grep time_to_first_token
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Breakdown by version if a canary is running&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># DCGM state at that moment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl http://dcgm-exporter:9400/metrics &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;PIPE_TENSOR|DRAM_ACTIVE|THROTTLE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Active traffic&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl top pods -n inference
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Resolution.&lt;/strong> Depends on the diagnosis. Typical cases:&lt;/p>
&lt;ul>
&lt;li>Canary regression → full rollback (see &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary&lt;/a>).&lt;/li>
&lt;li>Capacity saturation → scale replicas or accept a temporary 503 with &lt;code>Retry-After&lt;/code>.&lt;/li>
&lt;li>Prefill bound → enable or calibrate chunked prefill or disaggregated serving (see &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Closure.&lt;/strong> TTFT P95 inside SLO sustained 30 min.&lt;/p>
&lt;p>&lt;strong>Postmortem.&lt;/strong> &lt;strong>Mandatory&lt;/strong>. Document the root cause and the lever applied; update the runbook.&lt;/p>
&lt;h2 id="keep-yaml-workflows--three-complete-examples">Keep YAML workflows — three complete examples&lt;/h2>
&lt;p>Runbooks are only useful if they are &lt;strong>codified&lt;/strong> in the workflow engine. Keep lets you declare them in YAML versioned in git.&lt;/p>
&lt;h3 id="workflow-1--xid-detectedyaml">Workflow 1 — &lt;code>xid-detected.yaml&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">workflow&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">xid-detected-drain&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;XID error detected — cordon node and capture evidence&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;RB-03 implementation&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">alert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">filters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">alertname&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuXidErrorDetected&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">capture-evidence&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bash&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> set -e
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> NODE=&amp;#34;{{ alert.labels.node }}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> GPU=&amp;#34;{{ alert.labels.gpu }}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> INC_ID=&amp;#34;{{ alert.fingerprint }}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> mkdir -p /var/evidence/$INC_ID
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> kubectl debug node/$NODE -it --image=nvcr.io/nvidia/cuda:12.4.0-base-ubuntu22.04 -- \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> bash -c &amp;#34;nvidia-smi -q -d ERROR,PCIE,ROW_REMAPPER &amp;gt; /host/var/evidence/$INC_ID/smi.txt&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> kubectl describe node $NODE &amp;gt; /var/evidence/$INC_ID/node.txt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cordon-node&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kubernetes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cordon&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.labels.node }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">if&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.labels.severity == &amp;#39;critical&amp;#39; }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">actions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">open-jira-ticket&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">jira&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.jira-prod }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">project&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GPUOPS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">issuetype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Incident&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;RB-03: XID {{ alert.annotations.xid_code }} on {{ alert.labels.node }}/{{ alert.labels.gpu }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Severity: {{ alert.labels.severity }}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> XID: {{ alert.annotations.xid_code }}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Evidence: /var/evidence/{{ alert.fingerprint }}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Runbook: https://runbooks.example.local/RB-03&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">notify-slack&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">slack&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.slack-gpu-incidents }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">message&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> :warning: *RB-03 triggered*
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Node: `{{ alert.labels.node }}` GPU: `{{ alert.labels.gpu }}`
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> XID: `{{ alert.annotations.xid_code }}`
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;lt;{{ jira.url }}|Jira ticket&amp;gt;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">emit-audit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kafka&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.kafka-audit }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">topic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit.actions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">message&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">incident_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.fingerprint }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cordon_node&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">actor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;keep-workflow&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workflow_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;xid-detected-drain&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.labels.node }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timestamp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ now }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="workflow-2--ecc-dbeyaml--immediate-paging">Workflow 2 — &lt;code>ecc-dbe.yaml&lt;/code> — immediate paging&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">workflow&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ecc-dbe-critical&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ECC double-bit — page on-call and quarantine node&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">alert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">filters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">alertname&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuEccDoubleBit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cordon-immediately&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kubernetes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cordon&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.labels.node }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">drain-workload&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kubernetes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">drain&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.labels.node }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">options&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ignore-daemonsets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">delete-emptydir-data&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grace-period&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">page-oncall&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pagerduty&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.pagerduty-critical }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">service_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ env.PD_SERVICE_KEY }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;RB-04 ECC DBE on {{ alert.labels.node }}/{{ alert.labels.gpu }} — node drained&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">emit-lifecycle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kafka&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.kafka-incidents }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">topic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">incidents.lifecycle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">message&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">incident_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.fingerprint }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">incident.opened&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runbook&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RB-04&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requires_postmortem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">notify-dpo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">email&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">to&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dpo@example.local&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subject&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ECC DBE on a production GPU — assessment required&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">body&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> RB-04 ECC DBE incident detected on {{ alert.labels.node }}.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Affected model: {{ alert.labels.model }}.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Please assess whether personal/classified data was processed
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> during the error window and whether GDPR art. 33 notification is needed.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="workflow-3--canary-rollbackyaml--ttft-p95-out-of-slo">Workflow 3 — &lt;code>canary-rollback.yaml&lt;/code> — TTFT P95 out of SLO&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">workflow&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">canary-rollback-ttft&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Rollback canary when TTFT P95 ratio v2/v1 &amp;gt; 1.30&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">alert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">filters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">alertname&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmTtftP95OutOfSlo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">canary_active&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">check-ratio&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.prom-prod }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{version=&amp;#34;v2&amp;#34;}[5m])))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> /
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{version=&amp;#34;v1&amp;#34;}[5m])))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">condition&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">result &amp;gt; 1.30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">actions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">argo-rollback&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kubernetes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">exec&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">kubectl&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">argo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">rollouts&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">abort&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;{{ alert.labels.rollout }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- -&lt;span class="kc">n&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;{{ alert.labels.namespace }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">notify-and-audit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kafka&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ providers.kafka-audit }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">topic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit.actions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">message&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">incident_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ alert.fingerprint }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">canary_rollback&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ratio&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ steps.check-ratio.result }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">actor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keep-workflow&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timestamp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;{{ now }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each workflow lives in &lt;code>repos/keep-workflows/&lt;/code> versioned in git, reviewed by pull request, validated by CI (&lt;code>keep workflow validate&lt;/code>). The written runbook lives as &lt;code>docs/runbooks/RB-XX.md&lt;/code> linked from the workflow YAML; the two always evolve together.&lt;/p>
&lt;h2 id="the-canonical-kafka-event-schema">The canonical Kafka event schema&lt;/h2>
&lt;p>For the topics to be consumable by compliance, postmortem tooling and dashboards without each consumer having to guess the shape, the schema is fixed with Avro / Protobuf.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;IncidentLifecycleEvent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;record&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;fields&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;incident_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;event&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;enum&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;symbols&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;incident.opened&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;incident.acknowledged&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;action.proposed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;action.executed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;action.failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;incident.escalated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;incident.resolved&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;postmortem.attached&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;logicalType&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;timestamp-millis&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;actor&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;severity&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;enum&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;symbols&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;low&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;warning&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;critical&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;runbook&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;alert_name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;labels&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;map&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;values&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;annotations&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;map&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;values&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;evidence_uri&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;requires_postmortem&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;boolean&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For &lt;code>audit.actions&lt;/code> (WORM), a separate and stricter schema with non-modifiable fields:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;AuditAction&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;record&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;fields&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;incident_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;action&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;actor&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;actor_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;enum&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;symbols&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;human&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;workflow&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;scheduler&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;workflow_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;target&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;command&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;result&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;enum&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;symbols&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;success&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;failure&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;partial&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;logicalType&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;timestamp-millis&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;evidence_uri&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;approver&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The topic is configured with &lt;code>cleanup.policy=delete&lt;/code>, &lt;code>retention.ms=15552000000&lt;/code> (6 months) and &lt;code>min.insync.replicas=2&lt;/code> with &lt;code>acks=all&lt;/code> to guarantee durability. For longer retention without the Kafka cost, use &lt;strong>tiered storage&lt;/strong> to Ceph RGW or an S3-compatible backend: new log in the hot tier, old log in the cold tier, transparently to the consumer.&lt;/p>
&lt;h2 id="formal-fit-into-incident-management">Formal fit into incident management&lt;/h2>
&lt;p>Runbooks are not an isolated SRE practice. They fit into four regulatory frameworks that production LLM platforms touch daily.&lt;/p>
&lt;h3 id="isoiec-27035--information-security-incident-management">ISO/IEC 27035 — information security incident management&lt;/h3>
&lt;p>It defines the formal cycle in five phases: &lt;strong>plan &amp;amp; prepare&lt;/strong> → &lt;strong>detect &amp;amp; report&lt;/strong> → &lt;strong>assess &amp;amp; decide&lt;/strong> → &lt;strong>respond&lt;/strong> → &lt;strong>lessons learned&lt;/strong>. Each phase has outputs that must be documented. Translated to the stack:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Plan &amp;amp; prepare&lt;/strong>: runbooks RB-01 to RB-06 plus the Keep workflows are part of the &lt;em>Information Security Incident Management Plan&lt;/em>. Versioned in git, reviewed annually.&lt;/li>
&lt;li>&lt;strong>Detect &amp;amp; report&lt;/strong>: the Prometheus alerts entering Kafka are the materialisation.&lt;/li>
&lt;li>&lt;strong>Assess &amp;amp; decide&lt;/strong>: the severity in &lt;code>gpu.alerts.enriched&lt;/code> plus the Keep workflow logic.&lt;/li>
&lt;li>&lt;strong>Respond&lt;/strong>: execution of the workflow&amp;rsquo;s &lt;code>steps&lt;/code> plus &lt;code>actions&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Lessons learned&lt;/strong>: a mandatory postmortem for the runbooks that flag it; the output documented in the postmortem repo plus the runbook update.&lt;/li>
&lt;/ul>
&lt;h3 id="ens-esquema-nacional-de-seguridad--opexp-controls">ENS (Esquema Nacional de Seguridad) — op.exp controls&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>&lt;code>op.exp.7&lt;/code> Incident management&lt;/strong>: the runbook catalogue plus the Keep / Kafka pipeline materialise the &amp;ldquo;organised and procedural response&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>&lt;code>op.exp.8&lt;/code> Activity logging&lt;/strong>: the &lt;code>audit.actions&lt;/code> topic with WORM retention of 6 months (minimum for level ALTO).&lt;/li>
&lt;li>&lt;strong>&lt;code>op.exp.9&lt;/code> Incident management logging&lt;/strong>: the &lt;code>incidents.lifecycle&lt;/code> topic with the full cycle of each incident.&lt;/li>
&lt;li>&lt;strong>&lt;code>op.exp.10&lt;/code> Protection of activity logs&lt;/strong>: WORM plus encryption at rest plus access control (compliance consumers read-only).&lt;/li>
&lt;/ul>
&lt;h3 id="nis2--notification-to-the-competent-authority">NIS2 — notification to the competent authority&lt;/h3>
&lt;p>For essential and important entities, art. 23 sets three deadlines from the detection of &lt;em>significant impact&lt;/em>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>24 hours&lt;/strong>: an early warning to the national CSIRT (INCIBE-CERT in Spain).&lt;/li>
&lt;li>&lt;strong>72 hours&lt;/strong>: a formal notification with an initial assessment.&lt;/li>
&lt;li>&lt;strong>1 month&lt;/strong>: a final report with root cause, impact and corrective measures.&lt;/li>
&lt;/ul>
&lt;p>The data for those reports comes straight out of &lt;code>incidents.lifecycle&lt;/code> plus &lt;code>audit.actions&lt;/code> with a consumer that generates the dossier in the required format. Without the auditable pipeline, the NIS2 deadlines are unreachable.&lt;/p>
&lt;h3 id="eu-ai-act--art-73-serious-incident-reporting">EU AI Act — art. 73 (serious incident reporting)&lt;/h3>
&lt;p>Applicable to high-risk systems. Deadlines:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>2 days&lt;/strong>: for incidents causing death or irreversible harm to people or critical infrastructure.&lt;/li>
&lt;li>&lt;strong>10 days&lt;/strong>: for incidents producing serious disruption of critical infrastructure.&lt;/li>
&lt;li>&lt;strong>15 days&lt;/strong>: for all other &amp;ldquo;serious incidents&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>The definition of &amp;ldquo;serious incident&amp;rdquo; includes systematic model failures, a fundamental rights breach, material or environmental harm. Runbooks must flag which alerts can lead to a serious incident (typically anything affecting the model&amp;rsquo;s output in a high-risk context) and fire a specific legal assessment sub-workflow.&lt;/p>
&lt;h3 id="isoiec-42001--aims-clause-10-continual-improvement">ISO/IEC 42001 — AIMS clause 10 continual improvement&lt;/h3>
&lt;p>The mandatory post-incident postmortem feeds clause 10. Updating the runbook after every incident that reveals a new pattern is the &amp;ldquo;corrective action with verification of effectiveness&amp;rdquo; the standard demands. See &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO 42001 AIMS&lt;/a>.&lt;/p>
&lt;h2 id="four-anti-patterns">Four anti-patterns&lt;/h2>
&lt;p>&lt;strong>Anti-pattern 1 — alerts without a runbook.&lt;/strong> The alert fires, the junior operator on call looks at the dashboard, searches Confluence, finds nothing up to date, calls the senior on Slack, waits 20 minutes. In that time the incident has grown. Rule: &lt;strong>no alert goes to production without a published runbook and an approved Keep workflow&lt;/strong>. CI validates that every &lt;code>PrometheusRule&lt;/code> with severity ≥ warning has its corresponding &lt;code>keep workflow&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Anti-pattern 2 — a runbook without prior evidence capture.&lt;/strong> The workflow runs &lt;code>nvidia-smi --gpu-reset&lt;/code> as soon as the XID arrives, losing the state that would have diagnosed the root cause. The next identical XID forces the diagnosis to be redone from scratch. Rule: &lt;strong>&lt;code>steps&lt;/code> before &lt;code>actions&lt;/code>&lt;/strong>; all evidence is captured first, destructive actions come after.&lt;/p>
&lt;p>&lt;strong>Anti-pattern 3 — escalation by seniority rota instead of severity.&lt;/strong> The junior operator on call handles an ECC DBE because &amp;ldquo;it is their turn&amp;rdquo;. They lack the context to understand row remap, retired pages or the data corruption risk. Rule: &lt;strong>page by severity, not by rota&lt;/strong>: RB-04 and RB-03 page the senior primary ON-CALL with automatic escalation to infra/hardware if there is no acknowledgement within 10 min.&lt;/p>
&lt;p>&lt;strong>Anti-pattern 4 — no human gate for destructive actions.&lt;/strong> The workflow runs &lt;code>kubectl drain&lt;/code> automatically on any alert marked CRITICAL. On the first false alarm (a transient that resolved itself in 30 s), Keep drained a production node during peak hour. Rule: &lt;strong>destructive actions (drain, reset, RMA, full rollback) require human confirmation&lt;/strong> via a Slack interactive message, with a configurable timeout. A justified exception: ECC DBE confirmed by &amp;gt; 1 measurement, where the corruption risk outweighs the false alarm risk.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4 nodes × 4×H100 SXM 80 GB&lt;/strong> with &lt;strong>Kafka and Keep already deployed&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Kafka&lt;/strong>: a 3-broker cluster on non-GPU nodes of the K8s cluster; topics &lt;code>gpu.alerts.enriched&lt;/code>, &lt;code>incidents.lifecycle&lt;/code>, &lt;code>audit.actions&lt;/code> configured with replication factor 3, min.insync.replicas 2. Audit with tiered storage to Ceph RGW for retention &amp;gt; 6 months without a brutal cost.&lt;/li>
&lt;li>&lt;strong>Keep&lt;/strong>: 2 operator replicas plus 1 worker replica in a &lt;code>keep&lt;/code> namespace; connected to Prometheus (read provider), Kafka (read plus write provider), Slack, PagerDuty, Jira, Kubernetes (provider with a dedicated SA holding &lt;code>get/list/patch nodes&lt;/code> and &lt;code>create jobs&lt;/code> permissions).&lt;/li>
&lt;li>&lt;strong>Workflows&lt;/strong>: ~25-40 YAML files in the &lt;code>infra/keep-workflows/&lt;/code> repo, synchronised with the cluster via Flux or Argo CD. Validated by CI (&lt;code>keep workflow validate&lt;/code>) on every PR.&lt;/li>
&lt;li>&lt;strong>Event volume&lt;/strong>: for 16 GPUs in normal operation with debounced alerts, ~50-200 events/day on &lt;code>gpu.alerts.enriched&lt;/code>. In a typical incident, peaks of 500-2,000 events/day.&lt;/li>
&lt;li>&lt;strong>Compliance consumers&lt;/strong>: a python consumer in the &lt;code>compliance&lt;/code> namespace that generates NIS2 / ENS / EU AI Act reports weekly, reading &lt;code>audit.actions&lt;/code> and &lt;code>incidents.lifecycle&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Postmortem playbooks&lt;/strong> — the mechanics of RCA with 5-whys, Ishikawa adapted to LLMs, integration with MLflow tracking for re-training if the postmortem produces an enriched dataset.&lt;/li>
&lt;li>&lt;strong>Chaos engineering for LLMs&lt;/strong> — controlled injection of XID errors, simulated ECC, artificial HBM latency to validate runbooks &lt;strong>before&lt;/strong> the real incident.&lt;/li>
&lt;li>&lt;strong>Multi-cluster incident coordination&lt;/strong> — how to coordinate Keep across geographic clusters when an incident affects multiple regions.&lt;/li>
&lt;li>&lt;strong>CMDB and procurement integration&lt;/strong> — the &lt;code>RMA → ticket → ServiceNow → hardware replacement&lt;/code> cycle automated via workflow.&lt;/li>
&lt;li>&lt;strong>LLM forensics&lt;/strong> — extracting the full OTel trace of a request affected by an incident, with redacted PII, kept in an evidence vault.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">Anatomy of the twelve DCGM and five vLLM metrics&lt;/a> — the documented anomaly per metric that these runbooks resolve.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the compact list and the six critical alerts.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel trace captured as evidence.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — the rollback mechanism RB-06 invokes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — the scaling lever RB-01 and RB-05 invoke.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> — the head-room budgeted to absorb incidents without an SLO break.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001 AIMS for on-premise LLMs&lt;/a> — the clause 10 these postmortems materialise.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls ENS × 42001 × EU AI Act&lt;/a> — the control mapping these runbooks satisfy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: mapping to LLM architecture&lt;/a> — art. 73 on serious incidents, which activates the legal sub-workflow.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — codified runbooks are a level 3-4 requirement.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the cascade of sustained 429s that knocks over the gateway&amp;rsquo;s startup probes and puts the pods into a restart loop.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>ISO/IEC 27035-1:2023 — &lt;em>Information security incident management — Principles and process&lt;/em>.&lt;/li>
&lt;li>ISO/IEC 27035-2:2023 — &lt;em>Information security incident management — Guidelines to plan and prepare for incident response&lt;/em>.&lt;/li>
&lt;li>ENS — &lt;em>Real Decreto 311/2022&lt;/em>, Anexo II controles &lt;code>op.exp.7&lt;/code> a &lt;code>op.exp.10&lt;/code>.&lt;/li>
&lt;li>Directiva NIS2 (UE 2022/2555) — art. 23 (notificación de incidentes significativos).&lt;/li>
&lt;li>Reglamento EU AI Act (UE 2024/1689) — art. 73 (reporting of serious incidents).&lt;/li>
&lt;li>ISO/IEC 42001:2023 — &lt;em>AI management system — cláusula 10 (mejora continua)&lt;/em>.&lt;/li>
&lt;li>Keep project — &lt;code>keephq.dev&lt;/code> y &lt;code>github.com/keephq/keep&lt;/code> (documentación de workflows YAML, providers).&lt;/li>
&lt;li>Apache Kafka — &lt;em>Tiered Storage&lt;/em> y &lt;code>cleanup.policy&lt;/code> (docs.confluent.io / kafka.apache.org).&lt;/li>
&lt;li>Confluent — &lt;em>Schema Registry&lt;/em> y best practices para eventos lifecycle.&lt;/li>
&lt;li>NVIDIA — &lt;em>Xid Errors Documentation&lt;/em> y procedimientos de remediación.&lt;/li>
&lt;li>Google SRE Book — &lt;em>Effective Troubleshooting&lt;/em> y &lt;em>Postmortem Culture&lt;/em>.&lt;/li>
&lt;li>Atlassian — &lt;em>Incident Management Handbook&lt;/em> (referencia para severity matrices).&lt;/li>
&lt;/ul></description></item><item><title>Technical controls: the ENS × ISO 42001 × EU AI Act cross-mapping over the on-premise LLM architecture</title><link>https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/</link><pubDate>Mon, 01 Jun 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/</guid><description>&lt;blockquote>
&lt;p>Third post in the blog&amp;rsquo;s AI governance trilogy. The first, &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001&lt;/a>, broke down the certifiable management system. The second, &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act&lt;/a>, broke down the directly applicable legal regulation. This one covers the third framework that appears when the client is a Spanish public administration or an essential-services entity: the ENS (Esquema Nacional de Seguridad, Spain&amp;rsquo;s national security framework, Royal Decree 311/2022). The editorial challenge: triple compliance is not the arithmetic sum of three jobs; it is one single set of technical evidence labelled for three lenses. This post takes apart how that evidence is built, measure by ENS measure.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Royal Decree 311/2022 updated the Spanish Esquema Nacional de Seguridad (ENS), aligning it with NIS2 and with the modern cyber-threat landscape. It applies mandatorily to the entire Spanish public sector (central, regional and local administration, universities, autonomous bodies) and to suppliers that provide IT services to them. The framework defines 74 security measures organised into three blocks (organisational framework &lt;code>org&lt;/code>, operational framework &lt;code>op&lt;/code>, protection measures &lt;code>mp&lt;/code>), three application categories (Basic / Medium / High) according to the assessment of five dimensions (Confidentiality, Integrity, Traceability, Authenticity, Availability), and an Annex II with the measure × category matrix that dictates what is required at each level. This post maps the technical measures relevant to LLM systems (those of the &lt;code>op&lt;/code> block: planning, access control, operation, external services, continuity, monitoring; and those of the &lt;code>mp&lt;/code> block: communications, applications, information, services) against Annex A of ISO/IEC 42001 and the operational articles of the EU AI Act (Arts. 9 to 15, 17, 72 and 73), and shows that the overlap is massive: a single technical artefact from the blog&amp;rsquo;s OSS stack, the OTel spans of &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing&lt;/a>, the datasets versioned with &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">DVC&lt;/a>, the &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a> scanners, the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrail&lt;/a> decisions, the incidents of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> loop, simultaneously satisfies ENS measures, 42001 controls and AI Act articles, provided it is labelled with the correct codes from the moment it is captured. The editorial thesis: the difference between a system that passes all three audits and one that suffers three separate certifications is not budget, it is disciplined labelling and a common vocabulary. The post builds the master table of triple compliance, walks through the case of the multi-tenant chatbot for a High-category public administration as a live checklist, and closes with the five traps of triple compliance.&lt;/p>
&lt;h2 id="the-analogy-the-triple-inspection-of-a-critical-building">The analogy: the triple inspection of a critical building&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Triple inspection of a critical building — ENS + ISO 42001 + EU AI Act">
&lt;style>
.t-build{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.t-ens{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.t-42001{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:8}
.t-aia{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.t-evi{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.tl{font:600 13px sans-serif;fill:#222}
.ts{font:400 11px sans-serif;fill:#555}
.tn{font:italic 11px sans-serif;fill:#555}
.tar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mt3)}
.tcb{stroke:#7a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3}
&lt;/style>
&lt;defs>&lt;marker id="mt3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="280" y="20" width="260" height="80" class="t-build"/>
&lt;text x="410" y="44" text-anchor="middle" class="tl">On-premise AI system&lt;/text>
&lt;text x="410" y="62" text-anchor="middle" class="ts">LLM platform with pipeline, guardrails,&lt;/text>
&lt;text x="410" y="76" text-anchor="middle" class="ts">tracing, retrain, curated RAG, evals&lt;/text>
&lt;text x="410" y="92" text-anchor="middle" class="ts">(the critical building)&lt;/text>
&lt;rect x="20" y="160" width="220" height="80" class="t-ens"/>
&lt;text x="130" y="184" text-anchor="middle" class="tl">ENS inspector&lt;/text>
&lt;text x="130" y="202" text-anchor="middle" class="ts">RD 311/2022 · CCN-CERT&lt;/text>
&lt;text x="130" y="218" text-anchor="middle" class="ts">74 measures in 3 blocks&lt;/text>
&lt;text x="130" y="232" text-anchor="middle" class="ts">Basic/Medium/High categories&lt;/text>
&lt;rect x="300" y="160" width="220" height="80" class="t-42001"/>
&lt;text x="410" y="184" text-anchor="middle" class="tl">ISO/IEC 42001 inspector&lt;/text>
&lt;text x="410" y="202" text-anchor="middle" class="ts">7 clauses + 38 Annex A controls&lt;/text>
&lt;text x="410" y="218" text-anchor="middle" class="ts">Certification body&lt;/text>
&lt;text x="410" y="232" text-anchor="middle" class="ts">3-year cycle + annual surveillance&lt;/text>
&lt;rect x="580" y="160" width="220" height="80" class="t-aia"/>
&lt;text x="690" y="184" text-anchor="middle" class="tl">EU AI Act inspector&lt;/text>
&lt;text x="690" y="202" text-anchor="middle" class="ts">Reg. 2024/1689 — high risk&lt;/text>
&lt;text x="690" y="218" text-anchor="middle" class="ts">National surveillance authority&lt;/text>
&lt;text x="690" y="232" text-anchor="middle" class="ts">Annex IV dossier + CE marking&lt;/text>
&lt;path class="tar" d="M340,100 L150,158"/>
&lt;path class="tar" d="M410,100 L410,158"/>
&lt;path class="tar" d="M480,100 L670,158"/>
&lt;rect x="160" y="280" width="500" height="80" class="t-evi"/>
&lt;text x="410" y="304" text-anchor="middle" class="tl">One single set of technical evidence labelled for three lenses&lt;/text>
&lt;text x="410" y="322" text-anchor="middle" class="ts">OTel `gen_ai.*` spans + DVC + Vault Anonymize + Langfuse + retrain incidents&lt;/text>
&lt;text x="410" y="338" text-anchor="middle" class="ts">→ tag with ENS code (op.exp.8) + 42001 code (A.8.2) + AI Act article (Art. 12) in metadata&lt;/text>
&lt;path class="tcb" d="M130,240 L300,280"/>
&lt;path class="tcb" d="M410,240 L410,280"/>
&lt;path class="tcb" d="M690,240 L520,280"/>
&lt;text x="410" y="375" text-anchor="middle" class="tn">The building is one. The inspectors are three. Evidence is labelled so each of them finds their part in the same drawer.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A critical building, say the control centre of a regional electricity grid, a reference hospital, or a 112 emergency response centre, goes through three different inspections of the same physical elements:&lt;/p>
&lt;ul>
&lt;li>The regional inspection reviews the planning permit, local fire regulations and accessibility under regional legislation.&lt;/li>
&lt;li>The European inspection reviews CE marking of equipment and conformity with EU directives on energy efficiency.&lt;/li>
&lt;li>The ISO quality inspection reviews processes and maintenance under a management standard.&lt;/li>
&lt;/ul>
&lt;p>All three inspectors look at the same fire detector hanging from the ceiling. The regional one wants to see the installation certificate with the installer company&amp;rsquo;s number; the European one wants to see the CE mark stamped on the casing; the ISO one wants to see the monthly maintenance record with signatures. There is only one detector. The evidence each inspector needs is different. If the building management team kept one folder per inspector, the installation certificate, the photo of the CE mark and the maintenance sheet would live in three places, and on audit day inconsistencies between the three folders would surface. The professional way to operate: one single file per asset, with tags pointing at the three sets of legislation.&lt;/p>
&lt;p>The on-premise LLM system is that critical building. The OTel spans from &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing&lt;/a> are the fire detector: a single technical piece that satisfies ENS measure op.exp.8 (activity logging), 42001 control A.8.2 (information to interested parties), and EU AI Act article 12 (record-keeping) provided the spans carry the correct metadata: &lt;code>traceparent&lt;/code> propagated, &lt;code>gen_ai.*&lt;/code> semantic conventions, WORM retention, PII redacted by LLM Guard Vault. Without consistent labelling from capture onwards, audit day produces three folders that do not add up.&lt;/p>
&lt;p>The analogy matters because it bounds an architectural decision: labelling evidence is not a compliance topic, it is a technical design topic. It is decided when the OTel Collector is set up, when the Vault schema is designed, when the format of retrain incidents is agreed. Postpone it, and the debt compounds.&lt;/p>
&lt;h2 id="ens-in-15-seconds">ENS in 15 seconds&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Regulatory framework&lt;/strong>: Royal Decree 311/2022, of 3 May, in force since 5 May 2022. It replaces RD 3/2010. It implements the update of the Esquema with NIS2, the NCS (Estrategia Nacional de Ciberseguridad, Spain&amp;rsquo;s national cybersecurity strategy) and the post-2020 reality.&lt;/li>
&lt;li>&lt;strong>Mandatory scope&lt;/strong>: the Spanish public sector (central, regional and local administration, universities, autonomous bodies), public-sector entities with their own legal personality, and private suppliers that provide IT services to them (a very important clause in consultancy: if your client is the Junta de Andalucía, the Comunidad de Madrid or Bilbao City Council, you fall under ENS by contractual extension).&lt;/li>
&lt;li>&lt;strong>Operational applicability in 2026&lt;/strong>: any AI project financed with NextGenerationEU European funds, plus any project with public data, plus any integration with electronic administrative systems.&lt;/li>
&lt;li>&lt;strong>Categorisation&lt;/strong>: three categories, Basic / Medium / High, according to the assessment of the five dimensions (C, I, T, A, D). The category dictates which measures are required and at what depth.&lt;/li>
&lt;li>&lt;strong>Certification&lt;/strong>: for Medium and High categories, a formal audit by an accredited body. For Basic category, self-assessment. Certification cycle is biennial.&lt;/li>
&lt;li>&lt;strong>Authority&lt;/strong>: the Centro Criptológico Nacional (CCN-CERT, Spain&amp;rsquo;s national cryptologic centre), part of the CNI intelligence agency. It maintains the ens.ccn.cni.es portal with the STIC guides (800 series).&lt;/li>
&lt;li>&lt;strong>Total number of measures&lt;/strong>: 74, organised into three blocks, plus Annex II with the measure × category matrix that defines what applies at each level.&lt;/li>
&lt;/ul>
&lt;h2 id="the-five-security-dimensions-and-their-ai-mapping">The five security dimensions and their AI mapping&lt;/h2>
&lt;p>To classify the system as Basic / Medium / High, the ENS requires each dimension to be rated on a scale (not applicable / low / medium / high). The final category is the highest of the five. For LLM systems, the typical rating:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Typical LLM rating&lt;/th>
&lt;th>Reason&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>C&lt;/strong> Confidentiality&lt;/td>
&lt;td>Information protected from disclosure&lt;/td>
&lt;td>Medium-High&lt;/td>
&lt;td>PII in prompts, secrets in context, intellectual property in the RAG corpus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>I&lt;/strong> Integrity&lt;/td>
&lt;td>Information protected from modification&lt;/td>
&lt;td>Medium-High&lt;/td>
&lt;td>An altered RAG corpus produces false answers; a manipulated model produces directed bias&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>T&lt;/strong> Traceability&lt;/td>
&lt;td>Actions attributable to users&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Audit: who asked what, when, what were they answered, which dataset trained the model?&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>A&lt;/strong> Authenticity&lt;/td>
&lt;td>Identity of users and origin of information&lt;/td>
&lt;td>Medium&lt;/td>
&lt;td>Robust authentication plus identification of chunks by source&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>D&lt;/strong> Availability&lt;/td>
&lt;td>Service available when needed&lt;/td>
&lt;td>Medium&lt;/td>
&lt;td>Typical SLA, known recovery time&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Typical result&lt;/strong>: Medium category for internal LLM systems with no sensitive data, High if they handle PII or regulated sectoral data (health, tax, judicial). The category triggers different controls.&lt;/p>
&lt;h2 id="the-three-blocks-of-measures">The three blocks of measures&lt;/h2>
&lt;p>&lt;strong>Organisational framework (&lt;code>org&lt;/code>)&lt;/strong>, 4 measures that always apply, transversal:&lt;/p>
&lt;ul>
&lt;li>&lt;code>org.1&lt;/code> Security policy&lt;/li>
&lt;li>&lt;code>org.2&lt;/code> Security regulations&lt;/li>
&lt;li>&lt;code>org.3&lt;/code> Security procedures&lt;/li>
&lt;li>&lt;code>org.4&lt;/code> Authorisation process&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Operational framework (&lt;code>op&lt;/code>)&lt;/strong>, 31 measures in 6 subgroups, these are how it is operated:&lt;/p>
&lt;ul>
&lt;li>&lt;code>op.pl&lt;/code> Planning (5)&lt;/li>
&lt;li>&lt;code>op.acc&lt;/code> Access control (6)&lt;/li>
&lt;li>&lt;code>op.exp&lt;/code> Operation (10)&lt;/li>
&lt;li>&lt;code>op.ext&lt;/code> External services (4)&lt;/li>
&lt;li>&lt;code>op.cont&lt;/code> Service continuity (4)&lt;/li>
&lt;li>&lt;code>op.mon&lt;/code> System monitoring (2)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Protection measures (&lt;code>mp&lt;/code>)&lt;/strong>, 39 measures in 7 subgroups, these are what is protected:&lt;/p>
&lt;ul>
&lt;li>&lt;code>mp.if&lt;/code> Facility protection (7)&lt;/li>
&lt;li>&lt;code>mp.per&lt;/code> Personnel management (4)&lt;/li>
&lt;li>&lt;code>mp.eq&lt;/code> Equipment protection (4)&lt;/li>
&lt;li>&lt;code>mp.com&lt;/code> Communications protection (4)&lt;/li>
&lt;li>&lt;code>mp.si&lt;/code> Information media protection (5)&lt;/li>
&lt;li>&lt;code>mp.sw&lt;/code> Application protection (2)&lt;/li>
&lt;li>&lt;code>mp.info&lt;/code> Information protection (6)&lt;/li>
&lt;li>&lt;code>mp.s&lt;/code> Service protection (4)&lt;/li>
&lt;/ul>
&lt;p>Of those 74, roughly 25 matter operationally for LLM systems. The rest are either transversal (policies, personnel management, physical facilities) or specific to other layers (telephony, physical media in the literal sense). The rest of this post drops down to the 25 relevant ones.&lt;/p>
&lt;h2 id="technical-mapping-key-ens-measures--42001-controls--eu-ai-act-articles">Technical mapping: key ENS measures × 42001 controls × EU AI Act articles&lt;/h2>
&lt;p>What follows, by ENS subgroup, are the measures relevant to LLM with their cross-mapping. The Technical evidence from the blog column points to the operational artefact that materialises the measure.&lt;/p>
&lt;h3 id="operational-framework--planning-oppl">Operational framework — planning (&lt;code>op.pl&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>op.pl.1&lt;/code> Risk analysis&lt;/td>
&lt;td>Formal periodic analysis, MAGERIT or OCTAVE methodology&lt;/td>
&lt;td>A.5.4 (alignment with AI risk treatment)&lt;/td>
&lt;td>&lt;strong>Art. 9&lt;/strong> Risk management&lt;/td>
&lt;td>Risk document linked to the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> metrics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.pl.2&lt;/code> Security architecture&lt;/td>
&lt;td>Architecture documentation, layer segregation&lt;/td>
&lt;td>A.4.2 documented info&lt;/td>
&lt;td>Art. 15 (technical robustness)&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven stack layers&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.pl.3&lt;/code> Acquisition&lt;/td>
&lt;td>Acquisition criteria that include security&lt;/td>
&lt;td>A.10.3 suppliers&lt;/td>
&lt;td>Art. 53 (GPAI obligations)&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers&lt;/a> lock-in analysis + copyright analysis&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.pl.4&lt;/code> Sizing&lt;/td>
&lt;td>Capacity to support the expected load&lt;/td>
&lt;td>A.4.5 system resources&lt;/td>
&lt;td>Art. 15 (consistent performance)&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> + GPU capacity study&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.pl.5&lt;/code> Certified components&lt;/td>
&lt;td>Preference for certified components&lt;/td>
&lt;td>A.10.5 third parties&lt;/td>
&lt;td>Art. 53 + Art. 15&lt;/td>
&lt;td>OSS inventory with licence analysis + supply chain audit (cosign + SLSA)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="operational-framework--access-control-opacc">Operational framework — access control (&lt;code>op.acc&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>op.acc.1&lt;/code> Identification&lt;/td>
&lt;td>Unique identification of users and processes&lt;/td>
&lt;td>A.3.2 roles&lt;/td>
&lt;td>Art. 14 (human oversight)&lt;/td>
&lt;td>Keycloak / OIDC + JWT with unique &lt;code>sub&lt;/code> + &lt;code>user_id_hashed&lt;/code> in OTel spans&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.acc.2&lt;/code> Access requirements&lt;/td>
&lt;td>Need to know, authorisation&lt;/td>
&lt;td>A.9.4 intended use&lt;/td>
&lt;td>Art. 14 + Art. 26 (deployer)&lt;/td>
&lt;td>Per-tenant allowlist in the AI Gateway + RBAC over adapters&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.acc.3&lt;/code> Segregation of duties&lt;/td>
&lt;td>No incompatible accumulation&lt;/td>
&lt;td>A.3.2 + A.3.3&lt;/td>
&lt;td>Art. 17 (QMS)&lt;/td>
&lt;td>Separate roles: AI lead / data steward / SRE / DPO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.acc.5&lt;/code> Authentication&lt;/td>
&lt;td>Proportionate mechanism, MFA at High&lt;/td>
&lt;td>A.4.4 tooling&lt;/td>
&lt;td>Art. 14&lt;/td>
&lt;td>Keycloak + WebAuthn + mandatory MFA for High category&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.acc.6&lt;/code> Local access&lt;/td>
&lt;td>Protection against physical access&lt;/td>
&lt;td>mp.if + A.4.5&lt;/td>
&lt;td>Art. 15 (cybersec)&lt;/td>
&lt;td>Cluster in a datacenter with physical control (strictly outside the blog&amp;rsquo;s scope)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.acc.7&lt;/code> Remote access&lt;/td>
&lt;td>VPN, encryption, endpoint control&lt;/td>
&lt;td>A.4.5 + mp.com&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>WireGuard / Defguard + mTLS for administrative access to the cluster&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="operational-framework--operation-opexp">Operational framework — operation (&lt;code>op.exp&lt;/code>)&lt;/h3>
&lt;p>This is the densest subgroup and the one that overlaps most with the LLM architecture.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>op.exp.1&lt;/code> Asset inventory&lt;/td>
&lt;td>CMDB with all system components&lt;/td>
&lt;td>A.4 resources&lt;/td>
&lt;td>Art. 49 (EU DB registration)&lt;/td>
&lt;td>Inventory of models, adapters and datasets in a CMDB + Helm tags + OpenLineage lineage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.2&lt;/code> Security configuration&lt;/td>
&lt;td>Documented hardened configuration&lt;/td>
&lt;td>mp.eq + A.4&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>vLLM, KServe and Cilium configurations documented in GitOps + CIS Benchmarks for K8s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.3&lt;/code> Configuration management&lt;/td>
&lt;td>Traceable and authorised changes&lt;/td>
&lt;td>A.6.2 + A.4.2&lt;/td>
&lt;td>Art. 9 + Art. 15&lt;/td>
&lt;td>GitOps with Argo CD / Flux + mandatory PR review + immutable tags&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.5&lt;/code> Change management&lt;/td>
&lt;td>Changes planned, authorised, recorded&lt;/td>
&lt;td>A.6.2.6 + clause 8&lt;/td>
&lt;td>Art. 9 (lifecycle) + Art. 72 (post-market)&lt;/td>
&lt;td>CI/CD pipeline from the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps post&lt;/a> + change advisory board&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.6&lt;/code> Protection against malicious code&lt;/td>
&lt;td>Antivirus, EDR, malware control&lt;/td>
&lt;td>mp.eq&lt;/td>
&lt;td>Art. 15 (cybersec)&lt;/td>
&lt;td>Image scanning (Trivy / Grype) + container runtime security (Falco / Tetragon)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.7&lt;/code> Incident management&lt;/td>
&lt;td>Detection → response → recovery procedure&lt;/td>
&lt;td>A.3.3 + clause 10&lt;/td>
&lt;td>&lt;strong>Art. 73&lt;/strong> (serious incident reporting)&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Incident-driven retrain&lt;/a> + CCN-CERT channel + notification template&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>&lt;code>op.exp.8&lt;/code>&lt;/strong> &lt;strong>Activity logging&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Auditable logs, minimum 2-year retention at High category&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.8.2&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 12 + Art. 19&lt;/strong>&lt;/td>
&lt;td>&lt;strong>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel GenAI tracing&lt;/a> + Tempo / Jaeger + Loki&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.9&lt;/code> Incident management records&lt;/td>
&lt;td>Incident log with root cause and corrective action&lt;/td>
&lt;td>clause 10&lt;/td>
&lt;td>Art. 73&lt;/td>
&lt;td>Ticketing system + structured &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain incident events&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>&lt;code>op.exp.10&lt;/code>&lt;/strong> &lt;strong>Record protection&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Immutable logs, cryptographic integrity, guaranteed retention&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.8.2 + A.4.2&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 12&lt;/strong>&lt;/td>
&lt;td>&lt;strong>WORM storage (Ceph + immutable bucket) + log signing (sigstore) + 24-36 month retention&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.exp.11&lt;/code> Cryptographic keys&lt;/td>
&lt;td>Key lifecycle management, HSM at High category&lt;/td>
&lt;td>mp.info.4&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>HashiCorp Vault / SOPS + HSM (Yubico, AWS KMS on-prem) for High category&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="operational-framework--external-services-opext">Operational framework — external services (&lt;code>op.ext&lt;/code>)&lt;/h3>
&lt;p>Critical when the system integrates GPAI (Llama, Mistral) hosted elsewhere, or when the deployer is a third party.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>op.ext.1&lt;/code> Contracting external services&lt;/td>
&lt;td>Contract with security clauses, SLA, right to audit&lt;/td>
&lt;td>A.10.3 suppliers&lt;/td>
&lt;td>Art. 25 + Art. 53&lt;/td>
&lt;td>Contract with ENS clauses + annual audit review + Cloud Act analysis&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.ext.2&lt;/code> Alternative means&lt;/td>
&lt;td>Plan B if the supplier goes down&lt;/td>
&lt;td>A.4.5 + clause 6&lt;/td>
&lt;td>Art. 15 (resilience)&lt;/td>
&lt;td>Multi-cluster with failover + qualified alternative GPAI (Llama→Mistral→Qwen)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.ext.3&lt;/code> Supply chain protection&lt;/td>
&lt;td>Assessment of suppliers and sub-suppliers&lt;/td>
&lt;td>A.10 + clause 6&lt;/td>
&lt;td>Art. 53 + NIS2 supply chain&lt;/td>
&lt;td>SBOM + cosign + SLSA + continuous vulnerability scanning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.ext.4&lt;/code> System interconnection&lt;/td>
&lt;td>Interconnection agreements, secure gateways&lt;/td>
&lt;td>mp.com + A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>API Gateway with mTLS + JWT signing + rate limiting + WAF&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="operational-framework--continuity-opcont">Operational framework — continuity (&lt;code>op.cont&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>op.cont.1&lt;/code> Impact analysis&lt;/td>
&lt;td>BIA per system&lt;/td>
&lt;td>A.5.5 (impacts on individuals)&lt;/td>
&lt;td>Art. 9 (risk management)&lt;/td>
&lt;td>Documented BIA with RPO / RTO per system&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.cont.2&lt;/code> Continuity plan&lt;/td>
&lt;td>Documented DRP, RTO/RPO&lt;/td>
&lt;td>clause 6 + A.4&lt;/td>
&lt;td>Art. 15 (resilience)&lt;/td>
&lt;td>DRP + Velero K8s backups + DVC datasets in a secondary bucket&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.cont.3&lt;/code> Periodic testing&lt;/td>
&lt;td>Drills at a defined frequency (annual at High category)&lt;/td>
&lt;td>clause 9 (evaluation)&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Annual game-day with a simulated disaster + recovery stopwatches&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.cont.4&lt;/code> Alternative means&lt;/td>
&lt;td>Capacity to continue with degraded means&lt;/td>
&lt;td>clause 6 + A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Secondary cluster in a different datacenter + reserved GPU pool + replicated datasets&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="operational-framework--monitoring-opmon">Operational framework — monitoring (&lt;code>op.mon&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>&lt;code>op.mon.1&lt;/code>&lt;/strong> &lt;strong>Intrusion detection&lt;/strong>&lt;/td>
&lt;td>&lt;strong>IDS/IPS over network and applications&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.9.2 use&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 15 (cybersec)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails as a semantic WAF&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard PromptInjection&lt;/a> + Tetragon eBPF runtime&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>&lt;code>op.mon.2&lt;/code>&lt;/strong> &lt;strong>Metrics system&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Measurable operational metrics, auditable dashboard&lt;/strong>&lt;/td>
&lt;td>&lt;strong>clause 9 (performance evaluation)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 72 (post-market monitoring)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Prometheus + VictoriaMetrics + Grafana + &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Langfuse&lt;/a> dashboards&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>op.mon.3&lt;/code> Surveillance (High category)&lt;/td>
&lt;td>24×7 monitoring with alerting&lt;/td>
&lt;td>clause 9 + clause 10&lt;/td>
&lt;td>Art. 72 + Art. 73&lt;/td>
&lt;td>SOC with SIEM alerts (Wazuh, OpenSearch, Vector + custom) + on-call rotation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="protection-measures--communications-mpcom">Protection measures — communications (&lt;code>mp.com&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>mp.com.1&lt;/code> Secure perimeter&lt;/td>
&lt;td>Firewall, segmentation, DMZ&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Cilium NetworkPolicy + Calico + ingress controllers with WAF (mod_security)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.com.2&lt;/code> Confidentiality protection&lt;/td>
&lt;td>Encryption in transit (TLS 1.2+ mandatory, 1.3 recommended)&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>TLS 1.3 mandatory + cert-manager + Let&amp;rsquo;s Encrypt or an internal CA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.com.3&lt;/code> Integrity and authenticity protection&lt;/td>
&lt;td>Encryption + origin authentication&lt;/td>
&lt;td>A.4.5 + A.4.4&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Intra-cluster mTLS + JWT signing at the gateway + checksums on artifacts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.com.4&lt;/code> Flow separation&lt;/td>
&lt;td>Segmentation of mgmt vs production vs external traffic&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Cilium policies + network namespaces + east-west / north-south segregation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="protection-measures--applications-mpsw">Protection measures — applications (&lt;code>mp.sw&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>mp.sw.1&lt;/code> Application development&lt;/td>
&lt;td>Secure SDLC, code review, SAST/SCA&lt;/td>
&lt;td>A.6.2.3 responsible design&lt;/td>
&lt;td>Art. 9 + Art. 15&lt;/td>
&lt;td>Forgejo CI with SAST (Semgrep, CodeQL) + SCA (Trivy, Grype) + mandatory PR review&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.sw.2&lt;/code> Acceptance and go-live&lt;/td>
&lt;td>Acceptance tests, eval gates before production&lt;/td>
&lt;td>A.6.2.5 V&amp;amp;V&lt;/td>
&lt;td>Art. 9 + Art. 15&lt;/td>
&lt;td>Eval gates from the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals post&lt;/a> + canary deploy + pre-go-live metrics&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="protection-measures--information-mpinfo">Protection measures — information (&lt;code>mp.info&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>&lt;code>mp.info.1&lt;/code>&lt;/strong> &lt;strong>Personal data&lt;/strong>&lt;/td>
&lt;td>&lt;strong>GDPR compliance + technical and organisational measures&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.5.5 + A.7.6&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 10&lt;/strong> + Art. 26&lt;/td>
&lt;td>&lt;strong>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard Vault&lt;/a> + Presidio + minimisation in &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.info.2&lt;/code> Information classification&lt;/td>
&lt;td>Labelling by level (public / internal / confidential / restricted)&lt;/td>
&lt;td>A.7.2 data&lt;/td>
&lt;td>Art. 10&lt;/td>
&lt;td>Schema contracts from &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a> with a &lt;code>classification&lt;/code> field&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.info.3&lt;/code> Encryption&lt;/td>
&lt;td>At-rest minimum at Medium category, High category with HSM&lt;/td>
&lt;td>A.4.5 + mp.eq&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>LUKS / dm-crypt on disks + encryption in the Ceph bucket + keys in Vault&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.info.4&lt;/code> Electronic signature&lt;/td>
&lt;td>Documents signed with a valid certificate (Medium category and above)&lt;/td>
&lt;td>A.8.2&lt;/td>
&lt;td>Art. 12&lt;/td>
&lt;td>Log signing with sigstore + signing of published models with cosign&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.info.5&lt;/code> Timestamps&lt;/td>
&lt;td>Qualified timestamp for temporal integrity (High category)&lt;/td>
&lt;td>A.8.2&lt;/td>
&lt;td>Art. 12&lt;/td>
&lt;td>Timestamping with a qualified TSA + RFC 3161 on critical events&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.info.6&lt;/code> Document cleaning&lt;/td>
&lt;td>Removal of unauthorised metadata, anonymisation&lt;/td>
&lt;td>A.7.6 + A.5.5&lt;/td>
&lt;td>Art. 10&lt;/td>
&lt;td>LLM Guard Anonymize (input) + Sensitive (output) + Vault with TTL&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="protection-measures--services-mps">Protection measures — services (&lt;code>mp.s&lt;/code>)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ENS measure&lt;/th>
&lt;th>Requirement&lt;/th>
&lt;th>42001 control&lt;/th>
&lt;th>AI Act article&lt;/th>
&lt;th>Technical evidence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>mp.s.1&lt;/code> Email&lt;/td>
&lt;td>Anti-spam, anti-malware, optional encryption&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Outside the direct LLM scope&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.s.2&lt;/code> Protection of web services and applications&lt;/td>
&lt;td>WAF, hardening, vulnerability management&lt;/td>
&lt;td>A.9.2 + mp.com&lt;/td>
&lt;td>Art. 15 (cybersec)&lt;/td>
&lt;td>AI Gateway (LiteLLM / Envoy AI / Kong AI) with policies + ModSecurity + Cloudflare-like&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mp.s.3&lt;/code> Protection against denial of service&lt;/td>
&lt;td>Rate limiting, anti-DDoS, capacity planning&lt;/td>
&lt;td>A.4.5 + A.9.2&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Rate limiting at the gateway + token quotas + circuit breakers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>&lt;code>mp.s.4&lt;/code>&lt;/strong> &lt;strong>Protection against external threats (High category)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Advanced monitoring, threat intel&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.9.2 + clause 9&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 15 + Art. 72&lt;/strong>&lt;/td>
&lt;td>&lt;strong>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails, 4 lines&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">advanced LLM Guard scanners&lt;/a> + threat intel feed (CCN-CERT MISP)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="master-table-of-triple-compliance--the-25-relevant-controls-consolidated">Master table of triple compliance — the 25 relevant controls consolidated&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Triple compliance table ENS × 42001 × EU AI Act">
&lt;style>
.c-hdr{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.c-ens{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:6}
.c-42001{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:6}
.c-aia{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:6}
.c-evi{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:6}
.cl{font:600 11px sans-serif;fill:#222}
.cs{font:400 10px sans-serif;fill:#444}
.cn{font:italic 10px sans-serif;fill:#555}
&lt;/style>
&lt;rect x="20" y="20" width="780" height="40" class="c-hdr"/>
&lt;text x="410" y="42" text-anchor="middle" class="cl">Master table of triple compliance — ENS (yellow) · 42001 (green) · EU AI Act (orange) · Technical evidence (violet)&lt;/text>
&lt;text x="410" y="55" text-anchor="middle" class="cs">one row per technical capability — each cell gives the code of the framework it satisfies&lt;/text>
&lt;rect x="20" y="80" width="100" height="40" class="c-ens"/>&lt;text x="70" y="100" text-anchor="middle" class="cl">op.exp.8&lt;/text>&lt;text x="70" y="113" text-anchor="middle" class="cs">+ op.exp.10&lt;/text>
&lt;rect x="125" y="80" width="100" height="40" class="c-42001"/>&lt;text x="175" y="100" text-anchor="middle" class="cl">A.8.2&lt;/text>&lt;text x="175" y="113" text-anchor="middle" class="cs">A.4.2 (documentation)&lt;/text>
&lt;rect x="230" y="80" width="100" height="40" class="c-aia"/>&lt;text x="280" y="100" text-anchor="middle" class="cl">Art. 12 + Art. 19&lt;/text>&lt;text x="280" y="113" text-anchor="middle" class="cs">record-keeping&lt;/text>
&lt;rect x="335" y="80" width="465" height="40" class="c-evi"/>&lt;text x="567" y="100" text-anchor="middle" class="cl">OTel `gen_ai.*` spans + Tempo + WORM Ceph + 24-36m retention + sigstore&lt;/text>&lt;text x="567" y="113" text-anchor="middle" class="cs">trace_id propagated + prompt_id + dataset_hash + adapter_version + PII redacted&lt;/text>
&lt;rect x="20" y="125" width="100" height="40" class="c-ens"/>&lt;text x="70" y="145" text-anchor="middle" class="cl">op.mon.1&lt;/text>&lt;text x="70" y="158" text-anchor="middle" class="cs">+ mp.s.4&lt;/text>
&lt;rect x="125" y="125" width="100" height="40" class="c-42001"/>&lt;text x="175" y="145" text-anchor="middle" class="cl">A.9.2&lt;/text>&lt;text x="175" y="158" text-anchor="middle" class="cs">responsible use&lt;/text>
&lt;rect x="230" y="125" width="100" height="40" class="c-aia"/>&lt;text x="280" y="145" text-anchor="middle" class="cl">Art. 15&lt;/text>&lt;text x="280" y="158" text-anchor="middle" class="cs">cybersec + robustness&lt;/text>
&lt;rect x="335" y="125" width="465" height="40" class="c-evi"/>&lt;text x="567" y="145" text-anchor="middle" class="cl">Guardrails, 4 lines + LLM Guard PromptInjection + Tetragon eBPF + Llama Guard 4&lt;/text>&lt;text x="567" y="158" text-anchor="middle" class="cs">jailbreak + indirect injection + tool abuse + PII leakage in output detection&lt;/text>
&lt;rect x="20" y="170" width="100" height="40" class="c-ens"/>&lt;text x="70" y="190" text-anchor="middle" class="cl">op.mon.2&lt;/text>&lt;text x="70" y="203" text-anchor="middle" class="cs">+ op.mon.3&lt;/text>
&lt;rect x="125" y="170" width="100" height="40" class="c-42001"/>&lt;text x="175" y="190" text-anchor="middle" class="cl">clause 9&lt;/text>&lt;text x="175" y="203" text-anchor="middle" class="cs">performance eval&lt;/text>
&lt;rect x="230" y="170" width="100" height="40" class="c-aia"/>&lt;text x="280" y="190" text-anchor="middle" class="cl">Art. 72&lt;/text>&lt;text x="280" y="203" text-anchor="middle" class="cs">post-market monitoring&lt;/text>
&lt;rect x="335" y="170" width="465" height="40" class="c-evi"/>&lt;text x="567" y="190" text-anchor="middle" class="cl">Prometheus + VictoriaMetrics + Grafana + Langfuse dashboards + continuous evals&lt;/text>&lt;text x="567" y="203" text-anchor="middle" class="cs">F1 per guardrail category + RAG faithfulness + statistical drift + 24×7 cadence&lt;/text>
&lt;rect x="20" y="215" width="100" height="40" class="c-ens"/>&lt;text x="70" y="235" text-anchor="middle" class="cl">op.exp.7&lt;/text>&lt;text x="70" y="248" text-anchor="middle" class="cs">+ op.exp.9&lt;/text>
&lt;rect x="125" y="215" width="100" height="40" class="c-42001"/>&lt;text x="175" y="235" text-anchor="middle" class="cl">clause 10&lt;/text>&lt;text x="175" y="248" text-anchor="middle" class="cs">A.3.3 reporting&lt;/text>
&lt;rect x="230" y="215" width="100" height="40" class="c-aia"/>&lt;text x="280" y="235" text-anchor="middle" class="cl">Art. 73&lt;/text>&lt;text x="280" y="248" text-anchor="middle" class="cs">serious incidents&lt;/text>
&lt;rect x="335" y="215" width="465" height="40" class="c-evi"/>&lt;text x="567" y="235" text-anchor="middle" class="cl">Structured incident events + closed retrain loop + CCN-CERT notification&lt;/text>&lt;text x="567" y="248" text-anchor="middle" class="cs">15/10/2-day deadline + incident template + root cause + effectiveness check&lt;/text>
&lt;rect x="20" y="260" width="100" height="40" class="c-ens"/>&lt;text x="70" y="280" text-anchor="middle" class="cl">mp.info.1&lt;/text>&lt;text x="70" y="293" text-anchor="middle" class="cs">+ mp.info.6&lt;/text>
&lt;rect x="125" y="260" width="100" height="40" class="c-42001"/>&lt;text x="175" y="280" text-anchor="middle" class="cl">A.7.6&lt;/text>&lt;text x="175" y="293" text-anchor="middle" class="cs">data preparation&lt;/text>
&lt;rect x="230" y="260" width="100" height="40" class="c-aia"/>&lt;text x="280" y="280" text-anchor="middle" class="cl">Art. 10&lt;/text>&lt;text x="280" y="293" text-anchor="middle" class="cs">data governance&lt;/text>
&lt;rect x="335" y="260" width="465" height="40" class="c-evi"/>&lt;text x="567" y="280" text-anchor="middle" class="cl">LLM Guard Anonymize Vault + Presidio + RAG corpus curation, 5 layers&lt;/text>&lt;text x="567" y="293" text-anchor="middle" class="cs">runtime redaction + anti-contamination + chunk→trace lineage + measured PII F1&lt;/text>
&lt;rect x="20" y="305" width="100" height="40" class="c-ens"/>&lt;text x="70" y="325" text-anchor="middle" class="cl">op.pl.1&lt;/text>&lt;text x="70" y="338" text-anchor="middle" class="cs">risk analysis&lt;/text>
&lt;rect x="125" y="305" width="100" height="40" class="c-42001"/>&lt;text x="175" y="325" text-anchor="middle" class="cl">A.5.2-5.6&lt;/text>&lt;text x="175" y="338" text-anchor="middle" class="cs">impact assessment&lt;/text>
&lt;rect x="230" y="305" width="100" height="40" class="c-aia"/>&lt;text x="280" y="325" text-anchor="middle" class="cl">Art. 9&lt;/text>&lt;text x="280" y="338" text-anchor="middle" class="cs">risk management&lt;/text>
&lt;rect x="335" y="305" width="465" height="40" class="c-evi"/>&lt;text x="567" y="325" text-anchor="middle" class="cl">MAGERIT/OCTAVE risk doc + FRIA (if a public deployer applies) + fairness evals&lt;/text>&lt;text x="567" y="338" text-anchor="middle" class="cs">one methodology, three languages — the matrix is exported with three labels&lt;/text>
&lt;rect x="20" y="350" width="100" height="40" class="c-ens"/>&lt;text x="70" y="370" text-anchor="middle" class="cl">op.ext.1&lt;/text>&lt;text x="70" y="383" text-anchor="middle" class="cs">+ op.ext.3&lt;/text>
&lt;rect x="125" y="350" width="100" height="40" class="c-42001"/>&lt;text x="175" y="370" text-anchor="middle" class="cl">A.10.3&lt;/text>&lt;text x="175" y="383" text-anchor="middle" class="cs">suppliers&lt;/text>
&lt;rect x="230" y="350" width="100" height="40" class="c-aia"/>&lt;text x="280" y="370" text-anchor="middle" class="cl">Art. 53&lt;/text>&lt;text x="280" y="383" text-anchor="middle" class="cs">GPAI + NIS2 supply chain&lt;/text>
&lt;rect x="335" y="350" width="465" height="40" class="c-evi"/>&lt;text x="567" y="370" text-anchor="middle" class="cl">OSS vs hyperscalers lock-in analysis + SBOM + cosign + SLSA + Cloud Act analysis&lt;/text>&lt;text x="567" y="383" text-anchor="middle" class="cs">supplier register with licence + jurisdiction + plan B + annual audit&lt;/text>
&lt;rect x="20" y="395" width="100" height="40" class="c-ens"/>&lt;text x="70" y="415" text-anchor="middle" class="cl">mp.sw.1&lt;/text>&lt;text x="70" y="428" text-anchor="middle" class="cs">+ mp.sw.2&lt;/text>
&lt;rect x="125" y="395" width="100" height="40" class="c-42001"/>&lt;text x="175" y="415" text-anchor="middle" class="cl">A.6.2.3-2.5&lt;/text>&lt;text x="175" y="428" text-anchor="middle" class="cs">SDLC + V&amp;amp;V&lt;/text>
&lt;rect x="230" y="395" width="100" height="40" class="c-aia"/>&lt;text x="280" y="415" text-anchor="middle" class="cl">Arts. 9 + 15&lt;/text>&lt;text x="280" y="428" text-anchor="middle" class="cs">development + accuracy&lt;/text>
&lt;rect x="335" y="395" width="465" height="40" class="c-evi"/>&lt;text x="567" y="415" text-anchor="middle" class="cl">Forgejo CI + Semgrep SAST + Trivy SCA + eval gates + canary + pre-go-live metrics&lt;/text>&lt;text x="567" y="428" text-anchor="middle" class="cs">one CI/CD pipeline with triple labelling of the evidence artefact&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The key reading of the table: one row per technical capability. The organisation does not build three solutions for &amp;ldquo;activity log&amp;rdquo; (ENS) + &amp;ldquo;information to interested parties&amp;rdquo; (42001) + &amp;ldquo;record-keeping&amp;rdquo; (AI Act). It builds one single piece of OTel tracing with the correct metadata and presents it labelled for whichever inspector is asking.&lt;/p>
&lt;p>The labelling typically materialises in three mechanisms:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Tags in the CI/CD pipelines&lt;/strong>: every artefact produced carries tags &lt;code>ens:op.exp.8&lt;/code>, &lt;code>iso42001:A.8.2&lt;/code>, &lt;code>aia:art.12&lt;/code> in its Helm / Argo CD metadata.&lt;/li>
&lt;li>&lt;strong>Semantic OTel attributes&lt;/strong>: &lt;code>gen_ai.compliance.ens = &amp;quot;op.exp.8&amp;quot;&lt;/code>, &lt;code>gen_ai.compliance.iso42001 = &amp;quot;A.8.2&amp;quot;&lt;/code> as custom attributes on the relevant spans (not standard yet, but useful internally).&lt;/li>
&lt;li>&lt;strong>A mapping table in the wiki&lt;/strong>: a living document of ENS measure → 42001 control → AI Act article → technical runbook + owner + last verification. This is the artefact the auditor consults.&lt;/li>
&lt;/ol>
&lt;h2 id="applied-case-multi-tenant-chatbot-for-public-administration">Applied case: multi-tenant chatbot for public administration&lt;/h2>
&lt;p>A variant of the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">multi-tenant chatbot from the forensic post&lt;/a>, where the client is now a Spanish regional government offering citizen assistance on administrative procedures. The three lenses:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>ENS&lt;/strong>: applies mandatorily, being public sector plus a service to citizens. Category High (citizens&amp;rsquo; PII plus a critical service).&lt;/li>
&lt;li>&lt;strong>ISO 42001&lt;/strong>: the regional government requires certification from the supplier as a contractual condition.&lt;/li>
&lt;li>&lt;strong>EU AI Act&lt;/strong>: if the chatbot informs about procedures but decides nothing on the citizen&amp;rsquo;s behalf, it is limited risk (Art. 50 transparency). If it automates decisions (admission to a programme, refusal of a benefit), it is high risk.&lt;/li>
&lt;/ul>
&lt;p>We assume high risk for the most demanding walkthrough.&lt;/p>
&lt;h3 id="the-chatbots-25-key-technical-capabilities-consolidated">The chatbot&amp;rsquo;s 25 key technical capabilities consolidated&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Technical capability&lt;/th>
&lt;th>ENS&lt;/th>
&lt;th>42001&lt;/th>
&lt;th>AI Act&lt;/th>
&lt;th>Status&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Robust citizen authentication&lt;/td>
&lt;td>&lt;code>op.acc.5&lt;/code> (MFA High category)&lt;/td>
&lt;td>A.4.4&lt;/td>
&lt;td>Art. 14&lt;/td>
&lt;td>Cl@ve + digital certificate + WebAuthn&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unique tenant + user identification&lt;/td>
&lt;td>&lt;code>op.acc.1&lt;/code>&lt;/td>
&lt;td>A.3.2&lt;/td>
&lt;td>Art. 14 + 26&lt;/td>
&lt;td>JWT with &lt;code>tenant_id&lt;/code> + &lt;code>user_id_hashed&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Per-tenant allowlist&lt;/td>
&lt;td>&lt;code>op.acc.2&lt;/code>&lt;/td>
&lt;td>A.9.4&lt;/td>
&lt;td>Art. 14&lt;/td>
&lt;td>LiteLLM Proxy with policies + Envoy filter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Encryption in transit, mTLS&lt;/td>
&lt;td>&lt;code>mp.com.2-3&lt;/code>&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>TLS 1.3 + cert-manager + intra-mesh mTLS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Encryption at rest + HSM keys&lt;/td>
&lt;td>&lt;code>mp.info.3&lt;/code> + &lt;code>op.exp.11&lt;/code>&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>LUKS + Vault + Yubikey or nCipher HSM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PII anonymisation input/output&lt;/td>
&lt;td>&lt;code>mp.info.1 + .6&lt;/code>&lt;/td>
&lt;td>A.7.6&lt;/td>
&lt;td>Art. 10&lt;/td>
&lt;td>LLM Guard Vault + Presidio + Llama Guard 4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traceable logging + WORM&lt;/td>
&lt;td>&lt;strong>&lt;code>op.exp.8 + .10&lt;/code>&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.8.2&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Arts. 12 + 19&lt;/strong>&lt;/td>
&lt;td>OTel + Tempo + Loki + Ceph WORM 36 months&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Log signing + timestamps&lt;/td>
&lt;td>&lt;code>mp.info.4 + .5&lt;/code>&lt;/td>
&lt;td>A.8.2&lt;/td>
&lt;td>Art. 12&lt;/td>
&lt;td>sigstore + qualified TSA (FNMT)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runtime threat detection&lt;/td>
&lt;td>&lt;strong>&lt;code>op.mon.1 + mp.s.4&lt;/code>&lt;/strong>&lt;/td>
&lt;td>&lt;strong>A.9.2&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 15&lt;/strong>&lt;/td>
&lt;td>Guardrails, 4 lines + Tetragon + Falco&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Operational metrics dashboard&lt;/td>
&lt;td>&lt;strong>&lt;code>op.mon.2&lt;/code>&lt;/strong>&lt;/td>
&lt;td>&lt;strong>clause 9&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 72&lt;/strong>&lt;/td>
&lt;td>Prometheus + Grafana + Langfuse&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>24×7 SOC surveillance&lt;/td>
&lt;td>&lt;code>op.mon.3&lt;/code> (High category)&lt;/td>
&lt;td>clause 9&lt;/td>
&lt;td>Art. 72&lt;/td>
&lt;td>SOC with SIEM + on-call rotation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Incident management with notification&lt;/td>
&lt;td>&lt;strong>&lt;code>op.exp.7 + .9&lt;/code>&lt;/strong>&lt;/td>
&lt;td>&lt;strong>clause 10&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Art. 73&lt;/strong>&lt;/td>
&lt;td>Incident-driven retrain + CCN-CERT channel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CI/CD pipeline with eval gates&lt;/td>
&lt;td>&lt;code>mp.sw.1 + .2&lt;/code>&lt;/td>
&lt;td>A.6.2.3-5&lt;/td>
&lt;td>Arts. 9 + 15&lt;/td>
&lt;td>Forgejo CI + Semgrep + Trivy + DeepEval gates&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backups + DRP&lt;/td>
&lt;td>&lt;code>op.cont.1-4&lt;/code>&lt;/td>
&lt;td>clause 6 + A.4&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Velero + DVC datasets in a secondary bucket + annual game-day&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Systematic risk analysis&lt;/td>
&lt;td>&lt;code>op.pl.1&lt;/code>&lt;/td>
&lt;td>A.5 + cl.6&lt;/td>
&lt;td>Art. 9&lt;/td>
&lt;td>MAGERIT + FRIA + ISO/IEC 23894 impact assessment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Documented secure architecture&lt;/td>
&lt;td>&lt;code>op.pl.2&lt;/code>&lt;/td>
&lt;td>A.4.2&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven phases&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supplier management&lt;/td>
&lt;td>&lt;code>op.ext.1 + .3&lt;/code>&lt;/td>
&lt;td>A.10.3&lt;/td>
&lt;td>Art. 53&lt;/td>
&lt;td>Contracts with ENS clauses + SBOM + Cloud Act analysis per GPAI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Certified components&lt;/td>
&lt;td>&lt;code>op.pl.5&lt;/code>&lt;/td>
&lt;td>A.10.5&lt;/td>
&lt;td>Art. 53&lt;/td>
&lt;td>Inventory with licence + supply chain audit&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Configuration hardening&lt;/td>
&lt;td>&lt;code>op.exp.2 + .3&lt;/code>&lt;/td>
&lt;td>A.4 + A.6&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>CIS Benchmarks for K8s + GitOps + immutable tags&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Protection against malicious code&lt;/td>
&lt;td>&lt;code>op.exp.6&lt;/code>&lt;/td>
&lt;td>mp.eq&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Image scanning + runtime security&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Asset inventory&lt;/td>
&lt;td>&lt;code>op.exp.1&lt;/code>&lt;/td>
&lt;td>A.4&lt;/td>
&lt;td>Art. 49&lt;/td>
&lt;td>CMDB + Helm tags + OpenLineage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Network segmentation&lt;/td>
&lt;td>&lt;code>mp.com.4&lt;/code>&lt;/td>
&lt;td>A.4.5&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>Cilium NetworkPolicy + namespaces&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rate limiting + anti-DoS&lt;/td>
&lt;td>&lt;code>mp.s.3&lt;/code>&lt;/td>
&lt;td>A.4 + A.9&lt;/td>
&lt;td>Art. 15&lt;/td>
&lt;td>LiteLLM rate limit + token quotas&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Information classification&lt;/td>
&lt;td>&lt;code>mp.info.2&lt;/code>&lt;/td>
&lt;td>A.7.2&lt;/td>
&lt;td>Art. 10&lt;/td>
&lt;td>Schema contracts with a &lt;code>classification&lt;/code> field&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Transparency towards the user&lt;/td>
&lt;td>—&lt;/td>
&lt;td>A.9.4&lt;/td>
&lt;td>&lt;strong>Art. 50&lt;/strong>&lt;/td>
&lt;td>UI banner + disclaimer in responses&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Result&lt;/strong>: the 25 technical capabilities are common to all three frameworks. Only two require evidence for a single framework in isolation: &lt;code>mp.info.4-5&lt;/code> (qualified signature and timestamp, ENS-specific, not required so literally by 42001 or the AI Act) and &lt;code>Art. 50&lt;/code> (transparency banner, AI Act-specific). The rest are the same technical piece with three labels. A well-organised team certifies all three in series with only marginal extra work between the second and the third.&lt;/p>
&lt;h2 id="the-five-traps-of-triple-compliance">The five traps of triple compliance&lt;/h2>
&lt;p>&lt;strong>Trap 1 — Measuring the same thing three times.&lt;/strong> Inexperienced teams create three separate dashboards (one for ENS, another for 42001, another for the AI Act) with the same metrics duplicated. The result: three sources of truth that diverge, three audit teams with different figures, three corrections to resolve one and the same deviation. The rule: one metric, three labels.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Losing the control that covers only one framework.&lt;/strong> &lt;code>mp.info.4&lt;/code> (electronic signature) is ENS-specific and gets forgotten while the team is focused on 42001 + AI Act. On ENS audit day, the gap surfaces. Solution: the master table keeps every control visible, orphans included.&lt;/p>
&lt;p>&lt;strong>Trap 3 — Bias towards the most recent framework.&lt;/strong> The team spends 80% of the effort on the AI Act because it is the newest, and forgets the rigour of the ENS, which has been in force for 14 years. The ENS measures are technically more prescriptive than the AI Act (which is legally stricter but leaves implementation freedom). Moving up a level to ENS High category introduces specific requirements (HSM, qualified timestamps, 24×7 surveillance) that the AI Act does not spell out. The ENS deserves respect for its technical detail, not for its legal stature.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Mixing ENS Medium and High categories.&lt;/strong> The Annex II matrix of RD 311/2022 dictates which measures are required and at what depth per category. Moving from Medium to High changes 15-20 controls, which is not marginal. The category is decided at the start of the project and documented; changing it halfway forces a complete re-audit.&lt;/p>
&lt;p>&lt;strong>Trap 5 — Audit fatigue inside the technical team.&lt;/strong> Three audits a year (biennial ENS + annual 42001 surveillance + ad-hoc AI Act by the authority) exhausts the team if they are not planned and if evidence is not reused. The professional approach: one single quarterly internal audit cycle with rotating scope, producing evidence consumable by all three external auditors. The difference between 30 days a year and 90 days a year lost to audits is the discipline of single evidence plus disciplined labelling.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The CCN-CERT STIC series&lt;/strong> applicable to AI systems: STIC 800-159 (web service operation), STIC 800-105 (cryptography), STIC 800-150 (cloud environments). Each adds technical detail on how to materialise the ENS measures.&lt;/li>
&lt;li>&lt;strong>The GDPR as a fourth lens&lt;/strong>: privacy and personal data protection. It overlaps with &lt;code>mp.info.1&lt;/code> and with A.7.6 of 42001 plus Art. 10 of the AI Act. Material for an analogous pass over LOPDGDD + GDPR + AEPD against the three frameworks seen here.&lt;/li>
&lt;li>&lt;strong>Concrete templates&lt;/strong> of technical evidence with minimum fields: a log entry with every attribute required by the three lenses, an incident report with every field demanded by the three frameworks, a unified ENS / AI Act / 42001 declaration of conformity.&lt;/li>
&lt;li>&lt;strong>The NextGenerationEU funds case&lt;/strong>: specific compliance requirements for AI projects financed with European funds, where the AI Act + ENS are contractually mandatory.&lt;/li>
&lt;li>&lt;strong>42001 + ENS High category combined with DORA&lt;/strong> (Digital Operational Resilience Act, Reg. 2022/2554) for Spanish financial entities deploying AI.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>RD 311/2022&lt;/strong> — &lt;em>por el que se regula el Esquema Nacional de Seguridad&lt;/em>. &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;li>&lt;strong>CCN-CERT — Portal ENS&lt;/strong>: &lt;a href="https://ens.ccn.cni.es/">https://ens.ccn.cni.es/&lt;/a>. Guías STIC Series 800.&lt;/li>
&lt;li>&lt;strong>CCN-STIC 803&lt;/strong> — &lt;em>Valoración de los sistemas y de la información&lt;/em>. Metodología para asignar categoría ENS.&lt;/li>
&lt;li>&lt;strong>CCN-STIC 804&lt;/strong> — &lt;em>Esquema Nacional de Seguridad. Guía de implantación&lt;/em>.&lt;/li>
&lt;li>&lt;strong>CCN-STIC 824&lt;/strong> — &lt;em>Informe del estado de seguridad&lt;/em>. Plantilla para auditoría ENS.&lt;/li>
&lt;li>&lt;strong>MAGERIT v3&lt;/strong> — Metodología de Análisis y Gestión de Riesgos del Ministerio de Asuntos Económicos. Insumo de &lt;code>op.pl.1&lt;/code>.&lt;/li>
&lt;li>&lt;strong>ISO/IEC 42001:2023&lt;/strong> — Sistema de gestión IA. Norma certificable que solapa con ENS.&lt;/li>
&lt;li>&lt;strong>Regulation (EU) 2024/1689 (EU AI Act)&lt;/strong> — texto consolidado.&lt;/li>
&lt;li>&lt;strong>NIS2 (Dir. 2022/2555)&lt;/strong> — directiva de ciberseguridad que el ENS implementa parcialmente en su versión 2022.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the technical implementation of many of these controls: network, secrets, supply chain.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for the AI system&lt;/a> — the first post of the governance trilogy, the certifiable management system.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical dossier, article by article&lt;/a> — the second post, the directly applicable EU legal regulation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the reference operational architecture that supports all three frameworks.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the canonical piece that materialises &lt;code>op.exp.8 + .10&lt;/code> ENS + A.8.2 ISO 42001 + Arts. 12 + 19 AI Act simultaneously.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the piece that materialises &lt;code>op.mon.1 + mp.s.4&lt;/code> ENS + A.9.2 ISO 42001 + Art. 15 AI Act.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: the sworn translator with a book of equivalences&lt;/a> — the piece that materialises &lt;code>mp.info.1 + .6&lt;/code> ENS + A.7.6 ISO 42001 + Art. 10 AI Act.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> — the pieces that materialise &lt;code>mp.info.1-2&lt;/code> ENS + A.7 ISO 42001 + Art. 10 AI Act.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — the piece that materialises &lt;code>op.exp.7 + .9&lt;/code> ENS + clause 10 ISO 42001 + Art. 73 AI Act.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the piece that materialises &lt;code>mp.sw.2&lt;/code> ENS + A.6.2.5 ISO 42001 + Art. 15 AI Act.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the stack&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a> — the direct material for &lt;code>op.pl.2&lt;/code> architecture.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a> — input for &lt;code>op.ext.1 + .3&lt;/code> supplier analysis + Art. 53 GPAI.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps&lt;/a> — inventory of components with licences for &lt;code>op.exp.1&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic case walked through with the triple lens in this post.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLMs with Keep + Kafka&lt;/a> — the concrete piece that materialises &lt;code>op.exp.7-10&lt;/code> ENS + A.8.2 ISO 42001 + Art. 73 EU AI Act simultaneously: declarative Keep workflows + WORM Kafka &lt;code>audit.actions&lt;/code> + NIS2 deadlines of 24h/72h/1 month.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from the workstation to the cluster&lt;/a> and its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">operational runbook&lt;/a> — runtime isolation (bubblewrap on the client, Tetragon in the cluster) as a materialisation of &lt;code>op.mon&lt;/code> (monitoring) and &lt;code>op.exp&lt;/code> (activity logging): Tetragon&amp;rsquo;s eBPF events are technical evidence of what each agent executed and of every blocked access attempt.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">Model chain of trust (3/4): signing, provenance and AIBOM&lt;/a> — the technical evidence of model provenance (signature, attestations and AIBOM) that feeds the technical documentation of Art. 11 + Annex IV of the AI Act and the traceability controls of ISO 42001.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">Model chain of trust (4/4): workload identity and isolation&lt;/a> — SPIFFE/SPIRE and confidential computing as a materialisation of &lt;code>op.acc&lt;/code> and &lt;code>mp.com&lt;/code> in the ENS and of Art. 15 on robustness and cybersecurity.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a> — the concrete implementation of access control and administrative traceability at the gateway, with what it demonstrates to an auditor and what it does not.&lt;/p>
&lt;/li>
&lt;/ul></description></item><item><title>ISO/IEC 42001: the operations manual for the AI system — how the AIMS fits over the on-premise LLM platform described in this blog</title><link>https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/</link><pubDate>Mon, 01 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/</guid><description>&lt;blockquote>
&lt;p>This post closes an asymmetry the blog had been accumulating: we have described in detail the technical platform (&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven stack layers&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">five maturity levels&lt;/a>), the operational pipeline (&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six LLMOps stages&lt;/a>), the data pieces (&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">corpus curation&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">versioning&lt;/a>), the eval / safety pieces (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>) and the observe pieces (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel GenAI tracing&lt;/a>). What had not appeared is the governance layer a regulated client asks for on top of all that. ISO/IEC 42001 is that layer.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>ISO/IEC 42001:2023 is the first certifiable international standard that defines how an AI system is managed. It is not a technical standard (it does not say &amp;ldquo;use this inference engine&amp;rdquo; or &amp;ldquo;this safety threshold&amp;rdquo;): it is a management standard, a cousin of ISO 27001 and ISO 9001. From both it inherits the Annex SL structure, seven mandatory clauses running through context, leadership, planning, support, operation, performance evaluation and improvement, and it adds an Annex A with 38 AI-specific controls in 9 sections: policies, internal organisation, resources, impact assessment, lifecycle, data, information to interested parties, use, third parties. The thesis of this post is that the technical architecture described in this blog directly covers between 60% and 80% of the A controls with no extra work. The LLMOps pipeline materialises A.6, versioning and curation materialise A.7, guardrails and evals materialise A.9, OTel tracing materialises A.8. The rest is governance discipline that does not appear in the code (a written AI policy, impact assessments per system, a stakeholder register, role decisions between provider/producer/customer, mandatory documentation), and that is precisely what separates a real certification from performative compliance. The post maps the correspondence control by control, cross-references it with the EU AI Act (seven articles directly aligned with 42001: 9, 10, 11, 12, 13, 14, 17), with NIS2 (asset register, incident notification, supply chain) and with ENS (Esquema Nacional de Seguridad, Spain&amp;rsquo;s national security framework, RD 311/2022, Basic/Medium/High categories), lists the seven mandatory minimum documents an auditor asks for, presents the blog&amp;rsquo;s multi-tenant chatbot as a live 42001 checklist, and closes with the five usual traps (confusing 42001 with EU AI Act compliance, over-documenting without measuring, ignoring A.5 until the audit, assuming 27001 covers the AI part, thinking certification is a one-off project rather than a living system).&lt;/p>
&lt;h2 id="the-analogy-the-aircraft-operations-manual">The analogy: the aircraft operations manual&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ISO 42001 as an aircraft operations manual audited by EASA">
&lt;style>
.i-air{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.i-man{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.i-aud{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:8}
.i-ops{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.il{font:600 13px sans-serif;fill:#222}
.is{font:400 11px sans-serif;fill:#555}
.in{font:italic 11px sans-serif;fill:#555}
.iar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mi1)}
&lt;/style>
&lt;defs>&lt;marker id="mi1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="20" y="20" width="160" height="60" class="i-air"/>
&lt;text x="100" y="44" text-anchor="middle" class="il">AI system&lt;/text>
&lt;text x="100" y="62" text-anchor="middle" class="is">on-premise LLM platform&lt;/text>
&lt;text x="100" y="76" text-anchor="middle" class="is">(the aircraft)&lt;/text>
&lt;rect x="220" y="20" width="180" height="60" class="i-ops"/>
&lt;text x="310" y="40" text-anchor="middle" class="il">Technical operations&lt;/text>
&lt;text x="310" y="58" text-anchor="middle" class="is">LLMOps pipeline + guardrails +&lt;/text>
&lt;text x="310" y="72" text-anchor="middle" class="is">tracing + retrain (the flights)&lt;/text>
&lt;rect x="440" y="20" width="180" height="60" class="i-man"/>
&lt;text x="530" y="40" text-anchor="middle" class="il">Operations manual&lt;/text>
&lt;text x="530" y="58" text-anchor="middle" class="is">policies + impact assessment +&lt;/text>
&lt;text x="530" y="72" text-anchor="middle" class="is">roles + lineage (ISO 42001)&lt;/text>
&lt;rect x="660" y="20" width="140" height="60" class="i-aud"/>
&lt;text x="730" y="44" text-anchor="middle" class="il">Auditor&lt;/text>
&lt;text x="730" y="62" text-anchor="middle" class="is">certification body&lt;/text>
&lt;text x="730" y="76" text-anchor="middle" class="is">(EASA / Aenor / BSI)&lt;/text>
&lt;path class="iar" d="M180,50 L220,50"/>
&lt;path class="iar" d="M400,50 L440,50"/>
&lt;path class="iar" d="M620,50 L660,50"/>
&lt;rect x="20" y="130" width="780" height="80" class="i-man"/>
&lt;text x="410" y="152" text-anchor="middle" class="il">The 7 Annex SL clauses — the manual's mandatory table of contents&lt;/text>
&lt;text x="410" y="172" text-anchor="middle" class="is">4 Context · 5 Leadership · 6 Planning · 7 Support · 8 Operation · 9 Evaluation · 10 Improvement&lt;/text>
&lt;text x="410" y="190" text-anchor="middle" class="is">Inherited from Annex SL — the same skeleton as ISO 27001 and 9001, which allows management systems to be integrated&lt;/text>
&lt;rect x="20" y="230" width="780" height="80" class="i-aud"/>
&lt;text x="410" y="252" text-anchor="middle" class="il">The 38 Annex A controls — the manual's AI-specific procedures&lt;/text>
&lt;text x="410" y="272" text-anchor="middle" class="is">A.2 Policies · A.3 Org · A.4 Resources · A.5 Impact · A.6 Lifecycle · A.7 Data · A.8 Info to parties · A.9 Use · A.10 Third parties&lt;/text>
&lt;text x="410" y="290" text-anchor="middle" class="is">What separates 42001 from 27001/9001: every control is born from an AI-specific risk (bias, opacity, drift, data supply)&lt;/text>
&lt;text x="410" y="340" text-anchor="middle" class="in">The aircraft flies with its pilots; the manual is audited by the authority. If the manual is incomplete, the aircraft does not certify however well it flies.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A modern aircraft, an A350, a Boeing 787, a drone certified for urban delivery, does not fly because it has good engines. It flies because the organisation that operates it has an Operations Manual approved by the aviation authority (EASA in Europe, the FAA in the US, AESA in Spain as a delegated body). The manual does not contain the engine blueprints, which the manufacturer certifies; it contains the procedures: who is captain on each flight, which checklist is run before each take-off, which periodic inspections happen at 100, 500 and 2,000 flight hours, which external suppliers are authorised to touch which components, what is documented after each incident, what to do when a new alert appears on the panel. The authority does not sit in on every flight: it reads the manual, randomly audits the traceability of past flights against the manual, and if everything adds up, it keeps the certification in place.&lt;/p>
&lt;p>An AI system in production, the multi-tenant chatbot from the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a>, a copilot for lawyers, a credit scoring system, is exactly the same. It flies because the model is good, the LLMOps pipeline is well built, the guardrails catch the bad cases. But it certifies because the organisation operating it has an AIMS (AI Management System) described in an auditable manual. ISO/IEC 42001 is that manual: its mandatory table of contents (Annex SL, seven clauses) and its catalogue of AI-specific controls (Annex A, 38 controls). The auditor does not sit next to the MLOps engineer: they read the AI policy, review the impact assessments of the most recently deployed systems, check that incident retrain is documented, verify third-party contracts, audit a sample of Langfuse traces cross-checked against &lt;code>dataset_hash&lt;/code> and &lt;code>prompt_id&lt;/code>. And if everything adds up, they certify.&lt;/p>
&lt;p>The analogy matters because it frames the right question: 42001 does not certify the model or the code. It certifies the way of operating the complete system. A team can have the best OSS stack in the world and fail the audit because it has no written AI policy and no documented decision about which role (provider vs producer vs customer) it occupies towards its clients. And the reverse: a team with a modest model but with operations-manual discipline can certify without acrobatics.&lt;/p>
&lt;h2 id="isoiec-42001-in-15-seconds">ISO/IEC 42001 in 15 seconds&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Publication&lt;/strong>: December 2023, ISO/IEC JTC 1/SC 42 (the ISO/IEC subcommittee for AI).&lt;/li>
&lt;li>&lt;strong>Status in 2026&lt;/strong>: an active standard, certifiable by accredited bodies (BSI, AENOR, TÜV, Bureau Veritas, A-LIGN, Schellman). Not yet formally recognised as a harmonised standard under the EU AI Act, but it provides the management base to lean on.&lt;/li>
&lt;li>&lt;strong>Compatibility&lt;/strong>: it shares the Annex SL structure with ISO 9001 (quality), 27001 (information security), 27701 (privacy), 22301 (continuity), 20000-1 (IT services). Organisations with integrated management systems (IMS) add it with 20-40% of the effort it would take to implement from scratch.&lt;/li>
&lt;li>&lt;strong>Applicability&lt;/strong>: any organisation that develops, provides, deploys or uses AI systems. It is not limited to developers: a company that consumes a hosted LLM and integrates it into its own product is within scope.&lt;/li>
&lt;li>&lt;strong>Certification&lt;/strong>: a 3-year cycle with an initial audit (Stage 1: documentation review + Stage 2: on-site audit) and annual surveillance audits. Typical cost: 15,000-60,000 € for the initial one depending on size; 6,000-20,000 € per annual surveillance.&lt;/li>
&lt;/ul>
&lt;p>What 42001 does not do:&lt;/p>
&lt;ul>
&lt;li>It does not say which models to use or which thresholds to apply.&lt;/li>
&lt;li>It does not certify the individual model (that is done by specific evaluations such as a NIST AI RMF profile or EU AI Act technical documentation).&lt;/li>
&lt;li>It does not replace the EU AI Act or the GDPR: it is complementary. Implementing it well makes legal compliance easier but does not guarantee it.&lt;/li>
&lt;li>It is not a technical standard for explainability or robustness (those are ISO/IEC 25059, 24029, 23894 and others in the SC 42 family).&lt;/li>
&lt;/ul>
&lt;h2 id="distinction-from-neighbouring-frameworks">Distinction from neighbouring frameworks&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Framework&lt;/th>
&lt;th>Nature&lt;/th>
&lt;th>Scope&lt;/th>
&lt;th>Certifiable&lt;/th>
&lt;th>Overlap with 42001&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>ISO/IEC 42001:2023&lt;/strong>&lt;/td>
&lt;td>Management standard&lt;/td>
&lt;td>AIMS for any AI system&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>EU AI Act&lt;/strong> (Reg. 2024/1689)&lt;/td>
&lt;td>Binding legal regulation&lt;/td>
&lt;td>AI systems in the EU, risk-categorised&lt;/td>
&lt;td>No (it is law)&lt;/td>
&lt;td>Arts 9, 10, 11, 12, 13, 14, 17&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NIS2&lt;/strong> (Dir. 2022/2555)&lt;/td>
&lt;td>Cybersecurity directive&lt;/td>
&lt;td>Essential/important entities&lt;/td>
&lt;td>Via the national scheme&lt;/td>
&lt;td>Asset register, incident, supply chain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ENS&lt;/strong> (RD 311/2022)&lt;/td>
&lt;td>Spanish security regulation&lt;/td>
&lt;td>Public sector and its suppliers&lt;/td>
&lt;td>Yes (B/M/A categories)&lt;/td>
&lt;td>Traceability, incident management&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ISO/IEC 27001&lt;/strong>&lt;/td>
&lt;td>Management standard&lt;/td>
&lt;td>Information security&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Annex SL structure + Annex A overlap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ISO/IEC 27701&lt;/strong>&lt;/td>
&lt;td>Management standard&lt;/td>
&lt;td>Privacy (extends 27001)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>PII in training data&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NIST AI RMF 1.0&lt;/strong>&lt;/td>
&lt;td>Voluntary framework&lt;/td>
&lt;td>AI risk management&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Conceptually aligned, not identical&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ISO/IEC 23894&lt;/strong>&lt;/td>
&lt;td>Technical standard&lt;/td>
&lt;td>AI risk management&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Input to A.5 (impact assessment)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ISO/IEC 5259&lt;/strong>&lt;/td>
&lt;td>Family&lt;/td>
&lt;td>Data quality for AI&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Input to A.7 (data)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Three distinctions that matter operationally&lt;/strong> and are a recurring source of confusion with clients:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>ISO 42001 ≠ EU AI Act compliance&lt;/strong>. Holding the 42001 certification makes it easier to demonstrate articles 9-17 of the European Regulation, but the Regulation requires things 42001 does not cover directly (CE marking of high-risk systems, registration in the European database, declaration of conformity, specific post-market monitoring). Implementing 42001 first and then filling the AI Act gaps is the standard route.&lt;/li>
&lt;li>&lt;strong>ISO 27001 is not enough&lt;/strong>. 27001 covers confidentiality, integrity and availability of information. The AI side is missing: bias, opacity, model drift, training corpus quality, human evaluation, impact on affected persons. 42001 is a complement, not a substitute. Organisations that already have 27001 in place have an advantage because they share half the documentation.&lt;/li>
&lt;li>&lt;strong>NIS2 ≠ AI safety&lt;/strong>. NIS2 requires registering critical assets, notifying incidents within 24 h, and managing the digital supply chain. AI systems can be within NIS2 scope if they form part of the critical asset (an LLM serving customer support in a financial entity is), but NIS2 does not audit model quality. 42001 does.&lt;/li>
&lt;/ol>
&lt;h2 id="the-seven-clauses-annex-sl-the-mandatory-table-of-contents">The seven clauses (Annex SL): the mandatory table of contents&lt;/h2>
&lt;p>The seven clauses, from clause 4 to clause 10, are common to all modern management standards (Annex SL, also called the &amp;ldquo;High Level Structure&amp;rdquo;). That means an organisation with ISO 9001 or 27001 already in place recognises the structure. Clauses 1-3 are introductory (scope, normative references, terms).&lt;/p>
&lt;h3 id="clause-4--context-of-the-organisation">Clause 4 — Context of the organisation&lt;/h3>
&lt;p>Identify the external context (applicable regulation, customer expectations, social risks) and the internal context (strategy, culture, capabilities). Identify the interested parties and their expectations: customers, regulators, affected persons, employees, suppliers. Define the scope of the AIMS: which AI systems are in and which are out.&lt;/p>
&lt;p>The usual gap: organisations that say &amp;ldquo;all our AI systems are in scope&amp;rdquo; without having enumerated them. The auditor asks for the list. Without a list, there is no scope.&lt;/p>
&lt;h3 id="clause-5--leadership">Clause 5 — Leadership&lt;/h3>
&lt;p>Top management must approve and publish an AI policy, assign roles and responsibilities (typically AI lead, AI risk owner, data officer), and demonstrate commitment through resources, communication and oversight. The policy is an auditable document and must be made available to staff and interested parties.&lt;/p>
&lt;p>The usual gap: a generic AI policy copied off the internet, with no measurables and no concrete objectives. The auditor asks how compliance with it is measured. Without metrics, the policy is theatre.&lt;/p>
&lt;h3 id="clause-6--planning">Clause 6 — Planning&lt;/h3>
&lt;p>Identify risks and opportunities of the AIMS (not of the individual model). Define measurable AI objectives, with deadlines and owners. Plan changes to the AIMS.&lt;/p>
&lt;p>The usual gap: confusing AIMS risks (what happens if we do not document correctly?) with model risks (what happens if the model is biased?). The first belongs here; the second goes to A.5.&lt;/p>
&lt;h3 id="clause-7--support">Clause 7 — Support&lt;/h3>
&lt;p>&lt;strong>Resources&lt;/strong>: human, technical, financial, infrastructure. Competence of personnel (documented training). Awareness of the policy among personnel. Internal and external communication. Documented information (the backbone of the management system: policy, procedures, records, evidence).&lt;/p>
&lt;p>The usual gap: documentation scattered across Confluence/Notion/Drive with no version control and no recorded approvals. The auditor asks about the last change: who approved it? when? on what grounds?&lt;/p>
&lt;h3 id="clause-8--operation">Clause 8 — Operation&lt;/h3>
&lt;p>The most operational clause. It requires:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Operational planning and control&lt;/strong>: how the AI system&amp;rsquo;s lifecycle is managed day to day. → Covered in the blog by the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Impact assessment&lt;/strong> (linked to A.5).&lt;/li>
&lt;li>&lt;strong>AI system lifecycle management&lt;/strong> (linked to A.6).&lt;/li>
&lt;li>&lt;strong>Data for AI systems&lt;/strong> (linked to A.7).&lt;/li>
&lt;/ul>
&lt;p>This is the clause that materialises in controls A.5, A.6 and A.7. On its own it adds no new requirements: it links to Annex A.&lt;/p>
&lt;h3 id="clause-9--performance-evaluation">Clause 9 — Performance evaluation&lt;/h3>
&lt;p>&lt;strong>Monitoring, measurement, analysis, evaluation&lt;/strong>. Internal audits (planned, with criteria, scope, frequency, recorded results). Management review (typically quarterly or half-yearly, with a mandatory agenda: inputs, evidence, decisions, actions).&lt;/p>
&lt;p>The usual gap: there is OTel tracing + Langfuse + Grafana and plenty of data, but there is no formal management review agenda with documented minutes. The auditor asks for the minutes. Without minutes, there is no review.&lt;/p>
&lt;h3 id="clause-10--improvement">Clause 10 — Improvement&lt;/h3>
&lt;p>&lt;strong>Nonconformity and corrective action&lt;/strong>: when something fails, it is recorded, root cause is analysed, a correction is agreed, effectiveness is verified. Continual improvement: the system evolves deliberately.&lt;/p>
&lt;p>The usual gap: Jira tickets with technical post-mortems but no formal &amp;ldquo;ISO nonconformity&amp;rdquo; record closed out with an effectiveness check. They are two distinct artefacts, even if they can be integrated.&lt;/p>
&lt;h2 id="the-38-annex-a-controls-the-ai-specific-catalogue">The 38 Annex A controls: the AI-specific catalogue&lt;/h2>
&lt;p>Unlike Annex SL (which is common), Annex A is 42001&amp;rsquo;s AI-specific signature. The 38 controls are organised into 9 sections (A.2 to A.10; A.1 is the introduction) covering the AI-specific risks: opacity, bias, drift, corpus quality, impact on affected persons, third-party dependency. Each control has an objective (what it aims to achieve) and implementation guidance in Annex B.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Section&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th># controls&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>A.2&lt;/td>
&lt;td>AI-related policies&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.3&lt;/td>
&lt;td>Internal organisation&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.4&lt;/td>
&lt;td>Resources for AI systems&lt;/td>
&lt;td>6&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.5&lt;/td>
&lt;td>Impact assessment&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.6&lt;/td>
&lt;td>AI system lifecycle&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.7&lt;/td>
&lt;td>Data for AI systems&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.8&lt;/td>
&lt;td>Information for interested parties&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.9&lt;/td>
&lt;td>Use of AI systems&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A.10&lt;/td>
&lt;td>Third parties and customer relationships&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&lt;strong>38&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>What follows is the section-by-section mapping of the controls onto material we have already covered in the blog. The editorial intent is to show which gaps remain once the technical architecture is in place, so that the road to certification does not start from zero.&lt;/p>
&lt;h2 id="cross-mapping-38-controls--blog-posts">Cross-mapping: 38 controls ↔ blog posts&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Mapping of ISO 42001 Annex A controls onto the blog's on-premise LLM architecture">
&lt;style>
.m-cov{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:6}
.m-par{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:6}
.m-gap{fill:#f4b8b8;stroke:#444;stroke-width:1.4;rx:6}
.m-hdr{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.ml{font:600 12px sans-serif;fill:#222}
.ms{font:400 10px sans-serif;fill:#444}
.mn{font:italic 10px sans-serif;fill:#555}
&lt;/style>
&lt;rect x="20" y="20" width="780" height="40" class="m-hdr"/>
&lt;text x="410" y="42" text-anchor="middle" class="ml">ISO 42001 Annex A mapped onto the blog's on-premise LLM architecture&lt;/text>
&lt;text x="410" y="55" text-anchor="middle" class="ms">green = covered by code/architecture · yellow = partial · red = governance gap&lt;/text>
&lt;rect x="20" y="80" width="780" height="50" class="m-par"/>
&lt;text x="50" y="100" class="ml">A.2 Policies (2)&lt;/text>
&lt;text x="50" y="116" class="ms">A.2.2 AI policy · A.2.3 Alignment with existing policies&lt;/text>
&lt;text x="500" y="100" class="ml">Status: PARTIAL&lt;/text>
&lt;text x="500" y="116" class="ms">The blog's editorial discipline teaches the angle; a formal written policy per organisation is missing.&lt;/text>
&lt;rect x="20" y="140" width="780" height="50" class="m-gap"/>
&lt;text x="50" y="160" class="ml">A.3 Internal organisation (3)&lt;/text>
&lt;text x="50" y="176" class="ms">A.3.2 Roles and responsibilities · A.3.3 Incident reporting · A.3.4 Stakeholders&lt;/text>
&lt;text x="500" y="160" class="ml">Status: GAP&lt;/text>
&lt;text x="500" y="176" class="ms">Not technical. Requires an organisational decision: AI lead, risk owner, AI committee.&lt;/text>
&lt;rect x="20" y="200" width="780" height="50" class="m-cov"/>
&lt;text x="50" y="220" class="ml">A.4 Resources (6)&lt;/text>
&lt;text x="50" y="236" class="ms">Data, tooling, system, human, financial resources + documentation&lt;/text>
&lt;text x="500" y="220" class="ml">Status: COVERED&lt;/text>
&lt;text x="500" y="236" class="ms">Seven deployment phases + five maturity levels + seven layers + OSS catalogue.&lt;/text>
&lt;rect x="20" y="250" width="780" height="50" class="m-par"/>
&lt;text x="50" y="270" class="ml">A.5 Impact assessment (5)&lt;/text>
&lt;text x="50" y="286" class="ms">AI impact process · documentation · alignment with risks · individuals · society&lt;/text>
&lt;text x="500" y="270" class="ml">Status: PARTIAL&lt;/text>
&lt;text x="500" y="286" class="ms">ISO/IEC 23894 gives the method; a formal per-system impact assessment procedure is missing.&lt;/text>
&lt;rect x="20" y="300" width="780" height="50" class="m-cov"/>
&lt;text x="50" y="320" class="ml">A.6 Lifecycle (4)&lt;/text>
&lt;text x="50" y="336" class="ms">Objectives · design · verification and validation · operation and monitoring · documentation&lt;/text>
&lt;text x="500" y="320" class="ml">Status: COVERED&lt;/text>
&lt;text x="500" y="336" class="ms">Six-stage pipeline + request anatomy + continuous fine-tuning + retrain.&lt;/text>
&lt;rect x="20" y="350" width="780" height="50" class="m-cov"/>
&lt;text x="50" y="370" class="ml">A.7 Data (5)&lt;/text>
&lt;text x="50" y="386" class="ms">Quality · acquisition · provenance · preparation · privacy&lt;/text>
&lt;text x="500" y="370" class="ml">Status: COVERED&lt;/text>
&lt;text x="500" y="386" class="ms">Data versioning + RAG corpus curation + Presidio + LLM Guard Vault.&lt;/text>
&lt;rect x="20" y="400" width="780" height="50" class="m-cov"/>
&lt;text x="50" y="420" class="ml">A.8 Information to parties (4)&lt;/text>
&lt;text x="50" y="436" class="ms">System documentation · information on use · incident communication · external reporting&lt;/text>
&lt;text x="500" y="420" class="ml">Status: COVERED&lt;/text>
&lt;text x="500" y="436" class="ms">OTel GenAI tracing + Langfuse + chunk→trace lineage + guardrail spans.&lt;/text>
&lt;rect x="20" y="450" width="780" height="40" class="m-cov"/>
&lt;text x="50" y="470" class="ml">A.9 Use (3)&lt;/text>
&lt;text x="50" y="484" class="ms">Responsible use processes · use objectives · intended use&lt;/text>
&lt;text x="500" y="470" class="ml">Status: COVERED&lt;/text>
&lt;text x="500" y="484" class="ms">Guardrails + evals + LLM Guard + incident-driven retrain.&lt;/text>
&lt;rect x="20" y="500" width="780" height="40" class="m-cov"/>
&lt;text x="50" y="520" class="ml">A.10 Third parties (4)&lt;/text>
&lt;text x="50" y="534" class="ms">Allocation of responsibilities · supplier · customer · third-party&lt;/text>
&lt;text x="500" y="520" class="ml">Status: COVERED&lt;/text>
&lt;text x="500" y="534" class="ms">OSS vs hyperscalers + OSS catalogue + sovereignty + lock-in analysis.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="a2--ai-policies-2-controls-partial">A.2 — AI policies (2 controls): PARTIAL&lt;/h3>
&lt;ul>
&lt;li>A.2.2 AI policy: the organisation must have a documented AI policy, approved by management, reviewed periodically, communicated and available. It covers principles, scope and commitments.&lt;/li>
&lt;li>A.2.3 Alignment with other policies: the AI policy is not an orphan, it aligns with existing security, privacy, quality and ethics policies.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Gap&lt;/strong>: this is not a code matter. The AI policy is a document that the organisation&amp;rsquo;s management approves and signs. The blog teaches a neutral, technical editorial stance (no hype, sovereignty, OSS by default under ENS/NIS2), but that is not any specific organisation&amp;rsquo;s AI policy. Each client has to draft and sign their own.&lt;/p>
&lt;p>&lt;strong>Minimum template&lt;/strong>: 1-2 pages with: principles (transparency, human oversight, fairness, accountability, sustainability), scope (which systems), measurable commitments (annual review, impact assessment before deployment, team training), governance (who approves what).&lt;/p>
&lt;h3 id="a3--internal-organisation-3-controls-gap">A.3 — Internal organisation (3 controls): GAP&lt;/h3>
&lt;ul>
&lt;li>A.3.2 AI roles and responsibilities: roles defined, non-overlapping, communicated. Typically: AI lead, AI risk owner, data steward, AI ethics officer (which may be a single person in small organisations).&lt;/li>
&lt;li>A.3.3 Reporting of AI incidents/concerns: a channel for anyone (internal or external) to report a problem with an AI system, with documented follow-up.&lt;/li>
&lt;li>A.3.4 Identification of stakeholders: a maintained list of stakeholders (customers, affected persons, regulators, partners) and their expectations.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Gap&lt;/strong>: also not technical. An organisational decision. The usual way to cover it is to appoint an AI lead (which may be the CIO, the CTO or a new role depending on size), reuse the security reporting channel (typically already in place because of 27001) by extending it to AI, and maintain a live stakeholder register.&lt;/p>
&lt;h3 id="a4--resources-6-controls-covered">A.4 — Resources (6 controls): COVERED&lt;/h3>
&lt;ul>
&lt;li>A.4.2 Documented information: documentation of the AIMS.&lt;/li>
&lt;li>A.4.3 Data resources: identification and management of the data available for training, evaluation and operation.&lt;/li>
&lt;li>A.4.4 Tooling resources: development, validation and monitoring tools.&lt;/li>
&lt;li>A.4.5 System resources: hardware, infrastructure, compute.&lt;/li>
&lt;li>A.4.6 Human resources: competent personnel.&lt;/li>
&lt;li>A.4.7 Financial resources: budget.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Covered by the blog&lt;/strong> in the three architectural posts:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of the stack: seven layers&lt;/a>: A.4.5 system resources, A.4.4 tooling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases&lt;/a>: A.4.5 + A.4.7 (budget implicitly).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a>: A.4.5 + A.4.6 (team maturity).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS tooling catalogue&lt;/a>: A.4.4 tooling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a>: A.4.3 data resources.&lt;/li>
&lt;/ul>
&lt;h3 id="a5--impact-assessment-5-controls-partial">A.5 — Impact assessment (5 controls): PARTIAL&lt;/h3>
&lt;ul>
&lt;li>A.5.2 AI impact assessment process: a documented impact assessment procedure.&lt;/li>
&lt;li>A.5.3 Documentation of AI impact assessments: records of the assessments carried out.&lt;/li>
&lt;li>A.5.4 Alignment with AI risk treatment: the impact assessment&amp;rsquo;s decisions feed risk treatment.&lt;/li>
&lt;li>A.5.5 Impacts on individuals: specific dimensions covering affected persons (rights, discrimination, privacy).&lt;/li>
&lt;li>A.5.6 Societal impacts: dimensions covering society (information, social rights).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Partial&lt;/strong>: the method exists in the ISO/IEC SC 42 family. ISO/IEC 23894:2023 is the technical standard for AI risk management and NIST AI RMF 1.0 is the freely usable American equivalent. But the organisation has to write its procedure and run it per system before deployment. It is not code, it is discipline.&lt;/p>
&lt;p>&lt;strong>Minimum template&lt;/strong> for the impact assessment (3-5 pages per system):&lt;/p>
&lt;ol>
&lt;li>Description of the system (what it does, who it serves, underlying model and stack).&lt;/li>
&lt;li>Stakeholders identified.&lt;/li>
&lt;li>Potential impacts (intended and unintended) on people, groups and society.&lt;/li>
&lt;li>Fairness and robustness metrics applied, with thresholds and results.&lt;/li>
&lt;li>Mitigations applied (guardrails, evals, human oversight, rate limiting).&lt;/li>
&lt;li>Accepted residual risks, with signed justification.&lt;/li>
&lt;li>Review cadence (typically annual, or on substantial change).&lt;/li>
&lt;/ol>
&lt;h3 id="a6--ai-system-lifecycle-4-controls-covered">A.6 — AI system lifecycle (4 controls): COVERED&lt;/h3>
&lt;ul>
&lt;li>A.6.2.2 Objectives for responsible development of AI: responsible development objectives defined per system.&lt;/li>
&lt;li>A.6.2.3 Processes for responsible AI design and development: design and development procedures.&lt;/li>
&lt;li>A.6.2.4 AI system requirements and specifications: formal specification of the system.&lt;/li>
&lt;li>A.6.2.5 Verification and validation: V&amp;amp;V before and during operation.&lt;/li>
&lt;li>A.6.2.6 Deployment: deployment procedures.&lt;/li>
&lt;li>A.6.2.7 Operation and monitoring: continuous operation and monitoring.&lt;/li>
&lt;li>A.6.2.8 Documentation: lifecycle documentation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Covered by the blog&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a>: the complete master map of the lifecycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request&lt;/a>: the forensic version of how it runs in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a>: the A.6.2.3 + A.6.2.5 + A.6.2.6 + A.6.2.7 discipline in real operation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>: A.6.2.5 verification and validation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain&lt;/a>: A.6.2.7 operation + continuous iteration.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO, SimPO&lt;/a>: A.6.2.3 responsible design.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps landscape 2026&lt;/a>: the tooling landscape.&lt;/li>
&lt;/ul>
&lt;h3 id="a7--data-for-ai-systems-5-controls-covered">A.7 — Data for AI systems (5 controls): COVERED&lt;/h3>
&lt;ul>
&lt;li>A.7.2 Data for development and enhancement of AI: data management policy and procedures for development and improvement.&lt;/li>
&lt;li>A.7.3 Acquisition of data: acquisition procedures (origin, authorisation, quality).&lt;/li>
&lt;li>A.7.4 Quality of data for AI systems: measurable quality criteria.&lt;/li>
&lt;li>A.7.5 Data provenance: data lineage.&lt;/li>
&lt;li>A.7.6 Data preparation: preparation procedures (chunking, anonymisation, labelling).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Covered by the blog&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a>: A.7.4 + A.7.5 + A.7.6 in detail (five layers: schema, dedup, PII, anti-contamination, lineage).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC and lakeFS&lt;/a>: A.7.2 + A.7.5 (the four data artefacts versioned with lineage).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a>: A.7.6 preparation + filtering.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>: A.7.6 runtime anonymisation with Vault.&lt;/li>
&lt;/ul>
&lt;h3 id="a8--information-for-interested-parties-4-controls-covered">A.8 — Information for interested parties (4 controls): COVERED&lt;/h3>
&lt;ul>
&lt;li>A.8.2 System documentation and information for users: technical documentation available.&lt;/li>
&lt;li>A.8.3 External reporting: the capacity to report to authorities where applicable.&lt;/li>
&lt;li>A.8.4 Communication of incidents to users: notification to users when there is an incident.&lt;/li>
&lt;li>A.8.5 Information for interested parties: information for other stakeholders.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Covered by the blog&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>: A.8.2 per-request traceability, A.8.3 the capacity to extract forensic reporting.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>: A.8.2 documented versioning.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a>: A.8.4, &lt;code>gen_ai.guardrail.*&lt;/code> spans as the basis for incident notification.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>: A.8.4 incident events for retrain.&lt;/li>
&lt;/ul>
&lt;h3 id="a9--use-of-ai-systems-3-controls-covered">A.9 — Use of AI systems (3 controls): COVERED&lt;/h3>
&lt;ul>
&lt;li>A.9.2 Processes for responsible use of AI: responsible use procedures.&lt;/li>
&lt;li>A.9.3 Objectives for responsible use of AI: objectives.&lt;/li>
&lt;li>A.9.4 Intended use of AI systems: documentation of the intended use.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Covered by the blog&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a>: A.9.2 + A.9.3 (the four lines of defence).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>: A.9.2 operational detail.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>: A.9.3 measurement of objectives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain&lt;/a>: A.9.2 closed loop.&lt;/li>
&lt;/ul>
&lt;h3 id="a10--third-parties-and-customer-relationships-4-controls-covered">A.10 — Third parties and customer relationships (4 controls): COVERED&lt;/h3>
&lt;ul>
&lt;li>A.10.2 Allocation of responsibilities: distribution of responsibilities between AI roles.&lt;/li>
&lt;li>A.10.3 Suppliers: procedures for AI suppliers.&lt;/li>
&lt;li>A.10.4 Customers: procedures towards customers.&lt;/li>
&lt;li>A.10.5 Third parties: procedures for third parties.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Covered by the blog&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a>: A.10.3 supplier assessment with lock-in and contractual sovereignty analysis.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps&lt;/a>: A.10.5 third-party inventory (OSS components with licences and governance).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request&lt;/a>: A.10.2 + A.10.4 in the multi-tenant case.&lt;/li>
&lt;/ul>
&lt;h2 id="the-roles-defined-by-the-standard">The roles defined by the standard&lt;/h2>
&lt;p>ISO/IEC 22989:2022 (AI vocabulary, complementary to 42001) defines six roles. Each organisation has to decide which ones it occupies and document it:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Role&lt;/th>
&lt;th>Definition&lt;/th>
&lt;th>Main responsibility&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>AI provider&lt;/strong>&lt;/td>
&lt;td>Organisation that provides the AI system to others&lt;/td>
&lt;td>Makes the system available&lt;/td>
&lt;td>OpenAI provides GPT-5 via API&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AI producer&lt;/strong>&lt;/td>
&lt;td>Organisation that develops the AI system&lt;/td>
&lt;td>Design, development, validation&lt;/td>
&lt;td>Meta produces Llama 4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AI customer&lt;/strong>&lt;/td>
&lt;td>Organisation that acquires the AI system&lt;/td>
&lt;td>Selection, integration, oversight&lt;/td>
&lt;td>A consultancy integrating an LLM into its own product&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AI partner&lt;/strong>&lt;/td>
&lt;td>Organisation collaborating with another AI role&lt;/td>
&lt;td>Shared&lt;/td>
&lt;td>A GPU hardware manufacturer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AI subject&lt;/strong>&lt;/td>
&lt;td>Person or group affected by the system&lt;/td>
&lt;td>Recipient of the impact&lt;/td>
&lt;td>The chatbot&amp;rsquo;s end user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Relevant authority&lt;/strong>&lt;/td>
&lt;td>Regulator with jurisdiction&lt;/td>
&lt;td>External oversight&lt;/td>
&lt;td>AEPD, CNMC, EU AI Act authorities&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>An organisation can occupy several roles at once, which changes the applicable controls. A common pattern in consultancy is: producer + customer + provider towards the end client. The A.10 responsibilities are modulated by the roles.&lt;/p>
&lt;p>&lt;strong>Example role mapping&lt;/strong> for the multi-tenant chatbot from the forensic post:&lt;/p>
&lt;ul>
&lt;li>Base model manufacturer (Llama 4): AI producer of the base model.&lt;/li>
&lt;li>OSS stack operator (the consultancy): AI producer of the LoRA adapter + AI provider of the chatbot to its clients + AI customer of Meta&amp;rsquo;s base model.&lt;/li>
&lt;li>End client (the insurer): AI customer of the chatbot + AI provider of the customer support service.&lt;/li>
&lt;li>The policyholder: AI subject.&lt;/li>
&lt;li>AEPD + EU AI Act authority: relevant authority.&lt;/li>
&lt;/ul>
&lt;p>Each box in the table generates different obligations. The consultancy, being producer of the adapter, must document A.6 (lifecycle) and A.7 (data) for the adapter. Being provider of the chatbot, it must document A.10.4 (customers). Being customer of the base model, it must document A.10.3 (suppliers) and validate that Meta does its part.&lt;/p>
&lt;h2 id="impact-levels-and-proportionality">Impact levels and proportionality&lt;/h2>
&lt;p>42001 does not demand the same rigour of every system. Clause 6.1.2 and control A.5 introduce impact as a modulator. The standard does not define strict categories (unlike the EU AI Act, which does define &amp;ldquo;prohibited / high risk / limited risk / minimal&amp;rdquo;), but it recommends using levels according to severity and probability.&lt;/p>
&lt;p>Industry practice in 2026 aligns the 42001 levels with the EU AI Act categories:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>42001 level&lt;/th>
&lt;th>EU AI Act&lt;/th>
&lt;th>Examples&lt;/th>
&lt;th>Control depth&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>High&lt;/strong>&lt;/td>
&lt;td>High risk (Annex III)&lt;/td>
&lt;td>Credit scoring, HR, health, critical infrastructure&lt;/td>
&lt;td>Exhaustive impact assessment, mandatory human oversight, continuous monitoring, adversarial evals, detailed logging, half-yearly management review&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Medium&lt;/strong>&lt;/td>
&lt;td>Limited risk&lt;/td>
&lt;td>Customer service chatbots that do not automate decisions, productivity assistants&lt;/td>
&lt;td>Standard impact assessment, complete guardrails, annual review&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Low&lt;/strong>&lt;/td>
&lt;td>Minimal risk&lt;/td>
&lt;td>Spam filters, non-personalised content recommendations&lt;/td>
&lt;td>Lightweight impact assessment, basic controls&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This proportionality is operationally key: implementing 42001 at maximum rigour for a low-risk system is waste; relaxing it on a high-risk one is non-compliance.&lt;/p>
&lt;h2 id="the-seven-minimum-aims-documents">The seven minimum AIMS documents&lt;/h2>
&lt;p>An auditor at Stage 1 (documentation review) asks for between seven and ten documents. The seven essentials:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>AI policy&lt;/strong> (clause 5.2 + A.2.2). 1-2 pages. Approved by management, dated, versioned.&lt;/li>
&lt;li>&lt;strong>AIMS scope&lt;/strong> (clause 4.3). List of AI systems in scope, inclusion criteria.&lt;/li>
&lt;li>&lt;strong>Stakeholder register&lt;/strong> (clause 4.2 + A.3.4). A maintained list with expectations.&lt;/li>
&lt;li>&lt;strong>AIMS risk register&lt;/strong> (clause 6.1). Risks of the management system, not of each model.&lt;/li>
&lt;li>&lt;strong>Impact assessment procedure&lt;/strong> (A.5.2) + records of assessments carried out (A.5.3). The procedure plus one or more assessments done.&lt;/li>
&lt;li>&lt;strong>AI lifecycle procedure&lt;/strong> (A.6.2), which can literally read &amp;ldquo;see the six-stage LLMOps pipeline&amp;rdquo; with references to technical runbooks.&lt;/li>
&lt;li>&lt;strong>Data management procedure&lt;/strong> (A.7.2), covering acquisition, quality, provenance, preparation, anonymisation.&lt;/li>
&lt;/ol>
&lt;p>Common additional documents:&lt;/p>
&lt;ol start="8">
&lt;li>&lt;strong>Responsible use policy&lt;/strong> (A.9.2) with permitted and non-permitted use types.&lt;/li>
&lt;li>&lt;strong>AI third-party management procedure&lt;/strong> (A.10.3, A.10.5) with assessment criteria for AI suppliers.&lt;/li>
&lt;li>Internal audit plan + management review agenda (clauses 9.2 + 9.3).&lt;/li>
&lt;/ol>
&lt;p>For an organisation with a mature &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS stack&lt;/a>, documents 6 and 7 are references to technical artefacts that already exist (pipeline runbooks, DVC configurations, the PII policy in LLM Guard). The real documentary effort sits in documents 1, 2, 3, 4 and 5.&lt;/p>
&lt;h2 id="applied-case-the-blogs-multi-tenant-chatbot--42001-checklist">Applied case: the blog&amp;rsquo;s multi-tenant chatbot → 42001 checklist&lt;/h2>
&lt;p>We take the system described in the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a>, a multi-tenant customer support chatbot for insurers on an on-premise OSS stack, and walk it as a 42001 auditor would.&lt;/p>
&lt;p>&lt;strong>Clause 4 — Context&lt;/strong>. The AIMS scope includes the chatbot and excludes the internal HR system (a different AI). Stakeholders identified: client insurers, affected policyholders, the AEPD, the EU AI Act authority (once it enters into force on 2 Aug 2026), the supplier Meta (base model), the hardware supplier NVIDIA. → Documented.&lt;/p>
&lt;p>&lt;strong>Clause 5 — Leadership&lt;/strong>. AI policy signed by the CEO, in force. Roles assigned: AI lead (CTO), AI risk owner (CISO), data steward (Head of Data), quarterly AI ethics committee. → Documented.&lt;/p>
&lt;p>&lt;strong>Clause 6 — Planning&lt;/strong>. AIMS risk register: incomplete documentation, team churn, dependency on a single GPU supplier, EU AI Act regulatory change. AIMS objectives for 2026: 42001 certification before Q4, EU AI Act high-risk compliance before 2 Aug. → Documented.&lt;/p>
&lt;p>&lt;strong>Clause 7 — Support&lt;/strong>. Resources: a &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">4×H100 SXM&lt;/a> cluster + the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven stack layers&lt;/a>. Competence: 2 MLE + 2 SRE + 1 part-time AI ethics, all with documented training. Communication: AI policy on the intranet + handbook. → Documented.&lt;/p>
&lt;p>&lt;strong>Clause 8 — Operation&lt;/strong>. Operating procedures = the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. Impact assessment run before deployment + annual review + review on substantial change (defined as: base model change, major adapter change, expansion to a new tenant). → Documented.&lt;/p>
&lt;p>&lt;strong>Clause 9 — Evaluation&lt;/strong>. Monitoring: &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Langfuse + Tempo + VictoriaMetrics + Grafana&lt;/a>. Mandatory dashboard metrics: F1 per guardrail category over real traffic, statistical drift, RAG faithfulness, refusal rate. Quarterly internal audit with written criteria. Half-yearly management review with signed minutes. → Documented.&lt;/p>
&lt;p>&lt;strong>Clause 10 — Improvement&lt;/strong>. Incident-driven retrain tickets mapped as nonconformities when severity ≥ HIGH. Documented root cause analysis. Effectiveness verified at the next eval gate. → Documented.&lt;/p>
&lt;p>&lt;strong>Annex A — By section&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>A.2 (Policies): AI policy + responsible use policy. → Documented.&lt;/li>
&lt;li>A.3 (Organisation): roles assigned, reporting channel, stakeholder register. → Documented.&lt;/li>
&lt;li>A.4 (Resources): &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS catalogue&lt;/a> + training plan + annual budget. → Documented.&lt;/li>
&lt;li>A.5 (Impact): procedure + per-system assessments + fairness metrics applied. → Documented.&lt;/li>
&lt;li>A.6 (Lifecycle): &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>. → Documented.&lt;/li>
&lt;li>A.7 (Data): &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard Vault&lt;/a> + Presidio. → Documented.&lt;/li>
&lt;li>A.8 (Information to parties): &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel tracing&lt;/a> + Langfuse + &lt;code>gen_ai.guardrail.*&lt;/code> spans + notification to tenants within SLA. → Documented.&lt;/li>
&lt;li>A.9 (Use): &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> + responsible use policy. → Documented.&lt;/li>
&lt;li>A.10 (Third parties): &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers&lt;/a> with lock-in analysis + Meta contract for the base model + contracts with tenants. → Documented.&lt;/li>
&lt;/ul>
&lt;p>Result of the walkthrough: certifiable. The typical gaps (A.2.2 written policy, A.3 roles, A.5 impact assessment procedure) are covered as formal documents. The operational clauses (8, 9, 10) lean on the blog&amp;rsquo;s technical architecture. The distance between &amp;ldquo;having the architecture&amp;rdquo; and &amp;ldquo;having certification&amp;rdquo; is measured in documentary discipline, not in code.&lt;/p>
&lt;h2 id="cross-mapping-with-the-eu-ai-act-nis2-and-ens">Cross-mapping with the EU AI Act, NIS2 and ENS&lt;/h2>
&lt;h3 id="eu-ai-act-reg-20241689--seven-directly-aligned-articles">EU AI Act (Reg. 2024/1689) — seven directly aligned articles&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>EU AI Act article&lt;/th>
&lt;th>Topic&lt;/th>
&lt;th>Aligned 42001 control&lt;/th>
&lt;th>Applies to&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Art. 9&lt;/td>
&lt;td>Risk management system&lt;/td>
&lt;td>A.5 + clause 6&lt;/td>
&lt;td>High-risk systems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Art. 10&lt;/td>
&lt;td>Data and data governance&lt;/td>
&lt;td>A.7 (all)&lt;/td>
&lt;td>High-risk systems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Art. 11&lt;/td>
&lt;td>Technical documentation&lt;/td>
&lt;td>A.6 + A.4.2&lt;/td>
&lt;td>High-risk systems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Art. 12&lt;/td>
&lt;td>Record-keeping (logs)&lt;/td>
&lt;td>A.8.2 + OTel tracing&lt;/td>
&lt;td>High-risk systems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Art. 13&lt;/td>
&lt;td>Transparency to deployers&lt;/td>
&lt;td>A.8.5 + A.10.4&lt;/td>
&lt;td>High-risk systems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Art. 14&lt;/td>
&lt;td>Human oversight&lt;/td>
&lt;td>A.9.2 + documented oversight&lt;/td>
&lt;td>High-risk systems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Art. 17&lt;/td>
&lt;td>Quality management system&lt;/td>
&lt;td>Clauses 4-10&lt;/td>
&lt;td>High-risk providers&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The main obligations for high-risk systems enter into application on 2 August 2026. Implementing 42001 now builds the management base that deadline demands.&lt;/p>
&lt;p>What is missing for EU AI Act compliance that 42001 does not cover:&lt;/p>
&lt;ul>
&lt;li>CE conformity of high-risk systems (declaration of conformity, marking, registration in the EU database).&lt;/li>
&lt;li>The specific post-market monitoring required by Art. 72.&lt;/li>
&lt;li>Serious incident reporting to authorities within legal deadlines (not only to users).&lt;/li>
&lt;li>Transparency obligations towards users for limited-risk systems (Art. 50): chatbots, deepfakes, generated content.&lt;/li>
&lt;li>The Art. 5 prohibitions (social scoring, manipulation, real-time biometrics with exceptions).&lt;/li>
&lt;/ul>
&lt;h3 id="nis2-dir-20222555--three-pillars-with-overlap">NIS2 (Dir. 2022/2555) — three pillars with overlap&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Asset register&lt;/strong> (Art. 21.2.f): AI systems within NIS2 scope must be in the asset inventory. → Overlaps with A.4 + clause 4.3 (scope).&lt;/li>
&lt;li>&lt;strong>Incident notification&lt;/strong> (Art. 23): significant incidents are notified within 24 h (initial alert) + 72 h (detailed report). → Overlaps with A.3.3 (reporting) + clause 10 (improvement).&lt;/li>
&lt;li>&lt;strong>Supply chain security&lt;/strong> (Art. 21.2.d): security assessment of the digital supply chain. → Overlaps with A.10.3 (suppliers).&lt;/li>
&lt;/ul>
&lt;p>For essential NIS2 entities that also use AI systems, 42001 covers the AI-specific part that NIS2 requires by inference but does not spell out.&lt;/p>
&lt;h3 id="ens-rd-3112022">ENS (RD 311/2022)&lt;/h3>
&lt;p>The Spanish Esquema Nacional de Seguridad already expressly addresses AI in its Annex II (ENS controls). The Basic/Medium/High categories align with the 42001 impact levels. The ENS controls on traceability (op.exp.8), activity logging (op.exp.10) and incident management (op.exp.7) are covered by the same technical artefacts as A.8 and A.5 of 42001. An organisation certified at ENS High category with AI systems is a reasonable effort away from adding 42001.&lt;/p>
&lt;h2 id="the-five-usual-certification-traps">The five usual certification traps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — Confusing 42001 with EU AI Act compliance.&lt;/strong> Passing the 42001 audit does not imply conformity with the European Regulation. They are distinct universes with a 60-70% overlap. The trap surfaces when the client asks for CE marking of the high-risk system and the organisation produces only the 42001 certificate.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Over-documenting.&lt;/strong> Two-hundred-page manuals with procedures copied from templates, with no measurables and no evidence of application. The auditor asks for the last execution of the procedure; with no records, the procedures are ornament. The practical rule: prefer short documents referencing live technical artefacts to long self-contained ones.&lt;/p>
&lt;p>&lt;strong>Trap 3 — Under-measuring.&lt;/strong> Defining AIMS objectives with no operational metrics. &amp;ldquo;Improve model quality&amp;rdquo; is a null objective; &amp;ldquo;F1 per guardrail category ≥ 0.85 over real traffic, measured weekly, reviewed quarterly in the management review&amp;rdquo; is an auditable objective. The blog has insisted on this in every post on &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Ignoring A.5 until audit day.&lt;/strong> The impact assessment is the most underrated control and the first one the auditor asks for. Without per-system assessments carried out before deployment, there is no way to demonstrate A.5. The trap surfaces when there is no longer time to produce credible retrospective assessments.&lt;/p>
&lt;p>&lt;strong>Trap 5 — Assuming 27001 covers the AI part.&lt;/strong> Organisations that already have 27001 sometimes think &amp;ldquo;we have half of it done&amp;rdquo;. That is true for Annex SL (structure) and for A.5/A.6/A.7 of 27001 (not of 42001) as far as infosec goes. It is false for A.5 of 42001 (impact assessment), A.7 of 42001 (AI-specific data quality), A.9 (responsible use) and A.10.4 (AI customers). You have to add, not assume.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Concrete templates&lt;/strong> of the seven mandatory documents, with drafting examples and metrics. Material for a post along the lines of &amp;ldquo;The AIMS manual in 7 documents&amp;rdquo; with a reference frame.&lt;/li>
&lt;li>&lt;strong>Detailed article-by-article mapping to the EU AI Act&lt;/strong> with the checklist of technical evidence derivable from the blog&amp;rsquo;s OSS stack. Especially Arts 11 (technical documentation), 14 (human oversight) and 72 (post-market monitoring).&lt;/li>
&lt;li>&lt;strong>The combined ENS High category + 42001 case&lt;/strong>: which ENS controls are covered by which AIMS artefacts, avoiding duplication.&lt;/li>
&lt;li>&lt;strong>NIST AI RMF 1.0 vs 42001 comparison&lt;/strong>: many international clients ask for both. How the same artefacts are recycled to satisfy the two frameworks.&lt;/li>
&lt;li>&lt;strong>42001 for LLM agents and MCP&lt;/strong>: the new dimensions that emerge when the AI system is agentic (excessive agency, tool use, graduated autonomy). The &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a> post introduced line 3 (tool GR); 42001 has open gaps in this terrain and SC 42 is working on addenda.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>ISO/IEC 42001:2023&lt;/strong> — &lt;em>Information technology — Artificial intelligence — Management system&lt;/em>. ISO. &lt;a href="https://www.iso.org/standard/81230.html">https://www.iso.org/standard/81230.html&lt;/a>.&lt;/li>
&lt;li>&lt;strong>ISO/IEC 22989:2022&lt;/strong> — &lt;em>Information technology — Artificial intelligence — Artificial intelligence concepts and terminology&lt;/em>. Define los roles AI provider/producer/customer/partner/subject.&lt;/li>
&lt;li>&lt;strong>ISO/IEC 23894:2023&lt;/strong> — &lt;em>Information technology — Artificial intelligence — Guidance on risk management&lt;/em>. Insumo de A.5.&lt;/li>
&lt;li>&lt;strong>ISO/IEC 38507:2022&lt;/strong> — &lt;em>Governance implications of the use of AI by organizations&lt;/em>. Complemento de gobierno.&lt;/li>
&lt;li>&lt;strong>ISO/IEC 5259&lt;/strong> — &lt;em>Data quality for analytics and machine learning&lt;/em> (familia). Insumo de A.7.&lt;/li>
&lt;li>&lt;strong>EU AI Act (Regulation 2024/1689)&lt;/strong> — texto consolidado en EUR-Lex. Entrada en vigor de obligaciones de alto riesgo: 2 ago 2026.&lt;/li>
&lt;li>&lt;strong>NIS2 (Directive 2022/2555)&lt;/strong> — texto consolidado en EUR-Lex.&lt;/li>
&lt;li>&lt;strong>ENS — Real Decreto 311/2022&lt;/strong> — Esquema Nacional de Seguridad, BOE-A-2022-7191.&lt;/li>
&lt;li>&lt;strong>NIST AI RMF 1.0&lt;/strong> (2023) — &lt;a href="https://www.nist.gov/itl/ai-risk-management-framework">https://www.nist.gov/itl/ai-risk-management-framework&lt;/a>.&lt;/li>
&lt;li>&lt;strong>EUR-Lex EU AI Act consolidated text&lt;/strong> — &lt;a href="https://eur-lex.europa.eu/eli/reg/2024/1689">https://eur-lex.europa.eu/eli/reg/2024/1689&lt;/a>.&lt;/li>
&lt;li>A-LIGN / BSI / Schellman — blogs sobre experiencia de auditoría 42001 con casos reales 2024-2025.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the operational security controls that hold up the AIMS.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the operating procedure that materialises A.6 lifecycle with no extra work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic case walked through as a 42001 checklist in the &amp;ldquo;applied case&amp;rdquo; section of this post.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a> — direct material for A.4 resources.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the platform&lt;/a> — how to justify the proportionality of the controls against the existing maturity level.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> — A.7 data covered in detail.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — A.8 information to interested parties through standardised traceability.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a> — A.9 responsible use.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> — A.6.2.5 verification and validation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — clause 10 continual improvement + the incident-driven loop that feeds formal nonconformities.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a> — A.10.3 supplier assessment with a structural analysis of lock-in and contractual sovereignty; a direct input to the AI supplier register.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps&lt;/a> — A.10.5 inventory of OSS third parties with documented licence, governance and maturity.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLM-specific MLOps in 2026: the landscape&lt;/a> — the operational context in which the AIMS runs and is audited.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical dossier, article by article&lt;/a> — the sister post on Regulation (EU) 2024/1689; it moves from the management system down to the directly applicable legal obligations, with deadlines, penalties and a control-to-article mapping.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: the cross-mapping of ENS × ISO 42001 × EU AI Act&lt;/a> — the third post of the governance trilogy; it goes down to the detail of the 25 technical controls common to the three frameworks, with the master table of triple compliance and the labelling of evidence.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLMs with Keep + Kafka&lt;/a> — the operational materialisation of clause 10 (continual improvement) and the WORM trace A.8.2 demands: every incident opens a nonconformity, triggers a postmortem, updates the runbook and is recorded in the Kafka &lt;code>audit.actions&lt;/code> topic.&lt;/p>
&lt;/li>
&lt;/ul></description></item><item><title>EU AI Act: the technical dossier, article by article, over the blog's on-premise LLM architecture</title><link>https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/</link><pubDate>Mon, 01 Jun 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/</guid><description>&lt;blockquote>
&lt;p>Sister post to the &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">mapping onto ISO/IEC 42001&lt;/a>. That one broke down the AI management system, the certifiable standard. This one breaks down the binding legal regulation that applies without any certification: the EU AI Act is direct law in all 27 Member States, with no transposition and with explicit penalties of up to 35 million euros or 7% of worldwide turnover. The main obligations for high-risk systems enter into force on 2 August 2026; each article applies from its own date, not from the date the organisation gets certified.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Regulation (EU) 2024/1689 (the EU AI Act, &amp;ldquo;Reglamento Europeo de Inteligencia Artificial&amp;rdquo;), published in the Official Journal on 12 July 2024, sets obligations by risk tier (prohibited, high, limited, minimal) and by role (provider, deployer, importer, distributor, authorised representative). The obligations for high-risk systems (Annex III: biometrics, critical infrastructure, education, employment, essential public and private services, law enforcement, migration, justice, democratic processes) enter into force on 2 August 2026 and are the category that applies to most LLM projects in a medium-to-large enterprise. This post maps, article by article, the obligations relevant to a high-risk LLM system deployed on-premise: each article states its requirement, identifies which blog post already describes the technical piece that materialises it, and closes with an auditable checklist that a provider hands to a national supervisory authority. The Annex IV technical dossier is reconstructed by pointing its nine mandatory sections at the corresponding technical runbooks. Also covered: the Art. 6 classification and how to decide whether a system falls in as high risk, the Art. 5 prohibitions (what is excluded by construction), the GPAI obligations of Art. 53 that affect anyone building on base models (Llama, Mistral, DeepSeek, Qwen) rather than their own, the full timetable of application dates (5 Aug 2024 entry into force, 2 Feb 2025 prohibitions, 2 Aug 2025 GPAI, 2 Aug 2026 high risk Annex III, 2 Aug 2027 high risk Annex I and systemic GPAI), the Art. 99 penalty table (up to 35 M€ or 7% of worldwide turnover for Art. 5 violations, 15 M€ or 3% for high risk, 7.5 M€ or 1% for incorrect information) and the five frequent compliance traps. The editorial thesis: the technical architecture described in this blog directly covers between 70% and 85% of the Regulation&amp;rsquo;s technical requirements; the rest is documentary and procedural discipline (FRIA, CE marking, signed declaration of conformity, EU database registration, incident reporting within legal deadlines) built on top of technical artefacts that already exist but running on its own circuit.&lt;/p>
&lt;h2 id="the-analogy-the-type-approval-dossier-for-a-new-vehicle">The analogy: the type-approval dossier for a new vehicle&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EU AI Act as a vehicle type-approval dossier">
&lt;style>
.h-veh{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.h-doss{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.h-auth{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:8}
.h-mkt{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.hl{font:600 13px sans-serif;fill:#222}
.hs{font:400 11px sans-serif;fill:#555}
.hn{font:italic 11px sans-serif;fill:#555}
.hr{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mh1)}
&lt;/style>
&lt;defs>&lt;marker id="mh1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="20" y="20" width="160" height="60" class="h-veh"/>
&lt;text x="100" y="40" text-anchor="middle" class="hl">High-risk AI system&lt;/text>
&lt;text x="100" y="58" text-anchor="middle" class="hs">scoring, biometrics, HR,&lt;/text>
&lt;text x="100" y="72" text-anchor="middle" class="hs">health, justice (the new car)&lt;/text>
&lt;rect x="220" y="20" width="180" height="60" class="h-doss"/>
&lt;text x="310" y="40" text-anchor="middle" class="hl">Annex IV dossier&lt;/text>
&lt;text x="310" y="58" text-anchor="middle" class="hs">technical documentation +&lt;/text>
&lt;text x="310" y="72" text-anchor="middle" class="hs">QMS + FRIA (the WVTA file)&lt;/text>
&lt;rect x="440" y="20" width="160" height="60" class="h-auth"/>
&lt;text x="520" y="40" text-anchor="middle" class="hl">Conformity Assessment&lt;/text>
&lt;text x="520" y="58" text-anchor="middle" class="hs">notified body or self-assess.&lt;/text>
&lt;text x="520" y="72" text-anchor="middle" class="hs">(type approval)&lt;/text>
&lt;rect x="640" y="20" width="160" height="60" class="h-mkt"/>
&lt;text x="720" y="40" text-anchor="middle" class="hl">CE marking + EU DB&lt;/text>
&lt;text x="720" y="58" text-anchor="middle" class="hs">declaration of conformity&lt;/text>
&lt;text x="720" y="72" text-anchor="middle" class="hs">(E mark, type approval)&lt;/text>
&lt;path class="hr" d="M180,50 L220,50"/>
&lt;path class="hr" d="M400,50 L440,50"/>
&lt;path class="hr" d="M600,50 L640,50"/>
&lt;rect x="20" y="130" width="780" height="80" class="h-doss"/>
&lt;text x="410" y="152" text-anchor="middle" class="hl">The 9 sections of the Annex IV dossier (what the file has to contain)&lt;/text>
&lt;text x="410" y="172" text-anchor="middle" class="hs">1 General description · 2 Design and development · 3 Capabilities and limitations · 4 Data · 5 Monitoring · 6 QMS plan · 7 FRIA&lt;/text>
&lt;text x="410" y="190" text-anchor="middle" class="hs">8 Log records · 9 Declaration of conformity — all signed by the provider, available to authorities for 10 years&lt;/text>
&lt;rect x="20" y="230" width="780" height="80" class="h-mkt"/>
&lt;text x="410" y="252" text-anchor="middle" class="hl">Post-market monitoring (Art. 72) + Serious incident reporting (Art. 73)&lt;/text>
&lt;text x="410" y="272" text-anchor="middle" class="hs">The vehicle is tracked after sale: servicing, recalls for defects, records of serious accidents&lt;/text>
&lt;text x="410" y="290" text-anchor="middle" class="hs">Legal deadlines: 15 days (general), 10 days (death), 2 days (critical infra or widespread infringement)&lt;/text>
&lt;text x="410" y="340" text-anchor="middle" class="hn">The vehicle leaves the factory with a dossier; it gets the E mark; it travels with a service book; serious accidents are reported to the ministry.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A vehicle manufacturer cannot sell a new car in the EU without passing WVTA (Whole Vehicle Type Approval). The process is public and standardised: the manufacturer prepares a technical dossier with dozens of chapters (brakes, emissions, active and passive safety, lighting, noise, weight, dimensions, recyclable materials, driver assistance devices), submits it to a type-approval authority or a notified technical service, which audits the file and, if everything adds up, issues the type approval. The manufacturer then stamps the E mark (E1 Germany, E9 Spain, and so on) and the CE/UNECE plate on every vehicle of that type produced in series. Each vehicle also carries a service book with mandatory inspections, and serious accidents or systemic defects are reported to the competent ministry, which can order recalls.&lt;/p>
&lt;p>The EU AI Act adapts exactly this industrial model to high-risk AI software. The provider prepares the Annex IV dossier (nine mandatory sections) with the full technical documentation of the system, runs a conformity assessment (self-assessment for most Annex III cases, notified body for the more sensitive Annex I ones), signs the EU declaration of conformity, registers the system in the European database, and applies the CE marking to the system when it is placed on the market. From there on it must keep a live post-market monitoring system and report serious incidents to the market surveillance authorities of each Member State within legal deadlines ranging from 2 to 15 days depending on severity. Failing to comply, penalties reach 35 million euros or 7% of worldwide turnover depending on which article was breached.&lt;/p>
&lt;p>The analogy matters because it bounds expectations: this is not a one-off compliance job, nor a badge you buy. It is an industrial type-approval process, with cadences, evidence, signatures and criminal liability. And most of the technical evidence the dossier asks for already exists in any serious system described in this blog: data lineage, OTel tracing, continuous evals, guardrails, incident-driven retrain, a responsible-use policy. What is missing is assembling it in the correct legal format.&lt;/p>
&lt;h2 id="how-it-fits-with-iso-42001-nis2-and-ens">How it fits with ISO 42001, NIS2 and ENS&lt;/h2>
&lt;p>Before dropping into the articles, a reminder of the editorial position from the &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">post on ISO 42001&lt;/a>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Nature&lt;/th>
&lt;th>Who operates it&lt;/th>
&lt;th>Coverage&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>EU AI Act&lt;/strong>&lt;/td>
&lt;td>Direct EU law&lt;/td>
&lt;td>Provider + deployer + national surveillance authorities&lt;/td>
&lt;td>AI systems on the EU market, segmented by risk&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ISO/IEC 42001&lt;/strong>&lt;/td>
&lt;td>Certifiable management standard&lt;/td>
&lt;td>Organisation + certification body&lt;/td>
&lt;td>AIMS, organisational governance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NIS2&lt;/strong>&lt;/td>
&lt;td>Transposed cyber directive&lt;/td>
&lt;td>Essential/important entities&lt;/td>
&lt;td>Asset register, incident notification, supply chain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ENS&lt;/strong> (Esquema Nacional de Seguridad, Spain&amp;rsquo;s national security framework, RD 311/2022)&lt;/td>
&lt;td>Spanish security regulation&lt;/td>
&lt;td>Public sector + its suppliers&lt;/td>
&lt;td>Categories B/M/A, certifiable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Implementing 42001 makes it easier to demonstrate articles 9-17 of the EU AI Act, but it is not equivalent to legal compliance. The table of legal obligations and the chain of criminal liability come from the Regulation, not from the standard. An organisation certified against 42001 that deploys a high-risk system without CE marking, without registration in the EU database, without a signed declaration of conformity and without a documented FRIA breaches the Regulation even with the certificate on the wall.&lt;/p>
&lt;h2 id="the-four-risk-categories-and-the-art-6-classification">The four risk categories and the Art. 6 classification&lt;/h2>
&lt;p>The Regulation classifies systems into four risk tiers. The choice of category belongs to the provider and must be documented in the dossier, with technical and legal justification.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Category&lt;/th>
&lt;th>Article&lt;/th>
&lt;th>Examples&lt;/th>
&lt;th>Consequence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Prohibited&lt;/strong>&lt;/td>
&lt;td>Art. 5&lt;/td>
&lt;td>Social scoring, manipulation, real-time biometrics with exceptions, indiscriminate facial scraping&lt;/td>
&lt;td>Cannot operate in the EU under any circumstances&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>High risk&lt;/strong>&lt;/td>
&lt;td>Art. 6 + Annex I + Annex III&lt;/td>
&lt;td>Credit scoring, HR, education, critical infrastructure, non-real-time biometrics, justice, migration, health&lt;/td>
&lt;td>Complies with Arts. 9-17, Annex IV dossier, CE marking, EU DB registration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Limited risk&lt;/strong>&lt;/td>
&lt;td>Art. 50&lt;/td>
&lt;td>Chatbots with humans as users, deepfakes, synthetic content&lt;/td>
&lt;td>Transparency obligations towards the user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Minimal risk&lt;/strong>&lt;/td>
&lt;td>Everything else&lt;/td>
&lt;td>Spam filters, video game NPCs, content suggestions&lt;/td>
&lt;td>No specific obligations; a voluntary code of conduct is recommended&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Art. 6 test for deciding whether a system is high risk:&lt;/p>
&lt;ol>
&lt;li>Is it in Annex I? (products regulated by the listed harmonisation legislation: machinery, lifts, toys, medical devices, and so on). If the AI system is a safety component of an Annex I product, it is high risk.&lt;/li>
&lt;li>Is it in Annex III? (eight areas: biometrics, critical infrastructure, education, employment, essential public/private services, law enforcement, migration/asylum, justice/democratic processes). If the system falls in any of those areas it is high risk, except if the Art. 6.3 exception is invoked (systems performing a narrow procedural task, improving the result of a previous human activity, detecting decision patterns without influencing the final decision, preparatory tasks).&lt;/li>
&lt;/ol>
&lt;p>The Art. 6.3 exception requires formal documentation justifying why it does not apply. In other words, even falling outside is not free: you have to demonstrate why.&lt;/p>
&lt;p>For the typical LLM systems covered in this blog:&lt;/p>
&lt;ul>
&lt;li>Customer support chatbot for banking, insurance or healthcare: probably high risk if it automates contractual decisions about the customer, limited risk if it only informs.&lt;/li>
&lt;li>Internal HR assistant (CV screening): high risk (Annex III, employment area).&lt;/li>
&lt;li>Medical assistant (diagnostic support): high risk (Annex III, healthcare services area).&lt;/li>
&lt;li>Fraud detection system: high risk (Annex III, financial services area if it affects access to credit).&lt;/li>
&lt;li>Code copilot for developers: minimal risk (does not affect third parties&amp;rsquo; fundamental rights).&lt;/li>
&lt;li>Internal LLM as a service with no productive use: minimal risk.&lt;/li>
&lt;/ul>
&lt;p>The category decision is not an opinion: it is documented and justified in the dossier.&lt;/p>
&lt;h2 id="timetable-of-application">Timetable of application&lt;/h2>
&lt;p>The obligations arrive in stages. The dates are inflexible:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Date&lt;/th>
&lt;th>Applies&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>1 Aug 2024&lt;/strong>&lt;/td>
&lt;td>The Regulation enters into force&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>2 Feb 2025&lt;/strong>&lt;/td>
&lt;td>Art. 5 prohibitions + Art. 4 AI literacy obligations&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>2 Aug 2025&lt;/strong>&lt;/td>
&lt;td>GPAI obligations (Art. 53) + governance + general penalties + designated national authorities&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>2 Aug 2026&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Main high-risk obligations for Annex III + Art. 50 transparency to users&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>2 Aug 2027&lt;/strong>&lt;/td>
&lt;td>High risk under Annex I (components of regulated products) + GPAI with systemic risk&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>2 August 2026 is the date that matters to most enterprise LLM projects. We are in June 2026: less than two months to go.&lt;/p>
&lt;h2 id="article-by-article-mapping">Article-by-article mapping&lt;/h2>
&lt;p>The sections below follow the structure of the Regulation. For each article the requirement is stated, the blog&amp;rsquo;s technical artefact that covers it is identified, and it closes with an auditable checklist.&lt;/p>
&lt;h3 id="art-5--prohibited-practices-in-force-since-2-feb-2025">Art. 5 — Prohibited practices (in force since 2 Feb 2025)&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> It prohibits placing on the market, putting into service or using AI systems that:&lt;/p>
&lt;ul>
&lt;li>Manipulate behaviour through subliminal or deceptive techniques that distort decision-making.&lt;/li>
&lt;li>Exploit vulnerabilities related to age, disability or socio-economic situation.&lt;/li>
&lt;li>Implement social scoring by public authorities.&lt;/li>
&lt;li>Perform individual predictive policing based on profiling.&lt;/li>
&lt;li>Perform indiscriminate facial scraping to build facial recognition databases.&lt;/li>
&lt;li>Infer emotions in workplaces or education, except for medical or safety reasons.&lt;/li>
&lt;li>Perform biometric categorisation inferring sensitive attributes (race, political opinion, sexual orientation, and so on).&lt;/li>
&lt;li>Real-time remote biometric identification in public spaces by law enforcement, with strict exceptions (terrorism, kidnapping, missing persons) subject to prior judicial authorisation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> No piece of the blog facilitates these practices; the OSS catalogue described is oriented towards legitimate tasks. But the provider must explicitly document why the system does not fall under these prohibitions. It cannot be assumed.&lt;/p>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Prohibition analysis documented per system, with a written statement of non-applicability.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> If the system uses facial biometrics or emotion analysis: specific legal analysis with a formal opinion.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Annual legal review, or on any change of functionality.&lt;/li>
&lt;/ul>
&lt;h3 id="art-6--classification-of-high-risk-systems">Art. 6 — Classification of high-risk systems&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Determine whether the system is high risk by being in Annex I (safety component of a regulated product) or Annex III (8 areas). If it is in Annex III, assess the Art. 6.3 exception where applicable.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage pipeline&lt;/a> describe systems that are typically high risk (multi-tenant chatbot affecting service decisions in a regulated sector).&lt;/p>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Art. 6 analysis signed by the organisation&amp;rsquo;s legal officer.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> If Art. 6.3 is invoked: formal documentation of the exception.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Re-assessment if the functionality changes (for example, the assistant moves from informing to deciding).&lt;/li>
&lt;/ul>
&lt;h3 id="art-9--risk-management-system">Art. 9 — Risk management system&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> An iterative system, planned and executed across the whole lifecycle. Identify foreseeable risks, estimate risks under normal use and foreseeable misuse, evaluate emerging risks in post-market monitoring, adopt mitigation measures, communicate residual risks. Effectiveness testing is done under realistic conditions.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a>, the iterative lifecycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>, testing under realistic conditions with golden sets.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a>, operational mitigation measures.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a>, management of emerging risks via incident-driven retrain.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Risk management document per system, with identification, mitigation, and accepted residual risks signed off.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Periodic review procedure (at least annual, or on substantial change).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Link to the documented improvement loop.&lt;/li>
&lt;/ul>
&lt;h3 id="art-10--data-and-data-governance">Art. 10 — Data and data governance&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Training, validation and testing datasets that are relevant, representative, as free of errors and as complete as possible, taking into account the characteristics of the intended purpose. Document:&lt;/p>
&lt;ul>
&lt;li>Data collection and selection.&lt;/li>
&lt;li>Processing and annotation.&lt;/li>
&lt;li>Biases identified as likely to affect fundamental rights or cause discrimination; measures to prevent them.&lt;/li>
&lt;li>Identification of data gaps and how they are addressed.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a>, the four data artefacts plus end-to-end lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a>, five layers: schema, dedup, PII, anti-contamination, lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: Vault and Anonymize&lt;/a>, runtime anonymisation with restitution.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Data governance document per dataset (training / RAG corpus / golden eval / enriched retrain).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Bias analysis by protected category with metrics (parity ratio, equalized odds, calibration).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> PII / anonymisation / pseudonymisation procedure with measured F1.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Justification of representativeness for the intended context.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Verifiable chunk→trace lineage.&lt;/li>
&lt;/ul>
&lt;h3 id="art-11--annex-iv--technical-documentation">Art. 11 + Annex IV — Technical documentation&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> A technical dossier with the nine Annex IV sections, drawn up before the system is placed on the market, maintained during operation, and available to authorities for ten years after the last operation. The nine sections:&lt;/p>
&lt;ol>
&lt;li>General description of the system: name, version, purpose, integrator, intended hardware, instructions for use.&lt;/li>
&lt;li>Detailed description of design and development: architecture, base models, training methods, design decisions with justification.&lt;/li>
&lt;li>Information on monitoring, functioning and control: capabilities, limitations, expected accuracy, behaviour under normal use and foreseeable misuse.&lt;/li>
&lt;li>Information on data: datasets used, sources, preparation methods, biases addressed.&lt;/li>
&lt;li>Description of the monitoring system and metrics: traces, logs, dashboards.&lt;/li>
&lt;li>Description of the QMS and the Art. 17 procedures.&lt;/li>
&lt;li>FRIA where applicable (Fundamental Rights Impact Assessment, Art. 27).&lt;/li>
&lt;li>Automatically generated logs that the system stores (Art. 12).&lt;/li>
&lt;li>EU declaration of conformity under Art. 47, included.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> It serves as a direct input for each section:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Annex IV section&lt;/th>
&lt;th>Technical input from the blog&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1. General description&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of the stack: 7 layers&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2. Design and development&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Six-stage LLMOps pipeline&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">modern alignment&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3. Capabilities and limitations&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4. Data&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5. Monitoring&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OTel GenAI&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6. QMS&lt;/td>
&lt;td>Procedures derived from &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO 42001&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7. FRIA&lt;/td>
&lt;td>Gap, see Art. 27 below&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8. Logs&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel tracing&lt;/a> + retention and policies&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9. Declaration of conformity&lt;/td>
&lt;td>Documentary gap, see Art. 47 below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Annex IV dossier complete, versioned, dated, signed.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> 10-year retained access guaranteed (immutable / WORM storage).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Update procedure on substantial change.&lt;/li>
&lt;/ul>
&lt;h3 id="art-12--art-19--record-keeping-logs">Art. 12 + Art. 19 — Record-keeping (logs)&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> A high-risk system must be technically capable of generating automatic logs during its operation. Those logs must allow:&lt;/p>
&lt;ul>
&lt;li>Tracing how the system behaved over time.&lt;/li>
&lt;li>Facilitating post-market monitoring (Art. 72).&lt;/li>
&lt;li>Enabling investigation of serious incidents (Art. 73).&lt;/li>
&lt;li>Supporting audits.&lt;/li>
&lt;/ul>
&lt;p>The provider must retain the logs for at least six months (or longer if national laws or the QMS require it). For remote biometric identification systems there are additional specific requirements.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>, the canonical substrate. Every request emits a span with &lt;code>trace_id&lt;/code>, &lt;code>gen_ai.*&lt;/code> attributes, costs, latencies and guardrail decisions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning&lt;/a>: &lt;code>prompt_id&lt;/code> + &lt;code>version&lt;/code> travel as attributes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a>: &lt;code>gen_ai.guardrail.*&lt;/code> attributes record every decision.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>: spans per scanner with &lt;code>risk_score&lt;/code> and &lt;code>action&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning&lt;/a>: &lt;code>dataset_hash&lt;/code> and &lt;code>model_version&lt;/code> propagated.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> OTel + backend (Tempo, Jaeger) running in production.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Minimum 6-month retention (24-36 months suggested for financial regulation).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> WORM / immutable storage.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> PII in logs redacted (via LLM Guard Vault or equivalent). Logs are not exempt from the GDPR.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Forensic query procedure with audited permissions.&lt;/li>
&lt;/ul>
&lt;h3 id="art-13--transparency-to-deployers">Art. 13 — Transparency to deployers&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> The provider gives the deployer instructions for use that are clear, complete and accessible, in comprehensible language, covering:&lt;/p>
&lt;ul>
&lt;li>The provider&amp;rsquo;s identity.&lt;/li>
&lt;li>Characteristics, capabilities, limitations (accuracy by category, technical specifications).&lt;/li>
&lt;li>Foreseen changes to the system and its metrics.&lt;/li>
&lt;li>Human oversight measures (Art. 14).&lt;/li>
&lt;li>Expected computational resources and hardware.&lt;/li>
&lt;li>When it applies, the expected lifetime of the system and of its maintenance.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps&lt;/a>, which components and with what function.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers&lt;/a>, lock-in analysis and documented dependencies.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request&lt;/a>, capabilities and limitations at forensic level.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> User manual in non-technical language for the deployer, plus a detailed technical manual.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Accuracy metrics by category with thresholds.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Change procedure with prior notification.&lt;/li>
&lt;/ul>
&lt;h3 id="art-14--human-oversight">Art. 14 — Human oversight&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> The system is designed to allow effective human oversight throughout the period of use, with interfaces and procedures that make it possible to:&lt;/p>
&lt;ul>
&lt;li>Understand capabilities and limitations.&lt;/li>
&lt;li>Detect malfunctions (automation bias awareness).&lt;/li>
&lt;li>Decide not to use the system&amp;rsquo;s output, to override it, to reverse it.&lt;/li>
&lt;li>For remote biometric identification: human verification before acting, by at least two people.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety&lt;/a>, line 3 (Tool GR) with human-in-the-loop for destructive actions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, metrics on a human-accessible dashboard.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel tracing&lt;/a>, Langfuse with auditable human sessions.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Oversight interface documented with use cases.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Override / abort capability with no technical restrictions.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Documented training for oversight personnel.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Metrics of the effectiveness of the oversight (override rate, false-negative rate of the oversight itself).&lt;/li>
&lt;/ul>
&lt;h3 id="art-15--accuracy-robustness-and-cybersecurity">Art. 15 — Accuracy, robustness and cybersecurity&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> High-risk systems are designed and developed to reach an appropriate level of accuracy, robustness and cybersecurity, and to perform consistently throughout their lifecycle. The relevant accuracy metrics are declared in the instructions for use. Resilience against:&lt;/p>
&lt;ul>
&lt;li>Errors, faults and inconsistencies within the environment of use.&lt;/li>
&lt;li>Feedback bias during operation (feedback loops).&lt;/li>
&lt;li>Attacks that try to exploit the system&amp;rsquo;s vulnerabilities (data poisoning, model poisoning, model evasion, confidentiality attacks).&lt;/li>
&lt;li>Technical and organisational measures to detect, respond to and resolve vulnerabilities.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation fundamentals&lt;/a>, accuracy vs efficiency with reported metrics.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> + &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, operational robustness.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails: line 1 input + line 2 retrieval&lt;/a>, defence against prompt injection and data poisoning via RAG.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: PromptGuard 2 + injection scanners&lt;/a>, direct adversarial mitigation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: jailbreak resistance + adversarial&lt;/a>, robustness metrics evaluated in CI.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Declared accuracy metrics: F1 by category, accuracy, calibration, RAG faithfulness, hallucination rate.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Robustness plan against adversarial inputs (Garak suite / Promptfoo redteam / PyRIT run periodically).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Cybersecurity plan: vulnerability management, patching of the stack (vLLM, its deps, CUDA), secrets rotation.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Analysis of potential feedback loops with drift monitoring.&lt;/li>
&lt;/ul>
&lt;h3 id="art-17--quality-management-system-qms">Art. 17 — Quality management system (QMS)&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> The provider has a written, systematic and proportionate QMS covering (without being limited to):&lt;/p>
&lt;ul>
&lt;li>Regulatory compliance strategy.&lt;/li>
&lt;li>Design, verification and quality control of the system.&lt;/li>
&lt;li>Testing and validation procedures.&lt;/li>
&lt;li>Data management.&lt;/li>
&lt;li>The risk management system (Art. 9).&lt;/li>
&lt;li>Post-market monitoring (Art. 72).&lt;/li>
&lt;li>Incident reporting (Art. 73).&lt;/li>
&lt;li>Communication with authorities, deployers and other stakeholders.&lt;/li>
&lt;li>Records: documentation, log maintenance.&lt;/li>
&lt;li>Resource management.&lt;/li>
&lt;li>Accountability: management responsibilities.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> The QMS is not code, but it leans on code.&lt;/p>
&lt;ul>
&lt;li>ISO/IEC 42001 implemented (previous post) covers practically the whole content of Art. 17.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Six-stage LLMOps pipeline&lt;/a> as the reference operating procedure.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> QMS manual written, dated, signed, versioned.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Annual internal audit plan with criteria and records.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Management review agenda with signed minutes.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> If 42001 is implemented: documented QMS-42001 mapping.&lt;/li>
&lt;/ul>
&lt;h3 id="art-26--deployer-obligations">Art. 26 — Deployer obligations&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Whoever deploys the system (uses it under their own authority, not necessarily the developer) has obligations of their own:&lt;/p>
&lt;ul>
&lt;li>Use the system in accordance with the instructions (Art. 13).&lt;/li>
&lt;li>Assign competent, trained human oversight (Art. 14).&lt;/li>
&lt;li>Ensure the input data under their control is appropriate.&lt;/li>
&lt;li>Monitor operation and notify the provider on detecting problems or serious incidents.&lt;/li>
&lt;li>Keep the logs under their control for at least 6 months.&lt;/li>
&lt;li>Inform the affected persons when the system is used on them to make decisions (and, in an employment context, also consult their representatives).&lt;/li>
&lt;li>For some Annex III cases: complete a FRIA (Art. 27).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> In the case of the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">multi-tenant chatbot&lt;/a>, the deployer is the client insurer. It must:&lt;/p>
&lt;ul>
&lt;li>Accept the provider&amp;rsquo;s instructions (the consultancy&amp;rsquo;s) and sign the terms.&lt;/li>
&lt;li>Configure human oversight on its side.&lt;/li>
&lt;li>Notify the provider when it detects drift or a serious complaint.&lt;/li>
&lt;li>Keep its own logs in addition to the provider&amp;rsquo;s.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist (for the deployer).&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Contract with the provider covering SLAs, responsibilities, exit plan.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Training programme for oversight personnel.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Incident notification procedure towards the provider.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Policy for informing affected persons (in the employment context, notification to representatives).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> FRIA where applicable.&lt;/li>
&lt;/ul>
&lt;h3 id="art-27--fundamental-rights-impact-assessment-fria">Art. 27 — Fundamental Rights Impact Assessment (FRIA)&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Applicable to deployers that are public bodies or private entities providing public services, or that use systems to assess credit score or life insurance. Before first use, the deployer carries out a FRIA documenting:&lt;/p>
&lt;ul>
&lt;li>A description of the intended use.&lt;/li>
&lt;li>Period and frequency of use.&lt;/li>
&lt;li>Categories of affected persons.&lt;/li>
&lt;li>Specific risks of harm identified.&lt;/li>
&lt;li>Human oversight measures.&lt;/li>
&lt;li>Mitigation measures should the risks materialise.&lt;/li>
&lt;/ul>
&lt;p>The FRIA is notified to the market surveillance authority. Substantial changes force an update.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> The FRIA is a governance document, not a technical artefact. The blog does not cover it directly. But the technical input is:&lt;/p>
&lt;ul>
&lt;li>The impact analyses from the &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO 42001 post, section A.5&lt;/a> are the natural starting point.&lt;/li>
&lt;li>The metrics from the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals post&lt;/a> on fairness by category and groundedness feed the risk section of the FRIA.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> FRIA procedure documented, aligned with the AIIA in ISO 42005 (published as a complement).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> FRIA carried out per system before first use.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Notification to the market surveillance authority.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Periodic review, plus on substantial change.&lt;/li>
&lt;/ul>
&lt;h3 id="art-47--eu-declaration-of-conformity">Art. 47 — EU declaration of conformity&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> The provider draws up a written declaration of conformity per high-risk system, stating:&lt;/p>
&lt;ul>
&lt;li>Identification of the system and the provider.&lt;/li>
&lt;li>A statement that it complies with Arts. 8-15 + Art. 17.&lt;/li>
&lt;li>Reference to the harmonised standards and common specifications applied.&lt;/li>
&lt;li>Where applicable, the notified body and the certificate.&lt;/li>
&lt;li>Place and date, signature and name of the authorised signatory.&lt;/li>
&lt;/ul>
&lt;p>It must be available to authorities for ten years. It is kept up to date.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> A pure legal document. Template in Annex V.&lt;/p>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Declaration of conformity signed by an authorised person before market placement.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Languages: at least the official EU language of the market where the system is placed.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Re-signing procedure on substantial change.&lt;/li>
&lt;/ul>
&lt;h3 id="art-48--ce-marking">Art. 48 — CE marking&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> A high-risk system carries the CE marking visibly, legibly and indelibly. For digital systems with no physical part, the marking is included in the documentation or the interface. If a notified body was involved, its number follows.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> CE marking on a software system typically materialises in:&lt;/p>
&lt;ul>
&lt;li>The product information or &amp;ldquo;About&amp;rdquo; page.&lt;/li>
&lt;li>The official product documentation delivered to the deployer.&lt;/li>
&lt;li>Metadata of the exposed API (custom HTTP header, OpenAPI info).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> CE marking visible in the product interface or official documentation.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Notified body number attached where applicable.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Update procedure on substantial change.&lt;/li>
&lt;/ul>
&lt;h3 id="art-49--registration-in-the-european-database">Art. 49 — Registration in the European database&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Before placing on the market or putting into service a high-risk Annex III system (except area 2, critical infrastructure), the provider registers it in the EU database of high-risk AI systems managed by the Commission. Deployers that are public authorities also register their use. Most registration data is public (transparency towards the public).&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> A pure administrative procedure. No technical part beyond having the dossier ready.&lt;/p>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Registration completed before first productive use.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Update on substantial change.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Portal access maintained (credentials, contact).&lt;/li>
&lt;/ul>
&lt;h3 id="art-50--transparency-to-end-users">Art. 50 — Transparency to end users&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Applies to limited risk systems (and, complementarily, to some high-risk ones too):&lt;/p>
&lt;ul>
&lt;li>Chatbots and AI assistants: the person interacting must know they are talking to an AI, unless it is obvious from the context.&lt;/li>
&lt;li>Generated synthetic content (text, audio, image, video): marked as AI-generated in the output, in a machine-readable format.&lt;/li>
&lt;li>Deepfakes: explicitly declare that the content is artificially generated or manipulated.&lt;/li>
&lt;li>Emotion detectors or biometric categorisation: inform the affected persons.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> Technical materialisation:&lt;/p>
&lt;ul>
&lt;li>A UI banner in the chatbot saying &amp;ldquo;You are talking to an AI assistant&amp;rdquo;.&lt;/li>
&lt;li>A disclaimer on every exportable response (PDF, email): &amp;ldquo;Generated by AI&amp;rdquo;.&lt;/li>
&lt;li>Watermarking of the output (perplexity-based, model-fingerprint), optional but useful for deepfakes.&lt;/li>
&lt;li>For generated audio, image or video, standard C2PA metadata.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Mandatory UI banner.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Disclaimer on exportable outputs.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> If it generates visual content: C2PA marking or equivalent.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Procedure for use of the system on a person without their knowledge (for example, automated CV screening).&lt;/li>
&lt;/ul>
&lt;h3 id="art-53--obligations-of-gpai-providers-in-force-since-2-aug-2025">Art. 53 — Obligations of GPAI providers (in force since 2 Aug 2025)&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> Providers of GPAI (general-purpose models trained with vast compute, typically foundational: Llama 4, Mistral, DeepSeek, Qwen, Gemma) must:&lt;/p>
&lt;ul>
&lt;li>Maintain technical documentation of the model, accessible to the AI Office and national authorities.&lt;/li>
&lt;li>Make information available to downstream providers that will integrate it.&lt;/li>
&lt;li>Comply with EU copyright (Art. 4 of Directive 2019/790): an opt-out mechanism for rightsholders.&lt;/li>
&lt;li>Publish a summary of the training content (Annex XI, copyright summary).&lt;/li>
&lt;/ul>
&lt;p>If the model has systemic risk (10^25 FLOPs threshold, or designated by the Commission), additional obligations apply (Art. 55): model evaluation, adversarial testing, incident reporting, cybersecurity.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> For an organisation that uses GPAI models (Llama 4, Mistral) and does not train them from scratch:&lt;/p>
&lt;ul>
&lt;li>It is not a GPAI provider; it is a downstream provider integrating GPAI into its system.&lt;/li>
&lt;li>It must hold the GPAI technical documentation (Llama paper, Mistral docs) and reference it in its own Annex IV documentation.&lt;/li>
&lt;li>Analysis of the specific GPAI licence (Llama Community License, Apache 2.0, and so on).&lt;/li>
&lt;li>The &lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">post on modern alignment&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a> describe how a LoRA adapter on top of the GPAI does not turn the downstream party into a GPAI provider, as long as it does not train a new model from scratch.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist (downstream provider).&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Inventory of GPAI models used, with version, source, licence, referenced documentation.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> EU copyright compliance analysis (Art. 4 of Dir. 2019/790).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Mapping of responsibilities between the upstream GPAI provider and us as downstream.&lt;/li>
&lt;/ul>
&lt;h3 id="art-72--post-market-monitoring">Art. 72 — Post-market monitoring&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> A documented post-market monitoring system proportionate to the risk, collecting data on performance throughout the lifecycle, including interaction with other AI systems. It allows the provider to:&lt;/p>
&lt;ul>
&lt;li>Assess continuous compliance with Arts. 8-15.&lt;/li>
&lt;li>Adopt the necessary corrective measures.&lt;/li>
&lt;li>Detect trends in real use (drift, abuse).&lt;/li>
&lt;/ul>
&lt;p>The monitoring plan is part of Annex IV and is maintained throughout the system&amp;rsquo;s life. The Commission publishes a template in 2026.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel tracing + Langfuse&lt;/a>, the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Continuous evals&lt;/a>, operational metrics as online gates.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a>, judges over production sampling to detect degradation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Incident-driven retrain&lt;/a>, the closed improvement loop.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Post-market monitoring plan documented per system.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Operational metrics defined with thresholds and periodic review.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Corrective action procedure on alert.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Integration with Art. 73 (when an alert equals a serious incident).&lt;/li>
&lt;/ul>
&lt;h3 id="art-73--serious-incident-reporting">Art. 73 — Serious incident reporting&lt;/h3>
&lt;p>&lt;strong>What it requires.&lt;/strong> The definition of a serious incident (Art. 3(49)):&lt;/p>
&lt;ul>
&lt;li>Death or serious harm to health.&lt;/li>
&lt;li>Serious disruption of critical infrastructure.&lt;/li>
&lt;li>Infringement of legal obligations intended to protect fundamental rights.&lt;/li>
&lt;li>Serious damage to property or the environment.&lt;/li>
&lt;/ul>
&lt;p>The provider reports to the market surveillance authority of the Member State where the incident occurred. &lt;strong>Deadlines&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Type of incident&lt;/th>
&lt;th>Maximum deadline from becoming aware&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>General&lt;/td>
&lt;td>15 days&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Death&lt;/td>
&lt;td>10 days&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Critical infra affected, or widespread infringement&lt;/td>
&lt;td>2 days&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first report may be preliminary; the complete one follows later. Internal investigation is mandatory, as is cooperation with the authorities. Corrective action is proportionate.&lt;/p>
&lt;p>&lt;strong>The blog&amp;rsquo;s stack.&lt;/strong> The technical flow:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel tracing&lt;/a> provides the traceability for forensic investigation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety&lt;/a> emits the canonical incident_event (category, severity, trace_id).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Incident-driven retrain&lt;/a> materialises the corrective action.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Checklist.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;input disabled="" type="checkbox"> Incident reporting procedure documented with deadlines and templates.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Designated person (typically DPO + AI Risk Owner) with responsibility and training.&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Annual dry-run of the procedure (drill).&lt;/li>
&lt;li>&lt;input disabled="" type="checkbox"> Technical integration between the guardrails / tracing layer and the notification channel.&lt;/li>
&lt;/ul>
&lt;h2 id="the-assembled-annex-iv-dossier-svg-of-the-complete-file">The assembled Annex IV dossier: SVG of the complete file&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Annex IV dossier assembled over the blog's OSS stack">
&lt;style>
.x-sect{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:6}
.x-blog{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:6}
.x-hdr{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.x-out{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:6}
.x-gap{fill:#f4b8b8;stroke:#444;stroke-width:1.4;rx:6}
.xl{font:600 12px sans-serif;fill:#222}
.xs{font:400 10px sans-serif;fill:#444}
.xn{font:italic 10px sans-serif;fill:#555}
&lt;/style>
&lt;rect x="20" y="20" width="780" height="40" class="x-hdr"/>
&lt;text x="410" y="42" text-anchor="middle" class="xl">Annex IV dossier (Art. 11) — nine mandatory sections + derived outputs&lt;/text>
&lt;text x="410" y="55" text-anchor="middle" class="xs">yellow = Annex IV section · green = blog artefact covering it · red = documentary gap&lt;/text>
&lt;rect x="20" y="80" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="100" text-anchor="middle" class="xl">1. General description&lt;/text>
&lt;text x="120" y="114" text-anchor="middle" class="xs">purpose, version, hardware, deployer&lt;/text>
&lt;rect x="240" y="80" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="100" text-anchor="middle" class="xl">Seven stack layers + seven deployment phases + request anatomy + OSS catalogue&lt;/text>
&lt;text x="520" y="114" text-anchor="middle" class="xs">complete technical description accessible to the authority&lt;/text>
&lt;rect x="20" y="130" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="150" text-anchor="middle" class="xl">2. Design and development&lt;/text>
&lt;text x="120" y="164" text-anchor="middle" class="xs">architecture, base models, methods&lt;/text>
&lt;rect x="240" y="130" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="150" text-anchor="middle" class="xl">Six-stage LLMOps pipeline + Continuous fine-tuning + Alignment + Multi-LoRA + Quantisation&lt;/text>
&lt;text x="520" y="164" text-anchor="middle" class="xs">architecture decisions with technical justification&lt;/text>
&lt;rect x="20" y="180" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="200" text-anchor="middle" class="xl">3. Capabilities and limitations&lt;/text>
&lt;text x="120" y="214" text-anchor="middle" class="xs">accuracy, expected behaviour&lt;/text>
&lt;rect x="240" y="180" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="200" text-anchor="middle" class="xl">Evals with golden sets + LLM-as-judge + F1 by category + groundedness + faithfulness&lt;/text>
&lt;text x="520" y="214" text-anchor="middle" class="xs">metrics declared and measured in CI&lt;/text>
&lt;rect x="20" y="230" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="250" text-anchor="middle" class="xl">4. Data&lt;/text>
&lt;text x="120" y="264" text-anchor="middle" class="xs">datasets, sources, preparation, bias&lt;/text>
&lt;rect x="240" y="230" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="250" text-anchor="middle" class="xl">Data versioning DVC + lakeFS + RAG corpus curation + Presidio + LLM Guard Vault&lt;/text>
&lt;text x="520" y="264" text-anchor="middle" class="xs">four versioned data artefacts with end-to-end lineage&lt;/text>
&lt;rect x="20" y="280" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="300" text-anchor="middle" class="xl">5. Monitoring&lt;/text>
&lt;text x="120" y="314" text-anchor="middle" class="xs">metrics, traces, dashboards&lt;/text>
&lt;rect x="240" y="280" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="300" text-anchor="middle" class="xl">OTel GenAI tracing + Langfuse + Prompt versioning + gen_ai.guardrail.* spans&lt;/text>
&lt;text x="520" y="314" text-anchor="middle" class="xs">per-request traceability with retention &amp;gt;= 6 months (WORM)&lt;/text>
&lt;rect x="20" y="330" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="350" text-anchor="middle" class="xl">6. QMS (Art. 17)&lt;/text>
&lt;text x="120" y="364" text-anchor="middle" class="xs">quality management system procedures&lt;/text>
&lt;rect x="240" y="330" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="350" text-anchor="middle" class="xl">ISO/IEC 42001 implemented (clauses 4-10) + LLMOps pipeline procedures&lt;/text>
&lt;text x="520" y="364" text-anchor="middle" class="xs">signed QMS manual + internal audit plan + management minutes&lt;/text>
&lt;rect x="20" y="380" width="200" height="40" class="x-gap"/>
&lt;text x="120" y="400" text-anchor="middle" class="xl">7. FRIA (Art. 27)&lt;/text>
&lt;text x="120" y="414" text-anchor="middle" class="xs">if public deployer / scoring applies&lt;/text>
&lt;rect x="240" y="380" width="560" height="40" class="x-gap"/>
&lt;text x="520" y="400" text-anchor="middle" class="xl">Dedicated FRIA procedure (input: A.5 ISO 42001 + fairness eval metrics)&lt;/text>
&lt;text x="520" y="414" text-anchor="middle" class="xs">governance document, not technical — it has to be written expressly&lt;/text>
&lt;rect x="20" y="430" width="200" height="40" class="x-sect"/>
&lt;text x="120" y="450" text-anchor="middle" class="xl">8. Logs (Art. 12)&lt;/text>
&lt;text x="120" y="464" text-anchor="middle" class="xs">automatic logs + retention&lt;/text>
&lt;rect x="240" y="430" width="560" height="40" class="x-blog"/>
&lt;text x="520" y="450" text-anchor="middle" class="xl">OTel tracing + Tempo / Jaeger + WORM storage + PII redaction policy&lt;/text>
&lt;text x="520" y="464" text-anchor="middle" class="xs">6+ month retention, WORM, PII redacted by LLM Guard Vault&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The diagram shows the blog&amp;rsquo;s editorial asymmetry: seven of the nine Annex IV sections are covered directly by the technical stack. Only the FRIA (section 7) and the signed declaration of conformity (section 9, not shown in the SVG because it is a one-page document) are documentary gaps needing express administrative work. The main compliance effort is assembling and signing, not building from scratch.&lt;/p>
&lt;h2 id="applied-case-the-multi-tenant-chatbot-assessed-against-the-ai-act">Applied case: the multi-tenant chatbot assessed against the AI Act&lt;/h2>
&lt;p>We take the system from the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a>, a multi-tenant customer support chatbot for insurers, and walk through it as the provider of the system.&lt;/p>
&lt;p>Art. 6 classification: the chatbot helps customers understand products, check status and open tickets. It does not automate contractual decisions (it does not approve claims, it does not calculate premiums). Does it fall into Annex III area 8 (essential private services)? That depends on the use. If the insurer uses it only for informational support, it is limited risk (Art. 50 applies). If it uses it to assess claim declarations, it is high risk. Documenting the analysis is mandatory.&lt;/p>
&lt;p>We assume high risk for the complete walkthrough:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Art. 5 prohibitions&lt;/strong>: written statement of non-applicability. ✓&lt;/li>
&lt;li>&lt;strong>Art. 9 risk management&lt;/strong>: per-system document with identified risks (hallucination, dialect bias, PII leakage, jailbreak), applied mitigations (RAG over a curated corpus, guardrails with 4 lines, LLM Guard Vault, continuous evals) and signed accepted residuals. ✓ (input: pipeline + guardrails + evals).&lt;/li>
&lt;li>&lt;strong>Art. 10 data governance&lt;/strong>: governance of the four datasets (training adapter, insurer RAG corpus, golden eval, enriched retrain) with biases analysed, PII anonymised, lineage in place. ✓ (input: data-versioning + rag-corpus-curation + LLM Guard).&lt;/li>
&lt;li>&lt;strong>Art. 11 + Annex IV&lt;/strong>: dossier with 9 sections drafted, signed, accessible for 10 years in a WORM bucket. ✓ (7 sections from the blog, 2 new).&lt;/li>
&lt;li>&lt;strong>Art. 12 + Art. 19 logs&lt;/strong>: OTel + Tempo + Langfuse with 24-month retention, PII redacted by Vault. ✓&lt;/li>
&lt;li>&lt;strong>Art. 13 transparency to deployers&lt;/strong>: user manual for the insurer plus a technical manual. ✓ (input: OSS catalogue + request anatomy).&lt;/li>
&lt;li>&lt;strong>Art. 14 human oversight&lt;/strong>: Langfuse + Grafana dashboard, human escalation protocol for critical cases, training for the insurer&amp;rsquo;s staff. ✓&lt;/li>
&lt;li>&lt;strong>Art. 15 accuracy, robustness, cybersecurity&lt;/strong>: F1 by category declared, Promptfoo redteam adversarial suite run monthly, stack cybersecurity plan (vLLM patching, secrets rotation). ✓&lt;/li>
&lt;li>&lt;strong>Art. 17 QMS&lt;/strong>: ISO 42001 implemented and certified. ✓&lt;/li>
&lt;li>&lt;strong>Art. 26 deployer&lt;/strong>: the contract with the insurer includes the deployer&amp;rsquo;s obligations. ✓&lt;/li>
&lt;li>&lt;strong>Art. 27 FRIA&lt;/strong>: the insurer carries out a FRIA before first use (it is a private essential-service entity). ✓ (deployer&amp;rsquo;s responsibility, the provider assists).&lt;/li>
&lt;li>&lt;strong>Art. 47 declaration of conformity&lt;/strong>: signed by the consultancy&amp;rsquo;s CTO before market placement. ✓&lt;/li>
&lt;li>&lt;strong>Art. 48 CE marking&lt;/strong>: visible in the chatbot interface and in the official documentation. ✓&lt;/li>
&lt;li>&lt;strong>Art. 49 EU DB registration&lt;/strong>: completed before first productive use. ✓&lt;/li>
&lt;li>&lt;strong>Art. 50 transparency to users&lt;/strong>: UI banner &amp;ldquo;You are talking to an AI assistant&amp;rdquo;, disclaimer on exportable responses. ✓&lt;/li>
&lt;li>&lt;strong>Art. 53 GPAI&lt;/strong>: Llama 4 documentation (the base model) referenced, plus EU copyright analysis and responsibility mapping. ✓&lt;/li>
&lt;li>&lt;strong>Art. 72 post-market monitoring&lt;/strong>: documented plan, OTel + continuous evals + retrain already running. ✓&lt;/li>
&lt;li>&lt;strong>Art. 73 incident reporting&lt;/strong>: procedure, designated owner, annual dry-run. ✓&lt;/li>
&lt;/ul>
&lt;p>Result: certifiable and deployable on the EU market on 2 August 2026. The key gaps (FRIA, CE marking, EU DB registration, declaration of conformity) are documentary work over technical artefacts that already exist, not new technical projects.&lt;/p>
&lt;h2 id="penalties-art-99">Penalties (Art. 99)&lt;/h2>
&lt;p>The penalty table is proportional to worldwide turnover or to an absolute cap, whichever is higher:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Violation&lt;/th>
&lt;th>Penalty cap&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Art. 5 (prohibited practices)&lt;/td>
&lt;td>Up to 35 M€ or 7% of annual worldwide turnover&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Other obligations (Arts. 8-22, 26-50, 72-73, and so on)&lt;/td>
&lt;td>Up to 15 M€ or 3% of annual worldwide turnover&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Incorrect or misleading information to authorities&lt;/td>
&lt;td>Up to 7.5 M€ or 1% of annual worldwide turnover&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For SMEs and startups the caps are the lower of the two figures (not the higher). The proportionality mitigation exists but requires formal demonstration.&lt;/p>
&lt;p>In addition, national authorities can order:&lt;/p>
&lt;ul>
&lt;li>Immediate suspension of the system on the market.&lt;/li>
&lt;li>A mandatory recall.&lt;/li>
&lt;li>Mandatory public communication of the penalty.&lt;/li>
&lt;/ul>
&lt;h2 id="the-five-frequent-compliance-traps">The five frequent compliance traps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — Assuming the GPAI model used already covers the obligations.&lt;/strong> The downstream provider remains responsible for the integrated system: neither Meta nor Mistral nor DeepSeek takes on the obligations of whoever builds on their models. The trap surfaces when the authority asks for the dossier and the team points at the Llama model card as if that were enough.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Confusing ISO 42001 with EU AI Act conformity.&lt;/strong> Holding a 42001 certificate &lt;strong>does not imply&lt;/strong> conformity: certification is not a FRIA, not CE marking, not EU database registration, not a signed declaration of conformity. Standardisation is progressing, but until ISO 42001 is published as a harmonised standard (it was not at the end of 2025), there is no presumption of conformity. The table of legal obligations runs on its own circuit.&lt;/p>
&lt;p>&lt;strong>Trap 3 — Forgetting incident reporting within the legal deadline.&lt;/strong> Fifteen days sounds long until an incident lands in the middle of the summer holidays. Two days for critical infrastructure is not negotiable. Without a documented procedure and an annual drill, the deadline is missed in silence. A penalty for misleading information (1% of worldwide turnover) follows if the incomplete reporting is discovered.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Underestimating the deployer.&lt;/strong> The AI Act assigns obligations to both the provider and the &lt;strong>deployer&lt;/strong>. A company that &lt;strong>uses&lt;/strong> a hosted LLM integrated into its service (without developing it) is still a deployer with obligations of its own (Art. 26): human oversight, FRIA where applicable, notification to affected persons. The trap surfaces when the deployer assumes that &amp;ldquo;responsibility lies with the model provider&amp;rdquo;. It does not, not entirely.&lt;/p>
&lt;p>&lt;strong>Trap 5 — Leaving the FRIA until last.&lt;/strong> The FRIA (Art. 27) requires a fundamental rights impact analysis with qualitative dimensions (discrimination, privacy, social rights). It is not an afternoon&amp;rsquo;s document. It is carried out before first use, not after a problem is detected. Public deployers and private essential-service providers who leave it for &amp;ldquo;whenever they ask&amp;rdquo; take 4-8 weeks to produce a credible one, time they typically do not have when the inspection arrives.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>Concrete templates for each document: EU declaration of conformity (Annex V), full Annex IV dossier section by section, FRIA, post-market monitoring plan, initial serious incident report. Material for a post along the lines of &amp;ldquo;The EU AI Act compliance folder in 12 templates&amp;rdquo;.&lt;/li>
&lt;li>Voluntary codes of practice published by the Commission under Art. 56, useful for limited-risk systems that want to demonstrate good behaviour without a legal obligation.&lt;/li>
&lt;li>Comparative analysis of notified bodies for systems requiring third-party conformity assessment (mainly Annex I).&lt;/li>
&lt;li>How compliance changes for LLM agents, systems with graduated autonomy, tool calling and the capacity to act. SC 42 and the AI Office are working on specific guidance.&lt;/li>
&lt;li>The complete map of national authorities designated under Art. 70, plus the sectoral authorities that retain jurisdiction (AEPD for privacy, CNMV for financial services, Banco de España for banking, AESA for aviation).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Regulation (EU) 2024/1689 (EU AI Act) — Texto consolidado en EUR-Lex: &lt;a href="https://eur-lex.europa.eu/eli/reg/2024/1689">https://eur-lex.europa.eu/eli/reg/2024/1689&lt;/a>. Diario Oficial L 1689/12.7.2024.&lt;/li>
&lt;li>EU AI Act Explorer (AI Act Service Desk, Comisión Europea): &lt;a href="https://ai-act-service-desk.ec.europa.eu/en/ai-act-explorer">https://ai-act-service-desk.ec.europa.eu/en/ai-act-explorer&lt;/a>.&lt;/li>
&lt;li>AI Act Text portal (artificialintelligenceact.eu): artículos individuales con anotaciones.&lt;/li>
&lt;li>Anexo IV — Technical documentation: estructura de los nueve apartados obligatorios.&lt;/li>
&lt;li>Anexo V — EU declaration of conformity: plantilla obligatoria.&lt;/li>
&lt;li>Anexo III — High-risk AI systems: las ocho áreas que clasifican un sistema como alto riesgo.&lt;/li>
&lt;li>Anexo XI — Copyright training summary template: para GPAI providers.&lt;/li>
&lt;li>NIST AI RMF 1.0 (2023) — &lt;a href="https://www.nist.gov/itl/ai-risk-management-framework">https://www.nist.gov/itl/ai-risk-management-framework&lt;/a>.&lt;/li>
&lt;li>ISO/IEC 42001:2023 — sistema de gestión, complemento facilitador.&lt;/li>
&lt;li>ISO/IEC 42005 — Impact assessment AI (publicada 2025 como guía técnica para FRIA).&lt;/li>
&lt;li>Draft Commission guidance on serious incident reporting (2025) — borrador en consulta para Art. 73.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the technical hardening measures that back the dossier.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for the AI system&lt;/a> — the sister post on the management system that makes Arts. 9, 10, 11 and 17 of the Regulation easier to demonstrate.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the lifecycle that operationally materialises Arts. 9 (risk management) and 17 (QMS).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic case used as a compliance checklist in this post.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> — Art. 10 data governance.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — Arts. 12 + 19 (record-keeping) with canonical OTel.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — Arts. 14 (human oversight) and 15 (cybersecurity/robustness).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: the sworn translator with a book of equivalences&lt;/a> — Art. 10 + Art. 12 with PII redacted on the runtime path.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — Art. 15 (accuracy) and Art. 72 (post-market monitoring).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — Art. 72 continuous improvement + Art. 73 corrective action after an incident.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a> — Art. 53 (GPAI obligations) and analysis of integrated GPAI providers.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps&lt;/a> — Art. 13 (transparency to deployers) with a complete component inventory.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — responsible adapter design (Art. 9 mitigation + Art. 15 robustness).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: the cross-mapping of ENS × ISO 42001 × EU AI Act&lt;/a> — the technical zoom into the overlap of the three frameworks. A single tracing piece, a single guardrails piece and a single versioning piece materialise the technical requirements of ENS + 42001 + AI Act simultaneously when labelled with a common vocabulary.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLMs with Keep + Kafka&lt;/a> — the operationalisation of Art. 73 (reporting of serious incidents) with 2/10/15-day deadlines by severity, Keep YAML workflows and the WORM Kafka topic &lt;code>audit.actions&lt;/code> as evidence for the competent authority.&lt;/p>
&lt;/li>
&lt;/ul></description></item></channel></rss>