<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://erdincka.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://erdincka.github.io/" rel="alternate" type="text/html" /><updated>2026-09-06T14:31:26+00:00</updated><id>https://erdincka.github.io/feed.xml</id><title type="html">Erdinc Kaya</title><subtitle>Notes on running AI workloads on infrastructure you control — Kubernetes, data platforms, and the operational details that decide whether a design survives contact with a cluster.</subtitle><author><name>Erdinc Kaya</name></author><entry><title type="html">Deciding what crosses the link: an edge-to-core pattern on HPE Data Fabric</title><link href="https://erdincka.github.io/2026/09/deciding-what-crosses-the-link/" rel="alternate" type="text/html" title="Deciding what crosses the link: an edge-to-core pattern on HPE Data Fabric" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://erdincka.github.io/2026/09/deciding-what-crosses-the-link</id><content type="html" xml:base="https://erdincka.github.io/2026/09/deciding-what-crosses-the-link/"><![CDATA[<blockquote>
  <p><strong>Before you read this.</strong> This is an engineering demo, not a production reference
architecture. Every figure below was measured against a live HPE Data Fabric 8.1
cluster rather than estimated, but it is a single lab cluster with both sites on it —
the numbers show that the mechanisms work, not what they do at fleet scale. The
repository ships the provisioning, the verification and the failure modes, so the
claims can be checked rather than taken on trust.</p>
</blockquote>

<p>A field team working off a satellite link cannot pull down everything headquarters has.
That sounds like a bandwidth problem, and most designs treat it as one — compress harder,
sync less often, buy a bigger pipe. It isn’t. It’s a <strong>decision</strong> problem. The team needs
to know what <em>exists</em> so they can choose the few things worth spending the link on.</p>

<p><a href="https://github.com/erdincka/satellite">Satellite Demo</a> is a working model of that
pattern, built on <a href="https://www.hpe.com/us/en/products/software/data-fabric-software.html">HPE Data Fabric</a>.
An HQ site ingests imagery, catalogues it, and continuously broadcasts lightweight
<em>descriptions</em> to every edge site. The edge browses those descriptions and requests the
handful it actually wants. Only then does the imagery itself move. A vision model at the
edge can describe what arrived, so an operator gets an answer without opening every file.</p>

<p>The pattern generalises well beyond satellite imagery — disaster response, maritime
operations, remote industrial sites, defence — anywhere the link is expensive,
intermittent, or both.</p>

<p><img src="/assets/img/satellite-interface.png" alt="Headquarters on the left, the edge site on the right, and the live Data Fabric link between them" /></p>

<figure>
  <video controls="" preload="metadata" poster="/assets/img/satellite-interface.png">
    <source src="/assets/video/satellite-demo.mp4" type="video/mp4" />
    Your browser does not support embedded video.
    <a href="/assets/video/satellite-demo.mp4">Download the walkthrough</a> instead.
  </video>
  <figcaption>Ninety-seven seconds against the live cluster: an asset requested at the
  edge, the mirror pulled deliberately, then the link cut and restored while the backlog
  builds and drains.</figcaption>
</figure>

<h2 id="the-architecture-in-one-idea">The architecture in one idea</h2>

<p><strong>Separate the metadata plane from the data plane, and give them different transports and
different cadences.</strong></p>

<p>Descriptions are small, and they travel <em>continuously</em> over a replicated stream. Imagery
is large, and it travels <em>only when asked for</em>, by volume mirroring the edge triggers
itself. Two planes, two mechanisms, two economics.</p>

<p>That separation is what makes the system usable on a bad link. The catalogue stays
current for pennies. The expensive transfer happens under human control, at a moment
someone has decided is worth it.</p>

<h2 id="one-pipeline-four-data-models">One pipeline, four data models</h2>

<p>Here is what makes this interesting as a platform story. That single pipeline touches
four different kinds of data, and each wants a different interface:</p>

<table>
  <thead>
    <tr>
      <th>In the pipeline</th>
      <th>Data model</th>
      <th>Interface used</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Catalogue broadcast, asset requests</td>
      <td>Event stream</td>
      <td>Kafka API</td>
    </tr>
    <tr>
      <td>Satellite imagery</td>
      <td>Files</td>
      <td>POSIX, over NFS</td>
    </tr>
    <tr>
      <td>Asset metadata and AI narration</td>
      <td>Table</td>
      <td>Iceberg</td>
    </tr>
    <tr>
      <td>The Iceberg warehouse itself</td>
      <td>Objects</td>
      <td>S3</td>
    </tr>
    <tr>
      <td>Volumes, streams, topics, mirrors</td>
      <td>Administration</td>
      <td>REST</td>
    </tr>
  </tbody>
</table>

<p><strong>None of that required a second system.</strong> No message broker to deploy, no object store
to stand up, no separate catalog service, no file-transfer scheduler. One cluster, one
namespace, one identity — addressed through whichever standard interface each part of the
pipeline naturally speaks.</p>

<p>That is what multi-modal and multi-protocol mean in practice: the platform is not a
file system with an object gateway bolted on, or a message queue with a storage tier
attached. Files, objects, streams and tables are first-class, they live in the same
namespace, and they are governed by the same security model.</p>

<p>The practical consequence is that data does not have to move between systems to become
usable by a different consumer. An operator’s imagery lands on a volume via POSIX; the
catalogue that indexes it is an Iceberg table on S3; the notification that it exists is a
stream event. Same cluster, same volumes, same credentials — no ETL between tiers whose
only purpose is to satisfy the next tool’s preferred protocol.</p>

<h2 id="what-you-would-otherwise-assemble">What you would otherwise assemble</h2>

<p>It is worth being concrete about the alternative, because “one platform” is easy to say
and hard to evaluate. To build this pattern on general-purpose components you would need,
at minimum:</p>

<table>
  <thead>
    <tr>
      <th>Requirement</th>
      <th>Assembled stack</th>
      <th>HPE Data Fabric</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Events between sites</td>
      <td>Kafka plus MirrorMaker</td>
      <td>Stream replication, multi-master</td>
    </tr>
    <tr>
      <td>Bulk file transfer</td>
      <td>rsync or a sync service, plus a scheduler</td>
      <td>Volume mirroring, on demand or scheduled</td>
    </tr>
    <tr>
      <td>Object storage</td>
      <td>MinIO or Ceph, plus its own replication</td>
      <td>S3 on the same volumes</td>
    </tr>
    <tr>
      <td>Table storage</td>
      <td>A warehouse and a catalog service</td>
      <td>Iceberg on the same object store</td>
    </tr>
    <tr>
      <td>File access at the edge</td>
      <td>NFS server, separately deployed</td>
      <td>Native</td>
    </tr>
    <tr>
      <td>Identity across all of it</td>
      <td>Per-system auth, federated somehow</td>
      <td>One cluster identity, one ticket</td>
    </tr>
    <tr>
      <td>Provisioning</td>
      <td>Automation against several APIs</td>
      <td>One REST API</td>
    </tr>
  </tbody>
</table>

<p>Every row in that middle column is a component to deploy, secure, monitor, patch, and
capacity-plan — at <strong>every site</strong>, including the ones with no staff and a satellite link.</p>

<p>And each brings its own disconnection semantics. MirrorMaker’s lag behaviour is not
rsync’s retry behaviour, which is not object replication’s queueing behaviour. When the
link drops, you are reasoning about three or four independent recovery models at once,
and the interactions between them are where the incidents come from.</p>

<p>On Data Fabric, disconnection is handled the same way for everything, because it is the
platform’s concern rather than each component’s.</p>

<p><img src="/assets/img/satellite-backlog.png" alt="Published at HQ against received at the edge, through a cut and a restore" /></p>

<p>Cutting the link left HQ publishing normally while the edge stayed frozen and the backlog
grew to 21 messages; restoring it drained to zero within seconds. Imagery behaved
consistently: a requested asset simply sat staged in HQ’s outbound volume until the edge
chose to mirror it. Nothing about that is simulated, and none of it is application code.</p>

<p>These are ordinary Data Fabric objects, visible and managed like any other — the edge’s
imagery volume is a mirror, and the platform knows it:</p>

<p><img src="/assets/img/satellite-mcs-volumes.png" alt="The demo's five volumes in the Data Fabric control system, with satellite-edge-assets typed as a mirror" /></p>

<h2 id="what-still-needs-designing">What still needs designing</h2>

<p>The platform removes the infrastructure problem. It does not remove the design problem,
and three decisions mattered more than any other.</p>

<h3 id="model-the-link-as-a-state-not-an-error">Model the link as a state, not an error</h3>

<p>The instinct is to treat disconnection as an exception. It isn’t — for an edge deployment
it is a normal operating mode, and often a <em>scheduled</em> one. Designing for three explicit
states (connected, scheduled, cut) produced a much better system than designing for
“connected, with error handling”. The platform supports this directly: pausing and
resuming replication is a supported operation, and mirrors are on-demand by nature.</p>

<p>The corollary is to show the link’s real state, read from the cluster, rather than
inferring it from whether your own messages happen to be flowing.</p>

<h3 id="audit-what-you-replicate">Audit what you replicate</h3>

<p>HQ’s internal service hand-off runs on its own unreplicated stream. It would have been
one line of configuration to put it on the replicated pair — and it would have pushed
HQ’s private chatter down the very link the design exists to conserve.</p>

<p>Because replication is so easy to enable, it is equally easy to enable by accident. On a
constrained link that is a real and recurring cost. Decide deliberately what crosses.</p>

<h3 id="name-objects-by-site-not-by-cluster">Name objects by site, not by cluster</h3>

<p>Each site owns its own volumes, streams and buckets, distinguished by name rather than by
which cluster they happen to live on. The system then behaves identically whether both
sites share one cluster or sit on two with a trust relationship. One is not a degraded
version of the other, and moving from one to two is a configuration change rather than a
redesign.</p>

<h2 id="from-two-sites-to-a-thousand">From two sites to a thousand</h2>

<p>This demo is a baseline, not a blueprint. A real deployment has tens or thousands of edge
sites, and at that scale the platform features that matter are the ones you never see in
a two-site demo. It is worth naming them, because they are the reason this pattern scales
without becoming a bespoke integration project per location.</p>

<h3 id="one-namespace-across-many-fabrics">One namespace across many fabrics</h3>

<p>Data Fabric’s <strong>global namespace</strong> aggregates remote and disparate data sources so that
multiple fabrics can be viewed and operated as a single logical, local fabric. An
application addressing data at another site does not need to know it is remote.</p>

<p>Naming then becomes a fleet convention rather than a per-deployment choice: every cluster
in the namespace needs a unique name, and <code class="language-plaintext highlighter-rouge">mapr-clusters.conf</code> must carry the same
cluster configuration and naming across nodes and clients. The two-site convention in
this demo scales only if it is designed as a scheme — region, role, site identifier —
before the fleet exists rather than after.</p>

<h3 id="authorization-that-spans-clusters">Authorization that spans clusters</h3>

<p>For clusters to communicate at all, a secure <strong>trust relationship</strong> must exist between
them. That trust is what permits remote commands, remote replicas and mirrors, and NFS
access to another cluster — the exact operations this pattern depends on.</p>

<p>Setting it up is a defined procedure rather than a bespoke integration:
<code class="language-plaintext highlighter-rouge">configure-crosscluster.sh</code> generates a cross-cluster ticket, copies it to the other
cluster’s CLDB node, merges it into that cluster’s <code class="language-plaintext highlighter-rouge">maprserverticket</code> and distributes it;
<code class="language-plaintext highlighter-rouge">manageSSLKeys.sh</code> merges trust stores when a client must reach several clusters.
Identity remains the platform’s, not each component’s — which is the point. In an
assembled stack, cross-site authorization means federating several independent identity
systems and keeping them consistent across sites you cannot reach.</p>

<h3 id="the-link-itself-is-managed">The link itself is managed</h3>

<p>Data Fabric treats a constrained link as something to manage rather than saturate.</p>

<p><strong>Mirroring throttles itself.</strong> The sending server continuously measures round-trip time
and restricts mirror traffic to roughly 30% of available bandwidth, backing off when
other traffic needs it. Throttling can be disabled where a maintenance window justifies
full speed.</p>

<p><strong>Replication is compressed and can be encrypted.</strong> Data Fabric applies network
compression — <code class="language-plaintext highlighter-rouge">lz4</code> by default — and traffic between secure clusters can be encrypted on
the wire. In this demo the replica record reports exactly that: <code class="language-plaintext highlighter-rouge">networkcompression:
lz4</code>, <code class="language-plaintext highlighter-rouge">networkencryption: false</code> on a lab cluster where encryption was not required.</p>

<p><strong>Mirrors run on schedules with priorities.</strong> Schedules carry pre-assigned meanings —
critical, important, normal — so a fleet can express that some sites sync hourly and
others when someone asks, without writing a scheduler.</p>

<h3 id="operations-and-observability-at-fleet-scale">Operations and observability at fleet scale</h3>

<p>Data Fabric Monitoring collects metrics and logs for nodes, services and jobs, which is
what makes a fleet observable centrally rather than site by site. Note that monitoring
components are not installed on client or edge nodes — an edge site reports through its
own cluster, so the observability design follows the fabric topology.</p>

<p>For genuinely small sites there is <strong>HPE Data Fabric Edge</strong>: a small-footprint
edition running on commodity hardware in three- to five-node configurations, with the
full capability set — files, tables and streams, plus snapshots, mirroring, replication
and compression. An edge site is a small fabric, not a cut-down client, which is why the
same design works at both ends.</p>

<h2 id="trying-it">Trying it</h2>

<p>The demo runs as a single container against your own cluster. Both sites can point at the
same cluster — they stay separated by volume, stream and bucket names rather than by
geography — so you do not need two clusters to see the pattern work:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/erdincka/satellite
cd satellite
docker compose up -d
</code></pre></div></div>

<p>It starts with no configuration and is pointed at a cluster from the interface, which is
the easiest way to try it. The
<a href="https://github.com/erdincka/satellite/blob/main/README.md">README</a> covers the
prerequisites that actually matter — a reachable CLDB, apiserver, S3 and NFS, a client
version matching the cluster, and <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> on the container because imagery is
reached over an NFS mount.</p>

<hr />

<h2 id="the-takeaway">The takeaway</h2>

<p>The interesting claim isn’t that Data Fabric can move data between sites. Plenty of
things move data. It is that a pipeline spanning streams, files, tables and objects —
across sites, over a link that comes and goes — can be built on <strong>one platform, through
standard interfaces</strong>, instead of assembled from components that each need their own
deployment, security domain, replication mechanism and failure model.</p>

<p>That leaves the application with the one job it should have: deciding what is worth
sending, and when. The fabric handles delivery, queueing, resumption and consistency
underneath.</p>

<p>The application decides. The fabric delivers.</p>]]></content><author><name>Erdinc Kaya</name></author><category term="data-fabric" /><category term="edge" /><category term="streaming" /><category term="storage" /><summary type="html"><![CDATA[A field team on a satellite link cannot pull down everything headquarters has. That is not a bandwidth problem, it is a decision problem — and it is a good test of whether one platform really beats an assembled stack.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://erdincka.github.io/assets/img/satellite-interface.png" /><media:content medium="image" url="https://erdincka.github.io/assets/img/satellite-interface.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Five data engines, one namespace: a fraud pipeline on HPE Data Fabric</title><link href="https://erdincka.github.io/2026/09/five-data-engines-one-namespace/" rel="alternate" type="text/html" title="Five data engines, one namespace: a fraud pipeline on HPE Data Fabric" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://erdincka.github.io/2026/09/five-data-engines-one-namespace</id><content type="html" xml:base="https://erdincka.github.io/2026/09/five-data-engines-one-namespace/"><![CDATA[<blockquote>
  <p><strong>Before you read this.</strong> This is an engineering demo, not a production
reference architecture. I used AI extensively while building and documenting
it, but I reviewed the implementation and validated every claim below against a
running Data Fabric 8.1.0 cluster. The repository ships the verification steps
and the failure modes I hit, so the claims can be checked rather than taken on
trust.</p>

  <p>It deliberately does not try to be a data platform. There is no governance
layer, no lineage graph, no catalogue of catalogues, and the fraud model is
four lines of arithmetic. The goal is narrower: to show what changes when the
storage layer stops being a thing you integrate with and starts being a thing
you write to.</p>
</blockquote>

<p>Every data platform conversation I have ends up at the same question, and it is
never “which engine”. It is <strong>how many systems is that, and who runs them?</strong></p>

<p>The honest answer, for a fairly ordinary medallion architecture, is usually
uncomfortable. You want streaming, so that is Kafka and ZooKeeper. You want a
document store for raw events. You want open table formats, so that is Iceberg,
and Iceberg wants a catalogue service, and the catalogue wants a database. You
want object storage. You want the whole thing visible as files for the tools
that only speak POSIX. You want metrics, so there is a time-series database and
a dashboard in front of it. None of these are unreasonable. Together they are a
platform team’s entire year.</p>

<p><a href="https://github.com/erdincka/catchx">CatchX</a> is a working demonstration of the
other option. It is a fraud detection pipeline — transactions arrive on a
stream, customers arrive as a CSV, both move through bronze, silver and gold
into a shareable data product with suspected fraud flagged. That part is an
ordinary data engineering demo. The part worth an infrastructure architect’s
time is that the six steps below touch <strong>five different data engines</strong>, and the
number of services I had to deploy to support them is zero. Streams, document
store, NFS and object store are all fabric packages on the cluster I already
had. There is no broker, no ZooKeeper, no catalogue service, no metastore
database and no time-series store anywhere in this demo.</p>

<p>There is almost no fraud detection in it, which is the point.</p>

<p>I wrote about the same platform from the other direction in
<a href="https://erdincka.github.io/2026/09/deciding-what-crosses-the-link/">Deciding what crosses the link</a>,
which takes one workload across two sites and asks what is worth moving. This
one stays on a single cluster and asks the opposite question: how many
different engines can you serve from it before you start deploying things?</p>

<hr />

<h2 id="the-shape-of-the-thing">The shape of the thing</h2>

<p>Six steps, and every one of them writes to the same cluster.</p>

<p><img src="/assets/img/catchx-pipeline.png" alt="The Fraud and Risk pipeline: live record counts read from the cluster at every tier" /></p>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>Engine</th>
      <th>Lands in</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Generate</td>
      <td>POSIX file I/O over NFS</td>
      <td>the global namespace</td>
    </tr>
    <tr>
      <td>Publish</td>
      <td>Kafka producer API</td>
      <td>a fabric stream</td>
    </tr>
    <tr>
      <td>Ingest</td>
      <td>OJAI + Apache Iceberg</td>
      <td>bronze</td>
    </tr>
    <tr>
      <td>Refine</td>
      <td>OJAI</td>
      <td>silver</td>
    </tr>
    <tr>
      <td>Consolidate</td>
      <td>Delta Lake</td>
      <td>gold</td>
    </tr>
    <tr>
      <td>Detect</td>
      <td>Delta Lake merge</td>
      <td>gold</td>
    </tr>
  </tbody>
</table>

<p>Five engines. One <code class="language-plaintext highlighter-rouge">/mapr</code> mount. No Kafka cluster, no catalogue service, no
metastore database, no separate object store to stand up.</p>

<p>A full clean run — reset, provision, and all six steps against a single-node
cluster — takes <strong>1 minute 53 seconds</strong>. Almost all of it is per-document
DocumentDB writes, which is a property of the demo’s chattiness rather than the
platform’s.</p>

<h2 id="what-one-namespace-actually-means">What “one namespace” actually means</h2>

<p>This is the claim that sounds like marketing until you run <code class="language-plaintext highlighter-rouge">ls</code> against it.</p>

<p><img src="/assets/img/catchx-global-namespace.gif" alt="Generating source data, then browsing the demo directory over NFS" /></p>

<p>The app generates its CSVs by opening a POSIX path and writing to it. No upload
step, no staging bucket, no client library. Then the same directory, listed over
NFS, shows this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>drwxr-xr-x 2 mapr mapr     1 bronze
lr-------- 1 mapr mapr     3 changelog -&gt; mapr::table::3102.97.131518
-rw-r--r-- 1 root root 55713 customers.csv
drwxr-xr-x 2 mapr mapr     0 gold
-rw-r--r-- 1 root root 20480 iceberg.db
lr-------- 1 mapr mapr     3 incoming -&gt; mapr::table::3102.94.131512
drwxr-xr-x 2 mapr mapr     0 silver
-rw-r--r-- 1 root root 18339 transactions.csv
</code></pre></div></div>

<p>A CSV, a SQLite file, three volumes and two streams, in one directory listing.
The streams are not in a broker somewhere with their own retention config and
their own ACL model — they are entries in the filesystem, and they are governed
by the same volume that governs the CSV next to them.</p>

<p>The Iceberg catalogue is the sharpest example. Iceberg normally needs a
catalogue service to answer “where is table X”. Here it is <code class="language-plaintext highlighter-rouge">iceberg.db</code>, a
SQLite file, sitting in the namespace. Every client that can reach the mount can
reach the catalogue, which means there is no catalogue service to deploy, secure,
back up or scale. That is not the right answer for a thousand-user estate. It is
emphatically the right answer for a demo, and the fact that it is <em>possible</em> is
the platform property worth noticing: the namespace is the coordination point,
so a lot of coordination infrastructure stops being necessary.</p>

<h2 id="standard-clients-not-adapters">Standard clients, not adapters</h2>

<p>The demo’s most persuasive screen is the least dramatic one. Every step has a
<code class="language-plaintext highlighter-rouge">&lt;/&gt;</code> button that shows the function that ran, then follows the call chain down
to whatever actually talked to the cluster.</p>

<p><img src="/assets/img/catchx-code-viewer.gif" alt="Opening the code viewer and following the call chain to the Kafka and OJAI clients" /></p>

<p>Publishing to a fabric stream is this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">confluent_kafka</span> <span class="kn">import</span> <span class="n">Producer</span><span class="p">,</span> <span class="n">KafkaException</span>

<span class="n">producer</span> <span class="o">=</span> <span class="n">Producer</span><span class="p">({</span><span class="s">"streams.producer.default.stream"</span><span class="p">:</span> <span class="n">stream</span><span class="p">})</span>
<span class="n">producer</span><span class="p">.</span><span class="n">produce</span><span class="p">(</span><span class="n">topic</span><span class="p">,</span> <span class="n">message</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">))</span>
</code></pre></div></div>

<p>That is the stock <code class="language-plaintext highlighter-rouge">confluent_kafka</code> library. Not a fork, not a shim. The entire
fabric-specific part is one configuration key naming a path in the namespace
instead of a bootstrap server list. Everything downstream of that — consumer
groups, offsets, <code class="language-plaintext highlighter-rouge">enable.auto.commit</code>, the lot — behaves the way the Kafka
documentation says it does.</p>

<p>I care about this more than any throughput number, because it decides what
happens when the demo ends. Standard protocols mean the migration question is
“change a connection string”, and the skills question is “your team already knows
this”. A platform that requires a bespoke client is a platform you have to staff
for.</p>

<h2 id="two-table-formats-in-one-tier-because-the-tier-does-not-care">Two table formats in one tier, because the tier does not care</h2>

<p>Bronze holds transactions in DocumentDB and customers in Iceberg, at the same
time, in the same volume.</p>

<p><img src="/assets/img/catchx-bronze.gif" alt="Ingesting into DocumentDB and Iceberg, then reading bronze customers back" /></p>

<p>That is not cleverness for its own sake. Streaming JSON documents and batch
columnar loads have genuinely different access patterns, and a tier that forces
one format on both is making you choose against the workload. Here the choice is
per-table, and the tier boundary stays where it belongs — a statement about data
maturity, not about storage technology.</p>

<p>Gold then uses Delta Lake, which any Delta-aware engine can read straight from
the namespace. Three table formats in one pipeline, none of which needed
anything installed.</p>

<h2 id="where-the-data-product-becomes-shareable">Where the data product becomes shareable</h2>

<p>The tier boundary earns its keep in the silver step, and this is the demo’s best
moment in front of a governance-minded audience.</p>

<p><img src="/assets/img/catchx-silver.gif" alt="Refining to silver, showing masked birthdate and location alongside added subdivision codes" /></p>

<p>The same customer records that were fully visible in bronze — names, dates of
birth, locations — come back from silver with <code class="language-plaintext highlighter-rouge">birthdate</code> and <code class="language-plaintext highlighter-rouge">current_location</code>
reading <strong>masked</strong>, and with ISO 3166-2 subdivision codes added that were not
there before. Enrichment and redaction in the same pass, because they are the
same decision: what does the next consumer need, and what should it never see?</p>

<p>Gold goes further and drops the direct identifiers entirely, so the flagged-fraud
table an analyst consumes carries amounts, categories and timestamps but no
account numbers.</p>

<p><img src="/assets/img/catchx-gold-fraud.gif" alt="Consolidating to Delta Lake and flagging suspected fraud" /></p>

<h2 id="telemetry-without-a-telemetry-stack">Telemetry without a telemetry stack</h2>

<p>The record counts in the diagram are not the app remembering what you clicked.
Every one of them is read from the cluster: DocumentDB counts by projecting
<code class="language-plaintext highlighter-rouge">_id</code>, Iceberg and Delta by reading the table, stream depth and consumer lag from
<code class="language-plaintext highlighter-rouge">stream/topic/info</code> and <code class="language-plaintext highlighter-rouge">stream/cursor/list</code> on the cluster’s REST API.</p>

<p><img src="/assets/img/catchx-streams.gif" alt="Publishing transactions to a fabric stream and watching the count arrive" /></p>

<p>The practical consequence is that <strong>step completion is derived from cluster
state, not from session state</strong>. Reload the page and progress survives. Run half
the demo from <code class="language-plaintext highlighter-rouge">curl</code> and the UI shows it. Hand the laptop to a colleague
mid-presentation and it is still correct. I did not click a single button in the
UI before the screenshot above showed all six steps complete — an API run had
already done it, and the app simply reported what was true.</p>

<p>There is no Grafana here, no OpenTSDB, no metrics agent. For a demo that needs
live numbers, the cluster’s own API was enough, and every service I did not add
is a service that cannot fail on stage.</p>

<h2 id="provisioned-and-working-are-different-questions">“Provisioned” and “working” are different questions</h2>

<p>Now the part that actually cost me a day, because it is the transferable lesson
and because a post that only lists capabilities is an advert.</p>

<p>The demo has a reset button. It deleted the four volumes and recreated them, so
you could run the whole thing again from a clean cluster. It had presumably
worked at some point. When I ran it against this cluster, every DocumentDB write
after a reset failed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>insertOrReplace() failed with err code = 19
</code></pre></div></div>

<p>Nineteen is <code class="language-plaintext highlighter-rouge">ENODEV</code>. The code already had a retry loop for this, because a
freshly provisioned volume does take a few seconds to serve — so the first
theory was that the backoff was too short. It was not. The writes still failed
an hour later.</p>

<p>Second theory: the OJAI client caches its connection for the life of the
process, and the connection was stale. I restarted the application. Same error.</p>

<p>Third: check whether the table was actually broken, from somewhere else
entirely. It was not — <code class="language-plaintext highlighter-rouge">dbshell</code>, a fresh process on the cluster node, inserted
a document into the same table without complaint.</p>

<p>Which located it. The error was not in my client and not in the table. It was in
the <strong>Data Access Gateway</strong>, the service that speaks OJAI on port 5678:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Inode - syncPut() on '/catchx-demo/bronze/transactions'
        failed with error: No such device (19).
</code></pre></div></div>

<p>The gateway holds its own MapR client, and that client had resolved the path
while the <em>old</em> volume existed. Delete the volume, create a new one at the same
mount point, and the gateway keeps serving the handle it already had. Nothing
client-side can fix it; only restarting the gateway clears it.</p>

<p>Then the decisive experiment, which took two minutes and should have been the
first thing I did: delete and recreate a <em>table</em> at the same path, leaving the
volume alone. That works perfectly. The blast radius is volumes, not paths.</p>

<p>So the fix was not a longer retry or a reconnect. It was to stop deleting
volumes. The reset now removes the streams, tables and files and leaves the four
volumes in place, which is both correct and about twice as fast.</p>

<p>Three things from that are worth carrying elsewhere:</p>

<p><strong>A distributed storage layer is still made of services, and services have
caches.</strong> “One namespace” is a real and useful abstraction, and it has seams. The
seam here was a component I had not thought about all day, holding state I did
not know it held.</p>

<p><strong>Retry logic encodes a theory about the failure.</strong> The existing backoff assumed
“transient, will settle”. That assumption was invisible until a failure that
looked identical but was permanent walked into it. A retry loop that cannot
distinguish the two will happily hide the second for fifteen seconds and then
report the wrong thing.</p>

<p><strong>Ask a different client.</strong> The single most useful step in the whole
investigation was running <code class="language-plaintext highlighter-rouge">dbshell</code> on the cluster. Two minutes, and it cut the
search space in half by proving the data layer was fine. I had spent
considerably longer than two minutes theorising before I did it.</p>

<p>While I was in there, a related one: the same cached-connection design meant that
after <em>any</em> gateway restart, the application never recovered until it was
restarted too. That is now fixed as well — the connection is dropped and rebuilt
when a channel error is seen — and it is the more common failure in real life,
because gateways get restarted for ordinary reasons.</p>

<p>And a cluster-side finding for anyone benchmarking this: check whether your Data
Access Gateway is still logging at <code class="language-plaintext highlighter-rouge">debug</code>. The default <code class="language-plaintext highlighter-rouge">log4j2.xml</code> on this
cluster writes every gRPC frame and every document payload to disk. It dominated
my write latency, and no amount of application tuning would have touched it.</p>

<h2 id="what-it-does-not-show">What it does not show</h2>

<p>The honest limits, because the demo is small on purpose.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Scale</strong></td>
      <td>A single-node cluster and a few hundred rows. Nothing here says anything about how the fabric behaves at a thousand nodes, and I would not extrapolate.</td>
    </tr>
    <tr>
      <td><strong>Governance</strong></td>
      <td>Masking is a function in the refine step, not a policy engine. There is no lineage, no classification, no audit of who read what.</td>
    </tr>
    <tr>
      <td><strong>The fraud model</strong></td>
      <td>Deliberately trivial arithmetic. If you want a real one, that is a different post and mostly not an infrastructure problem.</td>
    </tr>
    <tr>
      <td><strong>Multi-tenancy</strong></td>
      <td>One admin account does everything. A real deployment separates the pipeline’s identity from the cluster admin’s.</td>
    </tr>
    <tr>
      <td><strong>Failure behaviour</strong></td>
      <td>I tested the reset path hard because it broke. I did not test node loss, network partition or a full disk.</td>
    </tr>
  </tbody>
</table>

<p>The one operational claim I will make firmly is the narrow one I actually
verified: reset the demo and run it again, repeatedly, and it completes with
correct counts at every tier without anything on the cluster being restarted.
That was not true when I started.</p>

<h2 id="worth-your-time">Worth your time?</h2>

<p>If you are choosing a data platform, the interesting question is not whether it
can do streaming, documents, tables and files. Most can, eventually, with enough
components. It is <strong>how many of those components you are agreeing to operate</strong>,
and whether the code you write against them is code you could write against
anything else.</p>

<p>CatchX answers the second question well — stock Kafka, stock OJAI, stock Iceberg
and Delta, with the fabric appearing as a path rather than as an SDK. It answers
the first question with a number: five engines, one mount, nothing extra
installed.</p>

<p>Then it spends a day teaching you that the abstraction has seams, and where one
of them is. Which is the more useful lesson of the two.</p>

<hr />

<p><em>The code, the verification steps and the failure modes are at
<a href="https://github.com/erdincka/catchx">github.com/erdincka/catchx</a>. It runs against
any Data Fabric 7.x or later cluster; use a lab one, since it creates and deletes
its own artefacts.</em></p>]]></content><author><name>Erdinc Kaya</name></author><category term="data-fabric" /><category term="kafka" /><category term="iceberg" /><category term="delta-lake" /><category term="storage" /><summary type="html"><![CDATA[A medallion pipeline touching five data engines and three services, and the day I spent learning where the one-namespace abstraction has seams.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://erdincka.github.io/assets/img/catchx-pipeline.png" /><media:content medium="image" url="https://erdincka.github.io/assets/img/catchx-pipeline.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building an agentic AI stack on Kubernetes Agent Sandbox: what it’s for and what it’s like to use</title><link href="https://erdincka.github.io/2026/08/building-an-agentic-ai-stack-on-kubernetes-agent-sandbox/" rel="alternate" type="text/html" title="Building an agentic AI stack on Kubernetes Agent Sandbox: what it’s for and what it’s like to use" /><published>2026-08-30T00:00:00+00:00</published><updated>2026-08-30T00:00:00+00:00</updated><id>https://erdincka.github.io/2026/08/building-an-agentic-ai-stack-on-kubernetes-agent-sandbox</id><content type="html" xml:base="https://erdincka.github.io/2026/08/building-an-agentic-ai-stack-on-kubernetes-agent-sandbox/"><![CDATA[<blockquote>
  <p><strong>Before you read this.</strong> This is an engineering demo, not a production reference
architecture. I used AI extensively while building and documenting it, but I
reviewed the implementation and, importantly, validated the security claims against
a running Kubernetes cluster. The repository ships the verification commands and the
failure modes I hit, so the claims can be checked rather than taken on trust.</p>

  <p>It deliberately does not try to solve every part of a production agent platform.
There is no elaborate retrieval pipeline, no model evaluation framework, no
comprehensive guardrail system and no production-grade observability stack. The goal
is narrower: to show how Kubernetes primitives can enforce selected agent
capabilities independently of the model.</p>
</blockquote>

<p>Sooner or later someone is going to ask your platform team to run AI agents in
production, if they haven’t done already. Not a chatbot — agents: things that hold credentials, query databases,
call internal APIs, and occasionally write and execute code. And when they do, the
question that lands on infrastructure is not “which model” but a much older one:
<strong>what stops it doing that?</strong></p>

<p>The uncomfortable answer, in most agent frameworks today, is a paragraph of English
in a system prompt. <em>You must not access customer records. Do not run code unless
asked.</em> That is a request, not a control. It has no audit trail, there is nothing to point a
security reviewer at, and it competes for the model’s attention with whatever the
user sent and whatever the retrieval step pulled in. Instructions and input do arrive
as separate message roles, but the only thing weighing one against the other is the
model — which is the component you were trying to constrain in the first place. You can try adding guardrails before and after and during the calls etc, but with the complexity and cost of these extra layers, it’s not always worth it or simply not manageable at scale.</p>

<p><a href="https://github.com/erdincka/meetings">Agentic Meetings</a> is a working demonstration
of the alternative, built on <a href="https://agent-sandbox.sigs.k8s.io/">Kubernetes Agent
Sandbox</a> — a Kubernetes SIG Apps project that
adds <code class="language-plaintext highlighter-rouge">Sandbox</code>, <code class="language-plaintext highlighter-rouge">SandboxClaim</code>, <code class="language-plaintext highlighter-rouge">SandboxTemplate</code> and <code class="language-plaintext highlighter-rouge">SandboxWarmPool</code> as
first-class API objects. The application on top is a multi-agent meeting simulator:
a supervisor picks who speaks next, and each participant argues from their
organisational role over the company’s documents. That part is an ordinary LLM demo.
The part worth an infrastructure architect’s time is underneath — every agent runs
in its own gVisor-isolated sandbox, and <strong>what each one may do is a Kubernetes
authorisation decision</strong>.</p>

<p>This piece is about how it is put together and what it is like to operate. There is
almost no machine learning in it, which is the point.</p>

<hr />

<h2 id="the-shape-of-the-thing">The shape of the thing</h2>

<p><img src="/assets/img/agent-sandbox-demo.gif" alt="A meeting running: agents take turns while the events log records each supervisor decision" /></p>

<p>Three trust boundaries, crossed once per turn:</p>

<p><img src="/assets/img/agent-sandbox-architecture.png" alt="The architecture: a browser reaches a FastAPI backend through Gateway API; the backend dispatches turns to Tier A persona sandboxes, which claim Tier B execution sandboxes through the Kubernetes API server." /></p>

<p><strong>The backend</strong> holds the orchestration graph, the database credential, and the
conversation state. <strong>Tier A</strong> is one gVisor-isolated pod per meeting attendee,
drawn from a warm pool, running that persona’s reasoning loop. <strong>Tier B</strong> is a
second, stricter sandbox tier — no network at all — where any Python the model
writes actually executes.</p>

<p>The interesting edge is the one from Tier A to Tier B. When an agent decides it
needs to run code, its own pod creates a <code class="language-plaintext highlighter-rouge">SandboxClaim</code> against the API server
<strong>using its own ServiceAccount token</strong>. The backend is not in that path and cannot
broker around the decision. If the persona’s profile carries the right role, the
claim succeeds. If it does not, the API server returns 403 — and the agent is told
so, in-band, as a tool result.</p>

<p>That last detail is a design choice worth copying. The denial is <em>reported, not
raised</em>: the tool returns <code class="language-plaintext highlighter-rouge">DENIED_BY_CLUSTER: ...</code>, the agent absorbs it and carries
on contributing to the meeting, and the refusal lands in the transcript and the
audit matrix as a policy decision rather than a stack trace. A control that crashes
the workload gets switched off. A control that degrades it gracefully
survives contact with users.</p>

<h2 id="what-this-is-and-is-not-trying-to-protect">What this is, and is not, trying to protect</h2>

<p>The boundary here is deliberately narrow, so it is worth stating before the
mechanics.</p>

<p>The model is treated as unreliable. It may be wrong, it may follow instructions
buried in something it retrieved, and it may reach for a capability its persona was
never granted. The goal is that a specific set of capabilities is enforced <em>below</em>
it — by Kubernetes authorization, workload isolation, mounted credentials and
network policy — so that whether it cooperates stops being the deciding factor.</p>

<table>
  <thead>
    <tr>
      <th>Threat</th>
      <th>Addressed here?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Model reaches for a tool it was not granted</td>
      <td>Yes, by RBAC at the apiserver</td>
    </tr>
    <tr>
      <td>Model-authored code tries to reach the database</td>
      <td>Yes, where the CNI enforces NetworkPolicy</td>
    </tr>
    <tr>
      <td>Persona needs a credential it was not given</td>
      <td>Yes — it is not mounted</td>
    </tr>
    <tr>
      <td>Agent escapes its sandbox</td>
      <td>Mitigated by gVisor, not eliminated</td>
    </tr>
    <tr>
      <td>Backend is compromised</td>
      <td>Partly: it cannot over-grant, but it is still highly trusted</td>
    </tr>
    <tr>
      <td>Kubernetes control plane is compromised</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

<p>Everything below is about the first three rows. The last two are the honest limits,
and I come back to them at the end.</p>

<h2 id="five-layers-and-the-ones-that-matter">Five layers, and the ones that matter</h2>

<p>The project describes its enforcement as five layers, and is honest about which are
load-bearing:</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Control</th>
      <th>What it actually stops</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Prompt</td>
      <td>Only granted tools are registered</td>
      <td>Honest mistakes</td>
    </tr>
    <tr>
      <td>Runtime</td>
      <td>Grants intersected with a mounted capability list</td>
      <td>A compromised backend over-granting</td>
    </tr>
    <tr>
      <td>Secret</td>
      <td>Credentials mounted only into templates that need them</td>
      <td>Unneeded application credentials are absent</td>
    </tr>
    <tr>
      <td>NetworkPolicy</td>
      <td>Default-deny egress per profile</td>
      <td>A stolen credential being <em>used</em></td>
    </tr>
    <tr>
      <td>RBAC</td>
      <td>Only some ServiceAccounts may claim an exec sandbox</td>
      <td>The agent itself</td>
    </tr>
  </tbody>
</table>

<p>Read that bottom-up and it is a familiar defence-in-depth story. The top layer is
the one every agent framework ships with. It is genuinely useful for steering
behaviour — it prevents accidental tool use and sets the default — but it is not a
security boundary, because nothing below the model is obliged to honour it. The
bottom two are ordinary Kubernetes objects that your cluster already knows how to
enforce.</p>

<p>That is the real argument for doing this on Kubernetes rather than inside an agent
framework: <strong>you are not inventing a permission system.</strong> You are reusing the one
that already governs everything else you run, expressed in primitives your platform
team can already reason about and wire into whatever authorization, policy and audit
tooling they have. Kubernetes supplies the primitives and the API audit surface;
whether those logs are retained, correlated and actually read is an organisational
question, not something the design hands you.</p>

<h2 id="what-its-like-to-use">What it’s like to use</h2>

<p>You configure it, define who is in the room, and watch a meeting run.</p>

<p><img src="/assets/img/agent-sandbox-roles.png" alt="The agent registry: nine personas, each with a department, a tone, a risk level and a set of granted tools" /></p>

<p>Personas are data, edited in the UI: a role, a tone, a risk level, and a set of
granted tools. The grant is the part with teeth — a persona’s tool list resolves to
a <strong>capability profile</strong>, and the profile decides which ServiceAccount its sandbox
runs under. Editing a persona is therefore a privilege change, and the app treats it
as one: the operator role can do it, the viewer role cannot. That split is not the
usual read/write one, and getting it right required noticing that “edit this text
field” and “grant this agent the ability to execute code” were the same action.</p>

<p>Start a meeting and the supervisor takes over: it picks a speaker, that persona’s
sandbox runs a turn, the utterance streams back over a WebSocket, and the events log
records every routing decision beside the transcript. Ask the General Counsel
persona to run a calculation and, if the model reaches for the tool, you get the
demonstrable moment — a 403 from the API server, surfaced in the audit matrix.
Whether it reaches for the tool on any given turn is the model’s decision, and
smaller ones are inconsistent about it. The cluster’s answer does not depend on
that, which is the next thing to show.</p>

<p>And you do not have to take the UI’s word for it, which is the part that matters
if you are the one signing off on this. The same decision is one command away:</p>

<p><img src="/assets/img/agent-sandbox-enforcement.png" alt="A terminal session against a live cluster: /proc/version reports a gVisor kernel, each persona pod runs under its own ServiceAccount, and kubectl auth can-i returns no for the General Counsel and yes for the Finance Director" /></p>

<p>Three things there are worth separating. <code class="language-plaintext highlighter-rouge">/proc/version</code> returning
<code class="language-plaintext highlighter-rouge">4.19.0-gvisor</code> proves the kernel boundary is real rather than a silent fallback
to <code class="language-plaintext highlighter-rouge">runc</code>. The pod listing shows one ServiceAccount per persona, all under the
<code class="language-plaintext highlighter-rouge">gvisor</code> runtime class. And <code class="language-plaintext highlighter-rouge">kubectl auth can-i</code> answers the actual question —
<strong>no</strong> for the General Counsel, <strong>yes</strong> for the Finance Director — without
involving the model, the prompt or the application at all.</p>

<p>That last property is what makes this auditable. Your security reviewer does not
have to trust the app’s audit matrix, or reason about whether a model could be
talked out of its instructions. They can ask the API server directly, the same
way they would for any other workload on the cluster.</p>

<p><img src="/assets/img/agent-sandbox-conclusion.png" alt="A concluded meeting: notes, agreed actions and identified resource gaps, rather than only a transcript" /></p>

<p>Meetings end with a decision record — notes, agreed actions, identified gaps —
rather than just a transcript.</p>

<h2 id="installed-and-working-are-different-questions">“Installed” and “working” are different questions</h2>

<p>This is the transferable lesson, and the project hit it at five separate layers.</p>

<p>A misconfigured <code class="language-plaintext highlighter-rouge">RuntimeClass</code> handler <strong>does not fail loudly</strong> — on several
container runtimes, containerd silently falls back to <code class="language-plaintext highlighter-rouge">runc</code>. The pod is green,
<code class="language-plaintext highlighter-rouge">Ready</code>, and completely unisolated. A CNI that ignores NetworkPolicy looks identical
to one that enforces it: the policies apply cleanly, <code class="language-plaintext highlighter-rouge">kubectl get networkpolicy</code> is
reassuring, and nothing is blocked. On kind’s default CNI, a supposedly-restricted
persona reached Postgres on five consecutive attempts while the security model
claimed the opposite. Gateway API CRDs can be present with no controller watching
them — Gateway API is a specification, and installing its CRDs gives you no
controller and no dataplane. An internal API router can be written, reviewed,
unit-tested and never mounted. And, as above, an egress rule can name a port when
you believed it named a destination.</p>

<p>Each of those presents as a healthy deployment. The habit that falls out is
<strong>provoke the behaviour and observe what happens</strong> — not “is the object present”,
not “is the pod ready”. The repo ships a <code class="language-plaintext highlighter-rouge">make preflight</code> that reports, for the
requirements that can be present and inert, whether the thing actually functions.
Isolation is verified by reading <code class="language-plaintext highlighter-rouge">/proc/version</code> inside a running sandbox, never by
a readiness check.</p>

<h2 id="five-decisions-an-infrastructure-architect-will-care-about">Five decisions an infrastructure architect will care about</h2>

<h3 id="1-keep-the-orchestration-graph-in-one-place">1. Keep the orchestration graph in one place</h3>

<p>It is tempting to distribute the agent graph across the sandboxes. Don’t. Sandboxes
here are <strong>turn executors, not graph participants</strong>: the backend owns the graph and
the state, and hands a sandbox one self-contained turn.</p>

<p>The alternative was tried. Running the reasoning loop remotely while RPC-ing
individual tool calls back cost a network round trip <em>per tool call</em> and turned a
demo into a distributed-systems project. Distributed checkpointing across untrusted
pods is a research problem; it is not a prerequisite for isolating agents.</p>

<h3 id="2-size-the-warm-pool-by-concurrent-speakers-not-attendees">2. Size the warm pool by concurrent speakers, not attendees</h3>

<p>gVisor pods are not fast to start, so both tiers are warm-pooled. Two operational
details:</p>

<ul>
  <li><strong>Acquisition is lazy.</strong> A five-person meeting where two attendees never speak
should not hold five pods. The claim happens on first selection.</li>
  <li><strong>A sandbox is held per persona, not per turn.</strong> Claiming per turn drains a pool
of two by the third turn of a four-person meeting, and everyone after that pays a
cold start or fails outright.</li>
</ul>

<p>Budget roughly <strong>8 CPU and 16 GiB allocatable</strong> for a full demo, most of it warm
pool sitting idle. It runs smaller by reducing warm replicas, at the cost of a
several-second pause the first time each persona speaks — worth knowing before
someone asks why idle pods are consuming quota.</p>

<h3 id="3-decide-what-a-sandbox-never-holds">3. Decide what a sandbox never holds</h3>

<p>The rule here is absolute: <strong>sandboxes never hold the application database
credential.</strong> Everything a persona needs arrives through a scoped internal API on
the backend, authenticated with the sandbox’s own projected ServiceAccount token —
validated by the API server, with identity read from pod labels rather than from a
request body the model composed.</p>

<p>Model calls go the same way. Sandboxes never call the inference provider; they reach
it through the backend’s <code class="language-plaintext highlighter-rouge">/internal/v1/llm</code> proxy, so a persona sandbox holds no
provider key and the provider can sit behind a CDN with no stable address.</p>

<p>Which was almost the neatest example in this whole piece of a control that reads as
closed and is not. Until recently that policy allowed <code class="language-plaintext highlighter-rouge">0.0.0.0/0</code> on TCP 443 and
6443, because the apiserver has to stay reachable — block it at the network and an
RBAC refusal becomes a connection timeout, which is worse than no control at all. But
443 is the whole HTTPS internet. The rule was a <em>port</em> restriction wearing the
costume of a destination one, and the comment beside it cheerfully claimed the
sandbox had no path off the cluster.</p>

<p>It does now. The policy is pinned to the apiserver’s own addresses — both the Service
ClusterIP and the real endpoints, because whether a NetworkPolicy implementation
matches one or the other depends on which side of DNAT it evaluates. A sandbox still
reaches the apiserver on the ClusterIP; a connection to <code class="language-plaintext highlighter-rouge">1.1.1.1:443</code> is now refused.</p>

<p>I mention it rather than quietly fixing it because it is the same failure this post
keeps circling: not a control that was missing, but one that was present, plausible,
and broader than its own documentation said.</p>

<h3 id="4-make-the-boundary-legible-from-outside">4. Make the boundary legible from outside</h3>

<p>Telemetry is optional here and off by default, on the grounds that observability
must never be why a system fails to start. When it is on, a single <code class="language-plaintext highlighter-rouge">traceparent</code> is
propagated over the sandbox RPC and again when a persona claims an exec sandbox, so
<strong>one meeting turn renders as one trace spanning all three trust boundaries.</strong></p>

<p>That is what makes <em>slow</em>, <em>denied</em> and <em>broken</em> three distinguishable outcomes
rather than one indistinguishable pause. A system where those three look identical
from the outside is not one you should grant more autonomy to, however good its
isolation is.</p>

<p>Two supporting choices: metric labels are low-cardinality by construction — a
persona’s <em>profile</em>, never its agent id — and <strong>denials are counted separately from
errors</strong>, because collapsing them would hide the one signal the project exists to
surface.</p>

<h3 id="5-deploys-that-report-success-and-change-nothing">5. Deploys that report success and change nothing</h3>

<p>Images are tagged by content digest, not <code class="language-plaintext highlighter-rouge">:latest</code>. A mutable tag leaves the
Deployment spec unchanged when the image is rebuilt, so <code class="language-plaintext highlighter-rouge">helm upgrade</code> finds nothing
to roll and the pod keeps serving stale code — a deploy that reports success and
changed nothing. Images are also cosign-signed with SLSA provenance, and <code class="language-plaintext highlighter-rouge">make
verify-images</code> answers “did this come from this repository’s CI”, which is a
different question from “did it change”.</p>

<p>Schema changes run as an Alembic Helm pre-upgrade hook rather than as a startup side
effect, so a migration is explicit and reviewable. Configuration is environment-only
— credentials from Secrets, operator-tunable values in a database table — and
attempting to set a credential through the settings API returns a 422.</p>

<h2 id="what-it-does-not-claim">What it does not claim</h2>

<p>Worth stating plainly, because demos in this space usually overclaim.</p>

<p>gVisor is a strong boundary, not a perfect one. The five layers stop specific,
enumerated things and the repo documents what each one measures. NetworkPolicy is
only a control if your CNI enforces it — on a cluster where it does not, two of the
five layers are decorative, and the preflight check says so rather than pretending
otherwise.</p>

<p>The backend is the part to be most honest about. It is not <em>assumed</em> trustworthy:
a compromised backend still cannot grant a persona more than its sandbox template
already provisions, because the runtime intersects what the backend asks for with
the capability list mounted into the pod. But it owns the conversation state, the
database credential, the orchestration and the model proxy, so compromising it
remains a serious event. What this design contains is untrusted <em>agent</em> execution.
It does not make the application as a whole compromise-resistant, and it trusts the
Kubernetes control plane outright.</p>

<p>Which is the honest version of the tagline. The cluster does not make the model
trustworthy, and it has nothing to say about what the model concludes, what it
emits, or what it is talked into by something it retrieved. It moves a specific set
of capability decisions below the model, into infrastructure that answers the same
way whether or not the model cooperates. For those decisions — and only those — the
model does not get the final say.</p>

<h2 id="trying-it">Trying it</h2>

<p>You need a cluster that can actually support the controls:</p>

<table>
  <thead>
    <tr>
      <th>Requirement</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Kubernetes 1.34+</td>
      <td><code class="language-plaintext highlighter-rouge">ImageVolume</code> for pgvector, and the floor CloudNativePG 1.30 supports</td>
    </tr>
    <tr>
      <td>A RuntimeClass with kernel-level isolation</td>
      <td>The boundary the design rests on</td>
    </tr>
    <tr>
      <td>Agent Sandbox v0.5.6+</td>
      <td>The <code class="language-plaintext highlighter-rouge">Sandbox</code> / <code class="language-plaintext highlighter-rouge">SandboxClaim</code> / <code class="language-plaintext highlighter-rouge">SandboxTemplate</code> API</td>
    </tr>
    <tr>
      <td>CloudNativePG 1.30+</td>
      <td>Postgres 18 with pgvector as a declarative extension</td>
    </tr>
    <tr>
      <td>Gateway API + a live GatewayClass</td>
      <td>The WebSocket upgrade the transcript needs</td>
    </tr>
    <tr>
      <td>A CNI that <strong>enforces</strong> NetworkPolicy</td>
      <td>Two of the five enforcement layers</td>
    </tr>
  </tbody>
</table>

<p>That version floor deserves a footnote, because two things set it and neither is
obvious.</p>

<p><code class="language-plaintext highlighter-rouge">ImageVolume</code> is how <em>this</em> demo gets pgvector into Postgres: the extension is
mounted from an OCI image, so its version is a value in the cluster manifest instead
of a Dockerfile you own and have to rebuild. It went alpha in Kubernetes 1.32 behind
a feature gate, beta and on by default in 1.33, and by 1.35 the gate is gone. So on
its own it would put the floor at 1.33.</p>

<p>CloudNativePG raises it. Version 1.30 lists Kubernetes 1.34, 1.35 and 1.36 as
supported; 1.31 through 1.33 are tested but explicitly not supported. Taking the
higher of the two, <strong>1.34+</strong> is the version to build against — and if you read an
earlier draft of this post or the repository saying 1.31+, that was wrong, and it is
the kind of number worth checking rather than inheriting.</p>

<p>None of this is load-bearing for the architecture. If you would rather bake pgvector
into a Postgres image the conventional way, that is a perfectly good route and it
relaxes the Kubernetes floor considerably — you would just be changing the
deployment, because this repo ships the one mechanism.</p>

<p>Then:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>kubectl helm kubeconform cosign uv
<span class="nb">cp </span>deploy/cluster/cluster.env.example deploy/cluster/cluster.env   <span class="c"># then edit</span>
make preflight
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">preflight</code> tells you what is missing and, for the requirements that can be present
and inert, whether they actually work. Install what it flags, then:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>deploy/cluster/install-prerequisites.sh all
make verify-images <span class="o">&amp;&amp;</span> make deploy <span class="o">&amp;&amp;</span> make seed
make operator-token
</code></pre></div></div>

<p>Inference is any OpenAI-compatible endpoint — Ollama on your own machine works for
development. There is deliberately no in-cluster model server: serving a model well
is a different problem from orchestrating agents, and running both on one cluster
makes the demo compete for CPU with the sandboxes it exists to serve.</p>

<p>The repo’s <a href="https://github.com/erdincka/meetings/blob/main/docs/architecture.md">architecture</a>,
<a href="https://github.com/erdincka/meetings/blob/main/docs/sandbox-security-model.md">sandbox security model</a>
and <a href="https://github.com/erdincka/meetings/blob/main/docs/lessons-learned.md">lessons learned</a>
docs go deeper than this piece does — the last of those is unusually candid about
controls that looked right and did nothing.</p>

<hr />

<h2 id="the-takeaway">The takeaway</h2>

<p>The agent-isolation problem is not new, and it is not really an AI problem. It is
the multi-tenancy problem, arriving through a new door: untrusted code, generated at
runtime, that wants credentials and network access.</p>

<p>Kubernetes has spent a decade building answers to that — namespaces, ServiceAccounts,
RBAC, NetworkPolicy, runtime classes, admission control — and Agent Sandbox is the
work of wiring agent workloads into them rather than inventing a parallel permission
system inside a Python framework.</p>

<p>For an infrastructure architect, that reframing is the useful part. You do not need
to become an ML engineer to have an opinion about how agents should be run. You need
to insist that the enforcement live where you can see it, test it, and show it to an
auditor — and then treat “the prompt says not to” as what it is, which is a comment.</p>]]></content><author><name>Erdinc Kaya</name></author><category term="kubernetes" /><category term="ai" /><category term="agents" /><category term="security" /><summary type="html"><![CDATA[Prompt-based restrictions are a request, not a control. What it takes to move agent permissions into Kubernetes, and what running it actually teaches you.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://erdincka.github.io/assets/img/social-card.png" /><media:content medium="image" url="https://erdincka.github.io/assets/img/social-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>