<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://tianpan.co/blog</id>
    <title>TianPan.co</title>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://tianpan.co/blog"/>
    <subtitle>Actionable essays, playbooks, and investor-grade memos on product, engineering leadership, and SaaS—so you ship faster and decide with conviction.</subtitle>
    <icon>https://tianpan.co/favicon.ico</icon>
    <rights>All rights reserved 2026, Tian Pan</rights>
    <entry>
        <title type="html"><![CDATA[How PII Redaction Sentinels Quietly Collapse Your Vector Index]]></title>
        <id>https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index</id>
        <link href="https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Privacy redactors that replace PII with sentinel tokens can silently dominate your embedding geometry, collapsing every redacted document into a single hub of the vector index and degrading retrieval where no benchmark is watching.]]></summary>
        <content type="html"><![CDATA[<p>A support engineer pulled up your RAG console to debug a complaint. The customer had asked "what does my account look like right now," the answer had come back coherent and confident, and it had been about somebody else's account entirely. The top-3 retrieved chunks all belonged to other customers. The engineer ran the same query against a fresh corpus snapshot to rule out indexing lag. Same result. Then she ran it against a snapshot from six months ago, before the privacy redactor had shipped. The right customer's chunk came back at rank 1.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=How%20PII%20Redaction%20Sentinels%20Quietly%20Collapse%20Your%20Vector%20Index" alt="" class="img_ev3q"></p>
<p>The redactor was working as designed. Every name was a <code>[NAME]</code>, every email an <code>[EMAIL]</code>, every account number an <code>[ACCOUNT]</code>. The legal team had a clean audit trail and the security team had a closed compliance ticket. What nobody on either team had modeled was that those sentinels, dropped into the same syntactic slots across millions of documents, were being seen by the embedding model as ordinary tokens — tokens that co-occurred more reliably with each other than any real content did. The redactor had not just removed information. It had added a new, very strong signal that every redacted document shared and nothing else did.</p>
<p>The retrieval index did exactly what retrieval indices do. It found the documents most similar to the query. And once enough records had been pushed through the redactor, the most similar documents were not the ones with the most relevant content — they were the ones with the most redactor artifacts. The top-k results started to look like a uniform sludge of other customers' redacted records, all sitting in a tight neighborhood the embedding model had inadvertently learned to recognize. The system was doing privacy correctly and retrieval wrong, and the two failures were the same failure seen from different angles.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-seam-between-privacy-and-retrieval-nobody-owns">The Seam Between Privacy and Retrieval Nobody Owns<a href="https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#the-seam-between-privacy-and-retrieval-nobody-owns" class="hash-link" aria-label="Direct link to The Seam Between Privacy and Retrieval Nobody Owns" title="Direct link to The Seam Between Privacy and Retrieval Nobody Owns" translate="no">​</a></h2>
<p>The reason this failure is so durable is that no single team has end-to-end visibility into it. Privacy redaction is owned by security or legal. They evaluate the redactor on precision and recall against a PII detection benchmark: did it catch the names, did it leave the non-names alone. The benchmark says yes, and the team moves on.</p>
<p>Retrieval quality is owned by the AI platform team. They evaluate the embedder on a clean test set: queries with known relevant documents, NDCG at 10, mean reciprocal rank. The benchmark says nothing has regressed, because the test set was built before the redactor existed and the redactor was not in the loop when the evals ran.</p>
<p>The redactor's second-order effect on embedding geometry sits squarely between the two teams. Security has no model of what a transformer does with a <code>[NAME]</code> token. The platform team has no model of how often <code>[NAME]</code> will appear in a document or how many other sentinels it will appear next to. The seam is invisible to both org charts and to both eval suites, and the symptom — retrieval returning records that share nothing semantically except their redaction history — only shows up at serving time, in production, on real customer queries, where neither team is looking.</p>
<p>Some teams catch this when the support volume spikes. Many do not catch it at all, and instead conclude that "the embedding model is just bad on our data" and start shopping for a replacement embedder. The replacement embedder, of course, exhibits the same behavior on the same redacted corpus, because the problem was never the embedder.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-embedding-model-treats-sentinels-as-strong-signal">Why the Embedding Model Treats Sentinels as Strong Signal<a href="https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#why-the-embedding-model-treats-sentinels-as-strong-signal" class="hash-link" aria-label="Direct link to Why the Embedding Model Treats Sentinels as Strong Signal" title="Direct link to Why the Embedding Model Treats Sentinels as Strong Signal" translate="no">​</a></h2>
<p>A transformer embedding model is trained to produce vectors that are close together when the underlying texts are semantically similar. "Semantically similar" in practice means "tend to appear in similar contexts." The training objective rewards the model for noticing reliable co-occurrence patterns and embedding them as geometric proximity.</p>
<p>A redactor sentinel is, from the model's perspective, an extremely reliable token. <code>[NAME]</code> appears next to <code>[EMAIL]</code> constantly. <code>[ACCOUNT]</code> appears near <code>[NAME]</code> constantly. The phrases around them are also templated by the redactor — "the customer, <code>[NAME]</code>, with email <code>[EMAIL]</code>, has an account <code>[ACCOUNT]</code>" repeats with mechanical consistency across millions of records. The model picks up on this and learns a strong "this is a redacted customer record" representation. That representation is geometrically tighter than any of the actual semantic clusters in your corpus, because the actual semantic clusters are noisier and less templated than your redactor's output.</p>
<p>The result is what the literature on high-dimensional spaces calls hubness — a small region of the embedding space that nearest-neighbor search keeps returning. Hubness is a well-studied pathology of nearest-neighbor retrieval in high dimensions: a few points become close to a disproportionate share of all queries, and retrieval quality degrades because those points are returned over and over while semantically relevant points get pushed out of the top-k. The redaction artifact is functionally a hubness-inducing feature you injected into your own corpus.</p>
<p>What makes this worse is that the queries are usually not redacted. A user asking "what does my account look like" types the unredacted question. The query vector is positioned by the embedder according to ordinary semantic content. The corpus vectors are positioned by the embedder according to ordinary semantic content plus a pile of artificial co-occurrences from the redactor. The asymmetry means the queries that should match real records get pulled toward the redaction cluster, because that cluster is dense and central and your real records are not.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-audit-you-have-to-invent">The Audit You Have To Invent<a href="https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#the-audit-you-have-to-invent" class="hash-link" aria-label="Direct link to The Audit You Have To Invent" title="Direct link to The Audit You Have To Invent" translate="no">​</a></h2>
<p>No standard observability surface in your stack will tell you that your top-1 results are 80% redaction-artifact and 20% semantic. Vector databases report latency, recall against a known set, and storage utilization. Embedding pipelines report throughput and dimension. Retrieval evals report NDCG against benchmarks that were never designed with redactor sentinels in mind.</p>
<p>You have to build the audit yourself, and the team that has to build it is whichever team is closest to the customer complaint when it arrives. A workable starting point looks like this:</p>
<ul>
<li class="">Sample a few hundred production queries and pull the top-k for each.</li>
<li class="">For each retrieved chunk, count the density of known sentinel patterns (<code>[NAME]</code>, <code>[EMAIL]</code>, <code>[ACCOUNT]</code>, plus whatever bespoke ones your redactor uses).</li>
<li class="">Compare that density against the corpus-wide average density.</li>
<li class="">If the top-k chunks have systematically higher sentinel density than a random sample of the corpus, you have artifact contamination — the index is sorting on the redactor's vocabulary rather than your content.</li>
</ul>
<p>A second, sharper test: take a redacted chunk and a non-redacted chunk that you know describe the same real customer fact. Embed both. Measure the distance. Then measure the distance between two redacted chunks describing totally unrelated customer facts. If the unrelated-but-both-redacted pair is closer than the same-content-different-redaction-state pair, you have proven the embedder is treating redaction state as a stronger signal than content. That is your problem in its purest form, and once you can produce that demonstration, both the privacy team and the platform team will finally understand they are looking at the same bug.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="patterns-that-close-the-gap">Patterns That Close the Gap<a href="https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#patterns-that-close-the-gap" class="hash-link" aria-label="Direct link to Patterns That Close the Gap" title="Direct link to Patterns That Close the Gap" translate="no">​</a></h2>
<p>Once the problem is named, the fixes split along three different time horizons.</p>
<p>The most direct fix is to make the redactor produce sentinels that do not all collide in token space. Instead of using a single <code>[NAME]</code> for every redaction, hash the original value (under a key the security team holds) into a token from a large vocabulary of plausible-looking but meaningless sentinels — <code>[NAME-7a2c]</code>, <code>[NAME-d191]</code>, and so on. The embedding model now sees a diverse population of tokens in those slots, the templated co-occurrence pattern dissolves, and the hubness collapses. The privacy property is preserved because the hash is non-reversible without the key. The cost is a slightly larger token vocabulary and a redactor that has to carry a hashing dependency. The benefit is that the geometry of your vector index goes back to measuring meaning.</p>
<p>A more careful long-term fix is embedding-aware redaction. Before deploying a new redactor or a new sentinel scheme, run a synthetic eval that measures the cluster impact of the proposed sentinels — embed a representative slice of the corpus with and without redaction, measure the change in pairwise distance distribution and the change in hubness statistics, and gate the deployment on those numbers the same way you gate it on PII recall. This makes the redactor a first-class consumer of retrieval metrics, which is the only way to break the org-chart seam permanently.</p>
<p>The retrieval-time mitigation is the cheapest to bolt on and the easiest to get wrong. You can down-weight matches that score highly because of sentinel density, either by post-filtering the top-k or by training a small reranker that has explicitly seen redacted text. The risk is that you also down-weight legitimately redacted records that the user actually wants. A user asking about their own account does want to retrieve their own redacted record. The reranker has to learn to distinguish "redaction is part of why this matched" from "redaction is irrelevant to why this matched," which is a real problem that "just penalize sentinels" does not solve. Treat retrieval-time fixes as the short-term tourniquet, not the long-term answer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-generalizes-to">What This Generalizes To<a href="https://tianpan.co/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#what-this-generalizes-to" class="hash-link" aria-label="Direct link to What This Generalizes To" title="Direct link to What This Generalizes To" translate="no">​</a></h2>
<p>The PII redactor is one instance of a broader pattern that shows up whenever a pipeline component upstream of the embedder produces structurally repetitive output. Templated boilerplate from a CMS does this. Auto-generated header and footer text in PDFs does this. A summarization step that always opens with "this document discusses" does this. Anything that injects a consistent, low-entropy pattern into a large fraction of your corpus is a potential hubness source, and the embedder will treat it as the strongest feature in the room.</p>
<p>The lesson is not "be afraid of redaction" or "stop sanitizing your data." The lesson is that an embedding model is a sensitive instrument that responds to whatever is most reliably present in its input, and that "most reliably present" is rarely the same thing as "most informative." Every pipeline stage upstream of the embedder is, whether the team owning it knows it or not, a participant in your retrieval quality. The teams that ship durable RAG systems are the ones that treat retrieval geometry as a shared concern, audit it the way they audit latency and cost, and check the embedding space for artifacts the same way a database team checks an index for bloat.</p>
<p>The customer whose account got swapped for somebody else's deserved better than a system that confidently retrieved the wrong record. The fix was not a smarter embedder. The fix was noticing that the redactor had become a louder signal than the content, and giving both teams a shared way to see it.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="embeddings" term="embeddings"/>
        <category label="privacy" term="privacy"/>
        <category label="retrieval" term="retrieval"/>
        <category label="vector-search" term="vector-search"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The MCP Tool List Grew Mid-Session and Your Agent Called a Tool It Had Never Been Told About]]></title>
        <id>https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination</id>
        <link href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[An MCP server can grow its tool list between two calls in the same session, and the agent's next selection can land on a tool the client was never told about — a hallucination that resolves to a real action.]]></summary>
        <content type="html"><![CDATA[<p>A security incident review opens with a question the team cannot answer: how did the agent learn the name of the tool it just called? The audit trail shows a <code>tools/call</code> for a tool whose name does not appear in any <code>tools/list</code> response the harness logged. The MCP server cheerfully accepted the call and executed it. The model, asked in a postmortem to explain where the tool name came from, offers no answer because there is none — it guessed, and the guess landed on a real action.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20MCP%20Tool%20List%20Grew%20Mid-Session%20and%20Your%20Agent%20Called%20a%20Tool%20It%20Had%20Never%20Been%20Told%20About" alt="" class="img_ev3q"></p>
<p>This is the failure mode at the seam between two assumptions that look compatible on paper. The client treats the tool list as a contract that names the surface area of authority it has been granted. The server treats the tool list as a snapshot of what is currently available, free to grow when the world grows. Between those two views, the LLM is a bridge that does not know the difference.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-list-once-habit-that-mcp-encouraged">The List-Once Habit That MCP Encouraged<a href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#the-list-once-habit-that-mcp-encouraged" class="hash-link" aria-label="Direct link to The List-Once Habit That MCP Encouraged" title="Direct link to The List-Once Habit That MCP Encouraged" translate="no">​</a></h2>
<p>Most agent harnesses call <code>tools/list</code> exactly once per session. The reasons are pragmatic. Tool descriptions are large; serializing them into every prompt is expensive in tokens. The list rarely changes within a single conversation, so caching it is the obvious optimization. Several popular client SDKs ship with <code>cache_tools_list=True</code> as the default, and most production agents leave it there.</p>
<p>The MCP spec acknowledges that lists can change. Servers that declare the <code>listChanged</code> capability are expected to emit <code>notifications/tools/list_changed</code> when the available set shifts, and clients that receive the notification are supposed to refetch. The protocol diagram looks tidy: discover, invoke, notify on change, rediscover.</p>
<p>What the diagram does not show is what happens when the server adds a tool and the client does not refetch. Maybe the client is between turns and the notification arrived during a model call. Maybe the harness is a stateless wrapper that ignores notifications because it treats <code>tools/list</code> as cacheable on a TTL. Maybe the server is one of the many implementations that has the <code>listChanged</code> capability flag set to true but never actually fires the notification because the underlying integration was bolted on later and nobody wired the event. The client has, in all three cases, the same outcome: it is operating on a stale view of the server's authority.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-llms-confabulation-hits-real-tools">Why the LLM's Confabulation Hits Real Tools<a href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#why-the-llms-confabulation-hits-real-tools" class="hash-link" aria-label="Direct link to Why the LLM's Confabulation Hits Real Tools" title="Direct link to Why the LLM's Confabulation Hits Real Tools" translate="no">​</a></h2>
<p>The naive picture of an LLM hallucinating a tool name imagines the model inventing a string that does not exist. The model is then corrected by a protocol error, the agent retries with a tool that does exist, and the audit trail is clean.</p>
<p>The dangerous picture is the one where the model invents a string that does exist. Tool names converge on a small surface. A workspace integration that connects to a project tracker is overwhelmingly likely to expose a tool called something like <code>create_issue</code>, <code>update_issue</code>, <code>list_issues</code>. If the model has been trained on enough public MCP servers — and it has — it will guess one of those names when the context implies an issue tracker is connected. If the server just added that integration five seconds ago and the client has not seen the new tool list, the model's guess lands.</p>
<p>The server has no reason to refuse. From its point of view, the tool exists, the caller is authenticated, the call shape is valid. There is no field in the <code>tools/call</code> request that asserts "I was told this tool existed." The server cannot verify that claim if there were one, because the protocol carries no signed receipt for past <code>tools/list</code> responses.</p>
<p>What looks in the trace like the agent calling a tool is actually the agent calling a name. The name happened to resolve to a tool. The audit trail loses the distinction.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-notification-channel-that-does-not-always-work">The Notification Channel That Does Not Always Work<a href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#the-notification-channel-that-does-not-always-work" class="hash-link" aria-label="Direct link to The Notification Channel That Does Not Always Work" title="Direct link to The Notification Channel That Does Not Always Work" translate="no">​</a></h2>
<p>The spec's answer to mid-session mutation is <code>notifications/tools/list_changed</code>. In a well-behaved system this should close the gap: the server tells the client when the surface changes; the client refetches; the agent's next turn sees the new list and the LLM's selection is grounded in something real.</p>
<p>In practice, the notification channel is the weakest part of the design. A few reasons:</p>
<ul>
<li class=""><strong>Transport ambiguity.</strong> The 2025-06-18 spec assumes a persistent connection (stdio, SSE) where notifications can be pushed. The 2026-07-28 release candidate moves toward a stateless HTTP core that scales on ordinary infrastructure. In stateless mode the server has nowhere to push notifications, so the client must poll or rely on cache headers. Many real deployments mix transports — a stateless gateway in front of a stateful server — and the notification gets dropped at the seam.</li>
<li class=""><strong>Capability advertising as theater.</strong> Servers declare <code>listChanged: true</code> because the SDK template has that field set. Nothing tests whether the notification actually fires when the list mutates. The capability bit becomes documentation of an intention, not a fact about behavior.</li>
<li class=""><strong>Client-side dispatch races.</strong> The harness receives the notification mid-turn, while a tool call is already in flight or while the model is generating. The naive implementation queues the refetch for "after the current turn." The current turn finishes by calling a tool, and the tool name was selected against the pre-notification list, but resolved against the post-notification surface.</li>
</ul>
<p>The 2026 release candidate's introduction of <code>ttlMs</code> and <code>cacheScope</code> fields on list responses helps with the polling case but does nothing for the dispatch race. A list with a one-second TTL still has a one-second window where the client's understanding lags the server's truth.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-the-authority-question-lives">Where the Authority Question Lives<a href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#where-the-authority-question-lives" class="hash-link" aria-label="Direct link to Where the Authority Question Lives" title="Direct link to Where the Authority Question Lives" translate="no">​</a></h2>
<p>The architectural mistake worth naming is treating the tool list as an inventory rather than a grant. An inventory is "here is what exists." A grant is "here is what I have authorized you to call." Those two ideas are not the same, and the protocol conflates them.</p>
<p>When the LLM picks a tool, the decision should be bounded by the grant, not the inventory. The grant is what the user or operator agreed to when they connected the server, plus whatever they were told about in the discovery step. The inventory is what the server happens to be exposing right now. A tool that joined the inventory after the grant was issued is not part of the grant. A model that calls it is, from a security review's perspective, taking an unauthorized action — even if the server accepts the call without complaint.</p>
<p>The protocol has no built-in way to encode this distinction. The <code>tools/call</code> request carries a name, not a reference to a specific <code>tools/list</code> response. The server does not know which version of the list the client was looking at. The client does not know which version of the list the server is now serving.</p>
<p>Closing the gap requires explicit work at both ends.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="patterns-that-close-the-gap">Patterns That Close the Gap<a href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#patterns-that-close-the-gap" class="hash-link" aria-label="Direct link to Patterns That Close the Gap" title="Direct link to Patterns That Close the Gap" translate="no">​</a></h2>
<p>A handful of patterns address different parts of the failure, and most production deployments need more than one.</p>
<ul>
<li class=""><strong>List-version pinning in tool calls.</strong> Extend <code>tools/call</code> to carry the version (or hash) of the <code>tools/list</code> response the caller is operating against. The server rejects calls that reference a stale or unknown version. This is the cleanest fix — it makes the grant explicit in every invocation — but it requires both sides to implement the extension, and the spec does not yet mandate it.</li>
<li class=""><strong>Refetch on every turn for high-mutation servers.</strong> Tag servers in the harness configuration as "high-mutation" and refetch their tool list at the start of every agent turn. The latency cost is real (an extra round trip per server per turn), but for servers like project trackers or codebase tools where the underlying surface genuinely shifts, the cost is the price of a faithful tool list.</li>
<li class=""><strong>Server-side grant enforcement.</strong> Have the server track, per session, which tools have been disclosed to the caller. A call to a tool that was added after the most recent <code>tools/list</code> for that session gets a soft-fail response that prompts the client to re-list before retrying. This is server-side enforcement of the grant model; it does not require client cooperation.</li>
<li class=""><strong>Reject-on-novel in the harness.</strong> Before the harness forwards a <code>tools/call</code> to the server, check the requested tool name against the cached list. If it is not present, refuse the call before it leaves the harness — even if the LLM is convinced the tool exists. The model's belief that a tool exists is not evidence that it does, and the harness has the cached list as authoritative.</li>
<li class=""><strong>Notification-driven cache invalidation with synchronous flush.</strong> When a <code>notifications/tools/list_changed</code> arrives, do not queue the refetch — block the next outbound call until the new list is in hand. Trading a small latency hit for surface coherence is the right side of the tradeoff.</li>
<li class=""><strong>Per-tool auditability with disclosure receipts.</strong> Log, for every tool call, the timestamp of the <code>tools/list</code> response that disclosed the tool to the caller. If no such response exists, the call is a security event by definition. The audit trail then answers the security team's question — "how did the agent learn about this tool" — even when the answer is "it didn't."</li>
</ul>
<p>The patterns trade latency against coherence. Refetching every turn is slower but truer. Pinning list versions is more cooperative but requires bilateral implementation. Reject-on-novel is unilateral but punishes valid use cases where the model usefully recovers from a partial list.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-says-about-tool-surfaces-as-contracts">What This Says About Tool Surfaces as Contracts<a href="https://tianpan.co/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#what-this-says-about-tool-surfaces-as-contracts" class="hash-link" aria-label="Direct link to What This Says About Tool Surfaces as Contracts" title="Direct link to What This Says About Tool Surfaces as Contracts" translate="no">​</a></h2>
<p>The mental model worth installing is that the tool surface a server exposes is not a static catalog but a continuously revised contract. Every change to the surface is a change to what the agent is authorized to do. Treating the surface as cacheable indefinitely is treating an active contract as a museum exhibit.</p>
<p>This is not unique to MCP. Any system where the set of available actions can change mid-session and the calling layer caches the set faces the same gap — REST API gateways, GraphQL schemas with dynamic field exposure, RPC services with feature flags on individual methods. MCP makes the gap worse only because the caller is an LLM whose hallucinations are unusually good at producing names that happen to be real.</p>
<p>The teams that ship MCP integrations safely will be the ones that stop thinking of <code>tools/list</code> as a discovery step and start thinking of it as an authorization step that needs to be repeated whenever the underlying authorization could have changed. The harness that refetches the list before a sensitive call has not added latency — it has added a contract check. The audit log that captures which list version authorized each call is not extra paperwork — it is the only way to distinguish "the agent acted within its grant" from "the agent guessed a real name."</p>
<p>The tool list is not what the server can do. It is what the agent is allowed to ask the server to do. Treat it that way, and the gap closes. Treat it as inventory, and you ship an agent whose hallucinations can buy real things.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="mcp" term="mcp"/>
        <category label="ai-agents" term="ai-agents"/>
        <category label="security" term="security"/>
        <category label="protocol-design" term="protocol-design"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The A/B Test Winner Whose Verbose Output Triggered Your Click Handler More Than the Better Answer]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Why prompt experiments on engagement metrics tend to ship the longer variant, and the patterns that decouple response shape from response quality before a satisfaction regression forces a reckoning.]]></summary>
        <content type="html"><![CDATA[<p>A prompt-variant experiment runs on the production traffic of an AI-assisted search product. The success metric is a click on any suggested action in the response. Variant B ships responses that are roughly forty percent longer with more enumerated options. The click-through rate is eleven percent higher with three nines of statistical significance. The experiment is declared a winner and shipped.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20A%2FB%20Test%20Winner%20Whose%20Verbose%20Output%20Triggered%20Your%20Click%20Handler%20More%20Than%20the%20Better%20Answer" alt="" class="img_ev3q"></p>
<p>A month later, the weekly customer satisfaction survey drops two points. Nobody connects it to the launch because the experiment has already been written up as a success and the team has moved on. A quarterly review eventually traces the satisfaction drop back to the prompt change, and the diagnosis lands hard: variant B won not because it gave users better answers but because longer answers contained more clickable surfaces. The click handler fired more often per impression because there was more to click, not because what the user read was more worth acting on.</p>
<p>The mistake was not in the statistics. The p-value was real, the lift was real, the sample size was honest. The mistake was that the success metric measured the shape of the response, and the shape of the response was something the prompt variant could change directly without changing the underlying quality. The experiment was a fair fight on the wrong axis.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-shape-wins-without-anyone-cheating">How Shape Wins Without Anyone Cheating<a href="https://tianpan.co/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer#how-shape-wins-without-anyone-cheating" class="hash-link" aria-label="Direct link to How Shape Wins Without Anyone Cheating" title="Direct link to How Shape Wins Without Anyone Cheating" translate="no">​</a></h2>
<p>Engagement metrics on AI products carry a hidden coupling between the surface area of the output and the probability of any single engagement event firing. Click-through rate, action invocation rate, suggested-followup acceptance rate — each of these is computed against an event that the response itself produces. A response with three suggested actions has three chances to trigger the metric. A response with seven has seven. The user did not become more interested; the response became more clickable.</p>
<p>This is not unique to LLMs. Product teams have measured engagement on long-form content for years and have known that pagination, infinite scroll, and recommendation carousels all inflate engagement counts by inflating the inventory of things to engage with. What is new is how cheaply an LLM can change the output's shape. A one-line prompt edit can take an answer from three bullets to ten. There is no design review, no engineering ticket, no UX trade-off to negotiate. The variation space of "how much surface area does the response present" is wide open to any prompt experiment, and any metric tied to per-impression engagement will silently reward expansion until something else breaks.</p>
<p>The phenomenon also extends beyond clicks. Time-on-page goes up when responses are longer to read. Copy-to-clipboard rates rise when there are more discrete blocks to copy. Thumbs-up button presses can increase because verbose answers feel more "complete" even when they are wrong in the same places a short answer would be wrong, just at greater length. Anything you measure that is downstream of the response producing more text will reward producing more text.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-length-bias-has-a-familiar-provenance">Why The Length Bias Has a Familiar Provenance<a href="https://tianpan.co/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer#why-the-length-bias-has-a-familiar-provenance" class="hash-link" aria-label="Direct link to Why The Length Bias Has a Familiar Provenance" title="Direct link to Why The Length Bias Has a Familiar Provenance" translate="no">​</a></h2>
<p>If the verbosity-rewards-itself failure mode sounds familiar, it should. The LLM evaluation community has been wrestling with length bias in judge-based comparisons for years. Models like GPT-4, when asked to pick between two candidate responses, systematically prefer the longer one even when an explicit rubric tells them to value brevity. The bias is robust enough that practitioners now publish length-normalized win rates as a matter of routine, and the literature has named the phenomenon explicitly.</p>
<p>The same dynamic is showing up one layer out, in product analytics. The judge in the production case is not an LLM; it is the click handler. The mechanism is different — a user clicks more because there are more buttons, not because they cognitively prefer longer text — but the failure mode is structurally identical. A metric that is downstream of response shape is going to be biased toward the response shape that maximizes its own opportunities to fire. The team that ships the longer variant has discovered the product version of the same length bias that has been distorting LLM-as-judge benchmarks since the chat era began.</p>
<p>This connection matters because it tells you the fix is not a one-off correction for one experiment. The fix has to be a property of the experimentation system itself. Any team running prompt or model variants against an engagement metric on a product whose output shape is variable is exposed. The longer variant will tend to win. The team that does not name this dynamic as a covariate of every experiment will keep shipping verbosity until a downstream satisfaction signal forces a reckoning months later.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-metric-was-doing-exactly-what-it-was-asked-to">The Metric Was Doing Exactly What It Was Asked To<a href="https://tianpan.co/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer#the-metric-was-doing-exactly-what-it-was-asked-to" class="hash-link" aria-label="Direct link to The Metric Was Doing Exactly What It Was Asked To" title="Direct link to The Metric Was Doing Exactly What It Was Asked To" translate="no">​</a></h2>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="ab-testing" term="ab-testing"/>
        <category label="llm-evaluation" term="llm-evaluation"/>
        <category label="product-metrics" term="product-metrics"/>
        <category label="experimentation" term="experimentation"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Agent Memory Store That Survived Your Tenant Deletion Because Nobody Owned It]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A correct deletion saga is no defense when the inventory it iterates over is stale. Why your tenant-deletion guarantee silently decays with every release that ships a new persistent store nobody filed.]]></summary>
        <content type="html"><![CDATA[<p>A compliance program is a description of the systems your company had on the day the auditor signed off. The systems your company has today are a different set, and the gap is the surface area of every release that shipped a new persistent store between then and now. The deletion guarantee you sold your customers is a guarantee against the first set, and the regulator who eventually asks about it will be asking about the second.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Agent%20Memory%20Store%20That%20Survived%20Your%20Tenant%20Deletion%20Because%20Nobody%20Owned%20It" alt="" class="img_ev3q"></p>
<p>The failure mode is not a bug in the deletion code. The deletion code is correct. The saga fans out across every storage system named in the data inventory, calls each one's erasure endpoint, collects a receipt per system, and reports success when every receipt comes back signed. The saga is doing exactly what it was built to do. The problem is that the saga is iterating over a list of storage systems that was true eighteen months ago, and the agent platform team shipped a long-term memory feature six months ago that nobody added to the list.</p>
<p>This is the gap that takes down a deletion guarantee: the system-of-record for "what stores tenant data" and the system-of-record for "what systems exist" are different artifacts, maintained by different teams, on different cadences. When they agree, the deletion saga is complete. When they disagree, the deletion saga is complete <em>against the world the inventory describes</em> and not against the world that exists. The disagreement is invisible until a regulator asks.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-inventory-is-a-snapshot-not-a-live-index">The inventory is a snapshot, not a live index<a href="https://tianpan.co/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it#the-inventory-is-a-snapshot-not-a-live-index" class="hash-link" aria-label="Direct link to The inventory is a snapshot, not a live index" title="Direct link to The inventory is a snapshot, not a live index" translate="no">​</a></h2>
<p>Most data inventories are built once, audited once, and then expected to stay accurate by social contract. The compliance program launches with a workshop where every team walks through their storage systems and an analyst types them into a spreadsheet or a GRC tool. The spreadsheet gets reviewed against the schema registry, the auditor signs off on the coverage, and the inventory becomes the authoritative answer to "what storage systems does this product use." The update process is a manual ticket: when you ship a new persistent store, you're supposed to file a ticket on the compliance team's backlog, and they're supposed to add an entry to the inventory.</p>
<p>The update process fails the same way every manual cross-team process fails. The team shipping the storage system has a sprint deadline. The compliance team has a backlog. The ticket gets filed, prioritized as "ongoing housekeeping," and sits behind the quarter's audit prep. Months pass. The next quarter's compliance review doesn't catch the gap because the review reconciles the inventory against the previous audit, not against production. The inventory is internally consistent and externally wrong.</p>
<p>The pattern that compounds the problem is that the most common new storage systems in an AI-heavy product are exactly the ones least likely to get filed. A relational database with a tenant_id column gets inventoried immediately because the schema review forces it. A vector store keyed by a synthetic agent-session-id that joins to tenant_id through a separate table looks, to the reviewer, like an internal cache. A KV store of agent state that the engineer described in the design doc as "ephemeral working memory" is in fact retained for six months because the eviction policy was tuned for retrieval quality. None of these get filed because none of them feel like systems-of-record for personal data, and the engineer who shipped them does not have the compliance vocabulary to recognize that they are.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-saga-succeeds-against-the-inventory-it-was-given">The saga succeeds against the inventory it was given<a href="https://tianpan.co/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it#the-saga-succeeds-against-the-inventory-it-was-given" class="hash-link" aria-label="Direct link to The saga succeeds against the inventory it was given" title="Direct link to The saga succeeds against the inventory it was given" translate="no">​</a></h2>
<p>The deletion saga is a model of architectural correctness. It reads from a config (the inventory), iterates over its entries, calls each system's deletion endpoint with the tenant ID, waits for an acknowledgement, and aggregates the receipts into a final report. The saga is testable, retryable, and observable. Every step emits a metric. The runbook is clear. When the saga reports success, the on-call engineer pages back to bed.</p>
<p>The saga's correctness is the problem. Because the saga is correct against its input, the place where the deletion silently fails is upstream of any code the saga is responsible for. There is no exception, no failed receipt, no metric anomaly. The saga writes a clean record to the audit log that says "tenant X deleted across all systems." The audit log is what the legal team shows the regulator. The regulator reads the log, sees a clean trail, and the inquiry moves on. Years later, when a different audit (often a DSAR from an unrelated customer that happens to surface a related-tenant snippet in an agent response) exposes the gap, the legal team's first question is "did the deletion run?" The audit log says yes. The vector store says yes-ish. The reconciliation between those two answers is what nobody owns.</p>
<p>This is the contract-vs-implementation gap restated: the contract says "we delete on request," the saga implements "we call deletion on every system in the inventory," and the gap between contract and implementation is exactly the difference between the inventory and reality. Most teams do not have a job that closes that gap. They have a quarterly audit that re-certifies the inventory, but the audit looks at what is in the inventory, not at what should be in the inventory and isn't. The audit is structurally incapable of finding storage systems it does not know about.</p>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="compliance" term="compliance"/>
        <category label="agent-memory" term="agent-memory"/>
        <category label="gdpr" term="gdpr"/>
        <category label="data-inventory" term="data-inventory"/>
        <category label="platform-engineering" term="platform-engineering"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Agent Timeout Your Users Learned to Game for Refunds]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A generous timeout-refund policy on an agent platform created a behavioral cohort that systematically gamed the boundary, doubled the timeout rate, and disguised itself as a quality regression.]]></summary>
        <content type="html"><![CDATA[<p>A platform shipped a thirty-minute wall-clock cap on long-running agent tasks, paired with a refund policy that returned the token spend on any task that hit the timeout without producing a deliverable. The intent was protective: a hung agent should not bill the customer. Six months later, the timeout rate had doubled, the engineering team was deep in an "agent reliability" investigation, and the support queue was full of users complaining that the agent "keeps timing out" — with screenshots that showed the user's own browser tab closing at twenty-nine minutes and change.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Agent%20Timeout%20Your%20Users%20Learned%20to%20Game%20for%20Refunds" alt="" class="img_ev3q"></p>
<p>The unit economics had quietly inverted on a behavioral cohort the finance model never named. The refund population was not a quality population. It was a strategy.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-boundary-you-reimburse-against-is-a-target">The Boundary You Reimburse Against Is a Target<a href="https://tianpan.co/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds#the-boundary-you-reimburse-against-is-a-target" class="hash-link" aria-label="Direct link to The Boundary You Reimburse Against Is a Target" title="Direct link to The Boundary You Reimburse Against Is a Target" translate="no">​</a></h2>
<p>Any operational threshold a platform reimburses against becomes a coordinate users can learn to land on. This is not new in cloud or SaaS — SLA credits, refund windows, and free-tier ceilings have been gamed for decades — but agent platforms have a uniquely sharp version of the problem because the cost of one task is large enough to be worth optimizing against individually.</p>
<p>The arithmetic, from the user's perspective, is simple. A complex multi-hour task that completes will bill at the agent's full token spend. The same task interrupted at the twenty-eight-minute mark hits the refund boundary, returns most of that spend, and the resulting in-context state can be picked up cheaply in a follow-up turn that does not start from zero. A user who runs ten of these workflows a week and has done the math will deliberately steer toward the boundary on every expensive run.</p>
<p>Power users find the seam first. They are the cohort with the highest dollar exposure, the highest motivation to read the policy carefully, and the operational sophistication to restructure a workflow around an envelope. They are also the cohort the platform least wants to lose, which is why the refund policy was generous to begin with.</p>
<p>The platform's response curve gets the directionality exactly wrong. Timeouts go up, so the reliability team investigates the agent. The agent is fine. The bill for refunds rises, and finance treats it as elevated platform failure cost. The on-call sees no incident. Every dashboard reports a problem with a different etiology than the one actually firing, because the dashboards were designed against a model of the user as a passive recipient of agent behavior rather than a participant in a pricing game.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-failure-mode-is-specific-to-agent-platforms">Why This Failure Mode Is Specific to Agent Platforms<a href="https://tianpan.co/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds#why-this-failure-mode-is-specific-to-agent-platforms" class="hash-link" aria-label="Direct link to Why This Failure Mode Is Specific to Agent Platforms" title="Direct link to Why This Failure Mode Is Specific to Agent Platforms" translate="no">​</a></h2>
<p>Traditional SaaS gives users a few coarse levers — cancel, downgrade, dispute a charge. Agent platforms expose much finer-grained behavioral controls that map directly onto unit cost. The user can decide when to abort a task, how to phrase the next message, whether to spawn a subagent, when to commit to a tool call. Each of these is a knob on the bill.</p>
<p>The timeout-refund game is structurally similar to the cost-attack class that has been documented against cloud APIs over the last two years: a pricing surface that scales with usage creates an incentive to push usage in a direction the platform did not intend. The difference with agents is that the optimizer is a customer, not an attacker, and the action is not malicious — the user is just reading the policy as written.</p>
<p>A second compounding factor: agent runs are long enough that the user has time, mid-run, to make a decision about whether to let the run complete. A two-second API call has no useful "abort here for refund" surface. A twenty-eight-minute agent task does. The very property that makes long-running agents valuable — durable, multi-step work — also gives users a window in which to optimize against the billing boundary.</p>
<p>A third factor, the one that turns this from an isolated trick into a behavioral cohort: the in-context state from a partially completed agent run has value. If the user can resume cheaply after the refund fires, they have effectively converted "the agent did most of the work" into "the agent did most of the work for free." The platform's checkpoint-and-resume capability — sold as a reliability feature — is doing double duty as a billing exploit. Reliability and refund-arbitrage share a code path, and the platform did not realize it was funding both.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-diagnosis-looks-like-a-quality-regression">The Diagnosis Looks Like a Quality Regression<a href="https://tianpan.co/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds#the-diagnosis-looks-like-a-quality-regression" class="hash-link" aria-label="Direct link to The Diagnosis Looks Like a Quality Regression" title="Direct link to The Diagnosis Looks Like a Quality Regression" translate="no">​</a></h2>
<p>This is the most expensive part of the failure mode, because it routes the response to the wrong team.</p>
<p>When the timeout rate doubles, the natural read inside engineering is "the agent is getting worse." Reliability investigations spin up. Eval suites get re-run. Model versions get bisected. Tool latencies get audited. None of it finds anything, because nothing has regressed — the agent is performing exactly as before, on a workload that has shifted under it.</p>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="agent-platforms" term="agent-platforms"/>
        <category label="pricing" term="pricing"/>
        <category label="billing" term="billing"/>
        <category label="unit-economics" term="unit-economics"/>
        <category label="reliability" term="reliability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Agent Wall-Clock Budget That Raced Your Tool's Own Timeout]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Inside the agent stack, the agent's clock and the tool's clock almost never share a t-zero. When their budgets drift, an 8-second tool call lands against a 7.9-second deadline and the harness replans around a success it never saw.]]></summary>
        <content type="html"><![CDATA[<p>There is a class of agent bug that does not appear in any single component when you look at it in isolation. The model is fine. The tool is fine. The retry policy is fine. The timeout values are even, on paper, generous. And yet a tool that consistently completes in eight seconds keeps landing against an agent that has already declared it a failure at seven point nine, replanned around an "error" that never happened, and started a second call that the first call's result is about to collide with.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Agent%20Wall-Clock%20Budget%20That%20Raced%20Your%20Tool%27s%20Own%20Timeout" alt="" class="img_ev3q"></p>
<p>The bug is not in any of the boxes. It is in the gap between two clocks that nobody agreed should be the same clock.</p>
<p>This is the agent-engineering equivalent of a distributed systems classic — clock drift between cooperating nodes — except the two nodes are inside the same process, and the drift is not measured in milliseconds of NTP skew. It is measured in whichever event each side decided to call "t-zero." The agent's budget tends to start ticking from the LLM's first emitted token. The tool's budget tends to start ticking from the moment the tool process actually receives the call. Between those two moments sits the entire prefill stage, the entire streaming pipeline, the entire tool-router hop, and any queueing in front of the tool worker. None of that time is shared between the two stopwatches. Both of them think they are timing the same thing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-two-clocks-nobody-agreed-were-the-same-clock">The Two Clocks Nobody Agreed Were the Same Clock<a href="https://tianpan.co/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout#the-two-clocks-nobody-agreed-were-the-same-clock" class="hash-link" aria-label="Direct link to The Two Clocks Nobody Agreed Were the Same Clock" title="Direct link to The Two Clocks Nobody Agreed Were the Same Clock" translate="no">​</a></h2>
<p>The orthodox view of an agent step looks like a single bar on a timeline: prompt goes in, model thinks, tool runs, result comes back. The orthodox view of a timeout is that you draw a vertical line somewhere on that bar and call it the deadline.</p>
<p>The reality has at least four clocks running concurrently and almost never synchronized:</p>
<ul>
<li class="">The agent harness clock, which usually starts the budget when the request is dispatched to the model.</li>
<li class="">The model's effective clock, which the harness frequently learns about only at the first streamed token (TTFT). Time-to-first-token in 2026 is reported in the hundreds-of-milliseconds-to-low-seconds range for chatty completions and can be much longer for long contexts. That whole interval may or may not count against your "agent budget" depending on which library you used.</li>
<li class="">The tool client clock, which starts when the harness emits the tool call and stops when the tool returns. Most frameworks expose this as a per-tool timeout.</li>
<li class="">The tool server clock, which only starts when the tool process actually receives the request. Anything sitting in front of it — a queue, an MCP router, a reverse proxy with its own idle timeout — adds latency the tool process cannot see and cannot bill against itself.</li>
</ul>
<p>In a well-behaved RPC stack, these clocks are reconciled by deadline propagation. The caller computes an absolute deadline, attaches it to the request as a wall-clock instant (not a duration), and every downstream hop inherits it. The classic gRPC formulation is explicit about this: a deadline is "5 seconds from now" expressed as an absolute timestamp, and the context is propagated through every child call so that the whole subtree dies at the same moment. Frameworks like userver describe the same idea — chain the deadline so a slow upstream cannot consume the budget the downstream still expected to have.</p>
<p>Agent stacks, in practice, do not do this. The agent's budget is a duration counted in the harness process. The tool's budget is a separate duration counted in the tool process. There is no shared deadline. There is no propagation.</p>
<p>So when the model's first token finally arrives, the harness clock is already eleven hundred milliseconds in. When the tool call routes through the MCP server, three hundred more milliseconds vanish. When the tool worker dequeues the request and starts its own eight-second budget, the harness has been counting against an eight-second budget that started 1.4 seconds ago. From the harness's perspective, the tool has 6.6 seconds. From the tool's perspective, it has 8. The numbers look equal. They are not.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-looks-like-a-successful-tool-call-that-the-agent-refuses-to-use">Why This Looks Like A Successful Tool Call That The Agent Refuses To Use<a href="https://tianpan.co/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout#why-this-looks-like-a-successful-tool-call-that-the-agent-refuses-to-use" class="hash-link" aria-label="Direct link to Why This Looks Like A Successful Tool Call That The Agent Refuses To Use" title="Direct link to Why This Looks Like A Successful Tool Call That The Agent Refuses To Use" translate="no">​</a></h2>
<p>The pathological case is not when the agent gives up before the tool starts. That is loud — you see a cancelled call and an angry log. The pathological case is when the agent gives up at 7.9 seconds, the tool finishes at 8.0 seconds, and both sides write structured success logs to their respective traces.</p>
<p>The agent's trace shows: started call, waited, timeout expired at 7.9s, replanned. From the agent's point of view, the tool failed. It enters a recovery branch — picks a different tool, asks the user a clarifying question, or, worst of all, retries the same call.</p>
<p>The tool's trace shows: received call, executed, returned at 8.0s with a clean 200. From the tool's point of view, everything worked. It even billed the user for the work.</p>
<p>The race shows up downstream as three distinct symptoms, all of which look like different bugs:</p>
<ol>
<li class="">A "successful" tool result that nothing is waiting for. The agent's coroutine has been cancelled. The result lands in a closed channel or an orphan future. Some harnesses log this as "tool_use without matching tool_result" — a shape that should be impossible if both sides agree on what happened.</li>
<li class="">A model that confidently reports "the tool failed, I'll try a different approach" while a perfectly correct answer is sitting in the tool's response queue.</li>
<li class="">A duplicate call, because the agent's replan picked the same tool with the same arguments — and now the tool worker is doing the same work twice, with the second invocation racing the first stale result back into a state machine that does not know which one to trust.</li>
</ol>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="agents" term="agents"/>
        <category label="llm-infrastructure" term="llm-infrastructure"/>
        <category label="distributed-systems" term="distributed-systems"/>
        <category label="observability" term="observability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Async Tool Call That Resolved After the User Already Closed the Conversation]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Long-running tool calls outlive the chat sessions that dispatched them. When the user closes the tab, the result still arrives — to a conversation that no longer exists, to the wrong session, or to nobody at all.]]></summary>
        <content type="html"><![CDATA[<p>The clearest sign that an agent's session model is broken is when a tool result has nowhere to go. The agent fired a long-running call — a render, a provisioning job, a multi-step query. The user watched the spinner for a few seconds, decided they didn't need it after all, closed the tab, and moved on. Forty seconds later the tool finishes. Its callback hits your gateway with a <code>conversation_id</code> that no longer points at anything. The gateway has two equally bad options: silently drop the result, or stitch it into whatever session inherits that ID next.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Async%20Tool%20Call%20That%20Resolved%20After%20the%20User%20Already%20Closed%20the%20Conversation" alt="" class="img_ev3q"></p>
<p>Most teams discover this failure mode the same way: a support ticket where a user sees an answer they did not ask for, attached to a conversation they did not start. Or a downstream system that processed the same charge twice because the gateway helpfully "retried" delivery against the next active session. Or — most commonly — nothing visible at all, just a slow drift in completion metrics that nobody can correlate to anything specific, because the failures don't fire alerts; they fire emptiness.</p>
<p>This is not the same failure mode as fire-and-forget, where the planner treats a job ID as a final answer and moves on without polling. That problem lives inside one agent loop. The problem in this post lives between the agent loop and the rest of your infrastructure: the tool <em>will</em> finish, the result <em>will</em> arrive, and your session boundary has already collapsed underneath it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-session-you-designed-was-synchronous-the-tools-are-not">The Session You Designed Was Synchronous; The Tools Are Not<a href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#the-session-you-designed-was-synchronous-the-tools-are-not" class="hash-link" aria-label="Direct link to The Session You Designed Was Synchronous; The Tools Are Not" title="Direct link to The Session You Designed Was Synchronous; The Tools Are Not" translate="no">​</a></h2>
<p>Most chat UIs grew up around a synchronous request-reply pattern. The user sends a message, the model answers, the turn closes. Conversation state lives in memory or in a short-lived cache; long-lived state migrates to a database when the conversation ends or after a brief idle window. The whole pipeline assumes that the time from user-input to final-output is bounded and that the user remains attached to the session for the duration of that bound.</p>
<p>Tools broke this assumption without anybody noticing, because the first wave of tools — search, calculators, dictionary lookups, simple API reads — were fast enough to fit inside the implicit "user is still here" budget. Then the second wave landed: rendering, transcription, provisioning, code execution, agent-to-agent dispatch, anything that calls an external system whose tail latency is measured in minutes rather than seconds. The pipeline did not change shape to match. The synchronous session still held the open turn, the planner still expected the result to come back inline, and the only thing keeping the model honest was the user staying attached.</p>
<p>So when the user disconnects — closes the tab, navigates away, kills the app, hits a flaky network, lets the screen lock — the agent loop stays parked waiting for a result it can no longer route. Some clients hold the turn open server-side for a TTL and let it die quietly. Others abort the agent run on disconnect and leave the tool execution orphaned in whatever queue it was dispatched to. Either way: the work continues. The session does not.</p>
<p>This is the gap. A tool whose actual duration exceeds session lifetime <em>will</em> land its result outside the session that requested it. Asking when this happens is the wrong question. Designing for when it happens is the only question.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-three-things-that-happen-when-the-result-lands-late">The Three Things That Happen When the Result Lands Late<a href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#the-three-things-that-happen-when-the-result-lands-late" class="hash-link" aria-label="Direct link to The Three Things That Happen When the Result Lands Late" title="Direct link to The Three Things That Happen When the Result Lands Late" translate="no">​</a></h2>
<p>When a tool callback arrives carrying a stale <code>conversation_id</code>, your routing layer takes one of three actions, and you should know which one yours takes before something forces you to.</p>
<p><strong>It drops the result.</strong> The gateway looks up the conversation, finds it expired, logs a warning, and returns 200 to the tool service so it doesn't retry. The tool ran. The side effects landed wherever they were going to land — the charge cleared, the email sent, the row deleted, the document created. Nothing tells the user. Nothing tells the next session. The work happened in the world and the model has no memory of it. The next time the user starts a conversation and asks "did that thing go through?" the agent has to derive the answer from the world's state, not from its own history. Most agents are not built to do that and will confidently answer either way.</p>
<p><strong>It routes to the next session.</strong> The gateway looks up the conversation, finds it expired, and helpfully grafts the result onto whatever conversation the same user opens next. The next session inherits a tool response with no matching tool call in its history. The model, faced with a hanging tool-result message, either ignores it (best case), hallucinates a justifying tool call (medium case), or treats the late result as a fresh user message and acts on it (worst case — the inherited side effect, where the next conversation's agent does additional work in response to leftover output from the previous one).</p>
<p><strong>It routes to a different user entirely.</strong> This is the one that wakes the on-call. The <code>conversation_id</code> was reused, or the user identity was tied to the conversation rather than the auth token, or a load-balancer key collision aliased two sessions, or the GC ran and a freshly-minted ID happened to collide with the expired one. The tool result lands in someone else's chat. Once is a near-miss; twice is a postmortem.</p>
<p>The first failure mode is invisible until you correlate completion rates with disconnect rates. The second is invisible until a user notices that an answer doesn't match their question. The third writes itself into the incident channel.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-idempotency-story-was-already-hard-now-it-is-two-idempotency-stories">The Idempotency Story Was Already Hard; Now It Is Two Idempotency Stories<a href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#the-idempotency-story-was-already-hard-now-it-is-two-idempotency-stories" class="hash-link" aria-label="Direct link to The Idempotency Story Was Already Hard; Now It Is Two Idempotency Stories" title="Direct link to The Idempotency Story Was Already Hard; Now It Is Two Idempotency Stories" translate="no">​</a></h2>
<p>Production retry stories assume that the <em>system</em> retries, not that the <em>session</em> retries. A durable execution engine like Temporal, Restate, or LangGraph's checkpointing layer guards against worker crash and tool flakiness by journaling each step and replaying with idempotency keys so completed work is not duplicated. This works because the workflow run is the unit of identity, and the idempotency key derives from the workflow run ID combined with the step.</p>
<p>The async-tool-callback problem is the orthogonal one. It is not the system retrying the same workflow; it is the <em>user</em> abandoning the workflow and starting a new one before the old one finishes. The idempotency key has nothing to deduplicate against, because the new workflow run has a different ID, and the tool service has no way to know that the new run "is" the same user wanting the same outcome.</p>
<p>Two failure surfaces, both wearing the word "retry":</p>
<ul>
<li class=""><strong>Engine-level retry</strong>: the worker crashed, the workflow resumes, the same step needs to either be replayed-from-journal or re-executed-with-idempotency. Solved by durable execution. Well understood.</li>
<li class=""><strong>User-level retry</strong>: the conversation expired, the user starts over, the tool result from the prior run is now an artifact looking for an owner. <em>Not</em> solved by durable execution. Often not solved by anything.</li>
</ul>
<p>If your tool service is well-built, it has an idempotency key derived from the run ID and step. That key protects you from duplicate execution if the engine retries. It does not protect you from the user starting a new conversation that re-issues the same logical request — to the tool service, those are two different keys and two different calls, and both will execute. The second call might succeed where the first one was about to. The first one might still complete after the second has already settled the outcome. The user sees one answer; the world sees two side effects.</p>
<p>The cleanest fix is to derive the idempotency key from something stable to the <em>intent</em> — the user, the tool, and the input — rather than the conversation run. That requires the tool layer to know which user is calling, which most tool layers do, and to accept that the same user calling the same tool with the same arguments within some window is the same logical request. Picking that window is the design choice. Pick it too narrow and you allow double-execution; pick it too wide and you block a user from legitimately re-doing the same work.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="tools-need-a-reversibility-tier-not-just-an-idempotency-key">Tools Need a Reversibility Tier, Not Just an Idempotency Key<a href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#tools-need-a-reversibility-tier-not-just-an-idempotency-key" class="hash-link" aria-label="Direct link to Tools Need a Reversibility Tier, Not Just an Idempotency Key" title="Direct link to Tools Need a Reversibility Tier, Not Just an Idempotency Key" translate="no">​</a></h2>
<p>Idempotency tells you whether it is safe to execute the call twice. Reversibility tells you whether it is safe to execute the call at all once the session has detached.</p>
<p>Reads are trivially safe. A read whose result has nowhere to go can be dropped — the side effect is zero. Writes split into two tiers: revertible writes, where the side effect can be undone if the result has no consumer (a draft saved, an idempotent provisioning step that can be torn down), and one-way writes, where the side effect persists regardless of who is listening (a sent email, a posted message, a charged card, a deleted row).</p>
<p>The agent's planner has no native concept of this distinction; the function-calling schema does not name it. The runtime layer has to. Before dispatching a tool, the gateway needs to know what happens if the session is gone by the time the tool finishes:</p>
<ul>
<li class=""><strong>Cancellable / revertible</strong>: emit a cancel-on-disconnect signal to the tool service when the session expires; ignore the late result.</li>
<li class=""><strong>Idempotent and durable</strong>: persist the result against a stable user-and-intent key; deliver it to the next session that matches; show the user the carry-over result as the first turn of their next conversation.</li>
<li class=""><strong>One-way and irreversible</strong>: do not dispatch on a session boundary that might collapse before the tool finishes; require a separate confirmation surface (notification, email, dedicated task list) so the result has a home that does not depend on the session staying open.</li>
</ul>
<p>The third tier is the one most teams skip, because the synchronous chat UI does not have a place for it. The chat is the only surface. Adding a task list, a notification channel, or a "your render finished" surface means treating the long-running tool as the unit of state rather than the conversation as the unit of state. That is the architectural shift the durable-execution community has been pushing for two years, and most agent frontends still have not made it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="session-lifetime-should-be-a-function-of-the-slowest-tool-the-agent-can-dispatch">Session Lifetime Should Be a Function of the Slowest Tool the Agent Can Dispatch<a href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#session-lifetime-should-be-a-function-of-the-slowest-tool-the-agent-can-dispatch" class="hash-link" aria-label="Direct link to Session Lifetime Should Be a Function of the Slowest Tool the Agent Can Dispatch" title="Direct link to Session Lifetime Should Be a Function of the Slowest Tool the Agent Can Dispatch" translate="no">​</a></h2>
<p>The default in most stacks is that conversation TTL is set by product instinct — minutes for chat, hours for assistants, days for project-style work — and tool timeouts are set by ops instinct — whatever made the slow tool stop failing under load. These two numbers were almost never picked together. The interesting failure cases all live in the gap between them.</p>
<p>A useful invariant: <strong>session-state lifetime must exceed the worst-case completion time of any tool the agent can dispatch from that session.</strong> Otherwise you have a guaranteed orphan rate equal to the fraction of tool runs that exceed session TTL, and that orphan rate is invisible to your existing dashboards unless you specifically count "tool results delivered to expired conversations" — which most teams do not.</p>
<p>This invariant is easier to state than to enforce. A 24-hour session TTL is cheap in terms of database rows and expensive in terms of context that grows stale. Letting the agent dispatch tools that take a day to complete forces the session-state layer to outlive the user's attention by a wide margin. The honest move is to split the state model: short-lived in-memory conversation state for the chat experience, long-lived durable run state for the agent loop and its outstanding tool calls, and a delivery layer that reconciles the two when results land.</p>
<p>Once the two state layers are separate, the question of "what happens when the user closes the conversation" becomes a straightforward routing decision rather than a data-loss event. The agent run keeps going against durable state. The tool result lands against the run's stable ID. When the user comes back — same session or a new one — the runtime asks the agent loop whether there are outstanding completions to surface, and the answer is either "yes, here is the render you started yesterday" or "no, everything you cared about resolved while you were gone."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-failure-mode-you-will-notice-last">The Failure Mode You Will Notice Last<a href="https://tianpan.co/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#the-failure-mode-you-will-notice-last" class="hash-link" aria-label="Direct link to The Failure Mode You Will Notice Last" title="Direct link to The Failure Mode You Will Notice Last" translate="no">​</a></h2>
<p>The post-mortem version of this problem is almost always written about the cross-session-leak case, because that is the one that gets reported. The version that costs more money over time is the silent-drop case — the tool runs to completion, the side effect lands in the world, and the user is never told. You pay for the tool, you pay for the side effect, and you get zero conversion credit because the user never saw the answer.</p>
<p>The instrumentation to catch this is unglamorous: for every tool call dispatched, log when it completes and whether a session was attached at completion time. Compute the ratio. If detached-at-completion is more than a few percent of total long-running calls, you have an architecture problem, not an alerting problem. The fix is not a louder alarm; the fix is the split state model and the delivery layer described above.</p>
<p>The async tool call you fired is going to finish. The interesting question is whether your system has somewhere to put the result by the time it does.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="ai-agents" term="ai-agents"/>
        <category label="tool-calling" term="tool-calling"/>
        <category label="async" term="async"/>
        <category label="distributed-systems" term="distributed-systems"/>
        <category label="session-management" term="session-management"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Canary Cohort Your Rollout Hashed by ID That Clustered Power Users Into One Arm]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A uniform hash of opaque IDs is not a uniform sample of users. When ID assignment correlates with engagement, a hash-bucketed canary can quietly assign every power user to one arm and report a phantom win.]]></summary>
        <content type="html"><![CDATA[<p>A rollout team ships a new model behind a percentage flag. The flag bucket is computed as <code>hash(user_id) % 100</code>, the canary is buckets 0–4, the lift on per-user engagement is large and stable for two weeks, and the team ramps to 20%, then 50%, then global. The lift evaporates somewhere between 50% and global, and the post-mortem traces it back to the canary cohort. The treatment didn't move the metric. The canary arm was a different population.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Canary%20Cohort%20Your%20Rollout%20Hashed%20by%20ID%20That%20Clustered%20Power%20Users%20Into%20One%20Arm" alt="" class="img_ev3q"></p>
<p>The team thought it had been sampling users. It had been sampling IDs.</p>
<p>The two words look interchangeable until you remember that IDs are not generated by the user. They are generated by whatever system happened to be running the day the account was created — a sequence in Postgres, a Snowflake-style time-encoded integer, a UUIDv7 with a timestamp prefix. If that generator embeds time, then "users with adjacent IDs" really means "users who signed up around the same time." And signup time is one of the strongest predictors of behavior most products have. The viral integration that brought in a power-user wave two years ago lives in a six-month window of ID space, and any bucketing scheme that doesn't actively scramble that window can land it whole inside one arm.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-hash-function-is-doing-exactly-what-you-told-it-to">The hash function is doing exactly what you told it to<a href="https://tianpan.co/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm#the-hash-function-is-doing-exactly-what-you-told-it-to" class="hash-link" aria-label="Direct link to The hash function is doing exactly what you told it to" title="Direct link to The hash function is doing exactly what you told it to" translate="no">​</a></h2>
<p>The cruel part is that the hash is innocent. A good hash on a dense ID space produces a population-uniform distribution <em>of IDs</em>. If you check the marginal distribution of bucket assignments, every bucket has roughly the same count, the chi-squared test for sample ratio mismatch comes back clean, and the experimentation platform declares the split healthy. SRM checks are the right tool for detecting a broken assignment pipeline — a missing variation script, a redirect that drops half the variant, a targeting rule that misfires — but they compare the <em>count</em> of users per bucket, not the <em>composition</em>. Equal counts with unequal compositions is a category of bias the standard health check is built to ignore.</p>
<p>The hash also reuses the same digest across experiments. Two concurrent experiments salted with the same string or run with the same partitioning policy can produce assignments that look independent in any one experiment but are correlated when you join them — a known failure mode that surfaces when the platform team starts looking for the source of phantom lifts. The fix in that case is per-experiment salts. The fix in <em>this</em> case is something different, because the problem isn't a collision between experiments. It's a collision between the ID space and the cohort space.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-uniform-on-ids-is-not-the-same-as-uniform-on-users">Why "uniform on IDs" is not the same as "uniform on users"<a href="https://tianpan.co/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm#why-uniform-on-ids-is-not-the-same-as-uniform-on-users" class="hash-link" aria-label="Direct link to Why &quot;uniform on IDs&quot; is not the same as &quot;uniform on users&quot;" title="Direct link to Why &quot;uniform on IDs&quot; is not the same as &quot;uniform on users&quot;" translate="no">​</a></h2>
<p>The implicit contract a hash bucketing scheme offers is: <em>if I hash on a unique identifier, I get an exchangeable sample of users</em>. That contract is true only when the identifier is statistically independent of the variables you care about. For a randomly assigned identifier with no information content, that holds. For an integer that monotonically increases with signup time, it doesn't — the identifier carries a time covariate, and any cohort behavior that correlates with signup time bleeds into the bucket assignment.</p>
<p>The most common version of this is heavy-user bias. The Microsoft Research paper "On Heavy-user Bias in A/B Testing" makes the point that a small fraction of accounts often drives a large fraction of metric movement, and the composition of heavy users inside an experiment window can deviate substantially from the long-run population. When heavy users cluster on a specific dimension — signup window, geography, plan tier, device — and your bucketing function is sensitive to that dimension, the experiment is measuring a different population than the rollout will eventually serve. The bias is not introduced by the experiment running too short. It is introduced by the assignment being non-random in a way that the count-based health check can't see.</p>
<p>A signup-time cohort is the textbook case. Power users sign up in concentrated bursts: a launch, a press cycle, a viral integration, a partnership. Those bursts produce dense ID ranges. A hash that maps adjacent IDs to adjacent buckets — and many practical hash functions do, especially modulo or fold-and-mix variants on small bucket counts — will route a contiguous ID range to a contiguous bucket range. A 5% canary that maps to buckets 0–4 is five contiguous bucket boundaries. There is no theoretical reason a six-month signup window with disproportionate engagement <em>won't</em> fall entirely inside those five buckets. There is a probabilistic reason it should be unlikely on average, but the variance of "where the power-user cohort lands" across a small number of buckets is high enough that any one rollout can hit the bad arrangement.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-standard-health-checks-miss">What the standard health checks miss<a href="https://tianpan.co/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm#what-the-standard-health-checks-miss" class="hash-link" aria-label="Direct link to What the standard health checks miss" title="Direct link to What the standard health checks miss" translate="no">​</a></h2>
<p>The experimentation platform's SRM detector fires when the <em>count</em> in the treatment arm is unusually far from the expected percentage. That check would catch a bucketing function with a hot spot, or a tracking script that fails to fire on half of variant pages, or a redirect that drops traffic. It wouldn't catch a count-balanced split where one arm's users happen to have spent 5x more on the product the previous month.</p>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="experimentation" term="experimentation"/>
        <category label="ab-testing" term="ab-testing"/>
        <category label="rollout" term="rollout"/>
        <category label="sampling-bias" term="sampling-bias"/>
        <category label="mlops" term="mlops"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Citation Index Your Chunker Shifted by One When It Started Prefixing Line Numbers]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A document chunker added a [line N] prefix and every citation started pointing one paragraph before the evidence — the failure mode where two systems agree on the shape of an integer but disagree on its meaning, and how to catch it before an auditor does.]]></summary>
        <content type="html"><![CDATA[<p>The chunker started prepending <code>[line N]</code> to every chunk. The eval went green. Every citation the model produced after that day pointed to the paragraph one position before the actual evidence, on every document, in the regulated industry the product serves. The team did not find out from the eval. The team found out from an auditor who looked at the cited sentence, read it, and pointed out that it contradicted the claim it was supposed to support.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Citation%20Index%20Your%20Chunker%20Shifted%20by%20One%20When%20It%20Started%20Prefixing%20Line%20Numbers" alt="" class="img_ev3q"></p>
<p>This is the kind of regression that survives a code review, a manual QA pass on three sample documents, and a feature-flag rollout. None of those checks were wrong in isolation. They were all asking the same question — does a citation appear where one is expected — and none of them were asking the question the auditor asked, which is whether the citation points at the sentence the claim came from. The gap between those two questions is where the off-by-one lived for as long as it lived.</p>
<p>What makes this failure mode worth a separate write-up is not the bug itself. Off-by-one errors are old news. The interesting part is that the failure was produced by two systems that continued to agree on the structure of an integer while silently disagreeing about what the integer meant.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-chunker-and-the-citation-parser-were-never-on-the-same-call">The chunker and the citation parser were never on the same call<a href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#the-chunker-and-the-citation-parser-were-never-on-the-same-call" class="hash-link" aria-label="Direct link to The chunker and the citation parser were never on the same call" title="Direct link to The chunker and the citation parser were never on the same call" translate="no">​</a></h2>
<p>A document chunker emits chunks. A citation extractor consumes the model's references to those chunks and resolves them back to spans in the original source. In most production RAG architectures, these two components are owned by different teams, deployed on different cadences, and tested by different eval suites. They communicate through a single integer per citation — the paragraph index, the line range, the chunk position.</p>
<p>That integer is a coordinate. Coordinates require a coordinate system. The chunker writes integers in one coordinate system; the parser reads them in another; the contract between them is the implicit agreement that both sides are counting the same thing from the same origin.</p>
<p>When the chunker added a <code>[line N]</code> prefix to every chunk so the model could cite line ranges instead of paragraph numbers, the prefix consumed the first line of the chunk. The chunker's emitted indices were unchanged at the storage layer. The model, reading the prefixed chunk, started numbering from the prefix. The citation parser, parsing what the model emitted, still mapped that number through the pre-prefix paragraph index. Every paragraph index shifted by one in the model's frame and by zero in the parser's frame, and the difference came out as a citation to the paragraph immediately before the actual evidence.</p>
<p>No code path threw. No regex failed to match. No chunk count changed. The two systems remained structurally compatible — same data type, same range, same response shape — while their semantic agreement quietly dissolved.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="citation-present-is-not-a-citation-metric">"Citation present" is not a citation metric<a href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#citation-present-is-not-a-citation-metric" class="hash-link" aria-label="Direct link to &quot;Citation present&quot; is not a citation metric" title="Direct link to &quot;Citation present&quot; is not a citation metric" translate="no">​</a></h2>
<p>The eval suite scored citations as a boolean: did the model produce a citation, and did the citation resolve to a chunk in the corpus? On both axes, the new chunker passed cleanly. Every response had a citation. Every citation resolved. The numerical score on the eval dashboard went up if anything, because the prefix gave the model a clearer signal to cite from in the first place.</p>
<p>The metric that would have caught this is not "citation present" but "citation correct" — defined as a semantic match between the cited span and the claim it supports. Citation correctness is a substantially more expensive metric to compute. It needs a notion of which atomic claims live in the answer, which span each claim was meant to come from, and a comparator that says yes-or-no on the alignment. Most teams do not maintain this. The teams that do typically only maintain it on a small golden set, not on a sample large enough to detect distributional shifts inside a single sub-corpus.</p>
<p>The cheaper proxies all degrade in the same direction. Citation accuracy in production RAG averages around 65–70% without explicit attribution training, but that's an aggregate; it doesn't tell you whether the 30% wrong ones are wrong in the same way, on the same documents, or after the same deploy. An off-by-one is a structured wrongness, and structured wrongness is the kind of failure that aggregate metrics smooth over.</p>
<p>The lesson is not that "citation present" is a bad metric. It's a fine smoke test. The lesson is that it is a smoke test, and smoke tests do not protect against semantic regressions in the things they are not measuring. Treating citation correctness as a first-class metric — instrumented continuously, alerted on slope rather than absolute, computed against documents whose answers are known — is the only way the off-by-one becomes visible before the auditor sees it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-systems-that-agreed-on-the-type-and-disagreed-on-the-meaning">Two systems that agreed on the type and disagreed on the meaning<a href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#two-systems-that-agreed-on-the-type-and-disagreed-on-the-meaning" class="hash-link" aria-label="Direct link to Two systems that agreed on the type and disagreed on the meaning" title="Direct link to Two systems that agreed on the type and disagreed on the meaning" translate="no">​</a></h2>
<p>The deeper failure here is one of typing. The chunker emitted an integer for a paragraph index. The parser accepted an integer for a paragraph index. The compiler, the linter, and the type-checker all signed off. Nothing in the type system said "these two integers must be in the same coordinate system."</p>
<p>This is the same class of bug as passing meters into a function that expects feet. The function will return a number. The number will be wrong. No tool you have will tell you, because both sides agree on the dimensionality of the quantity — only on its interpretation.</p>
<p>The pattern that closes this is a content-coordinate type. Instead of <code>paragraph_index: int</code>, name the indexing scheme:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ChunkPositionInPrefixedFrame</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ChunkPositionInOriginalFrame</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">LineNumberInPrefixedFrame</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SentenceIndexInChunk</span><br></div></code></pre></div></div>
<p>Each is a distinct type. Conversion between them is explicit. The chunker emits citations in one frame; the parser converts to another frame explicitly before resolving against the source. The shift becomes a function the team has to write, name, and review. Any change to the chunker's framing — a prefix, a header, a structural insertion — forces a corresponding change to the converter, because the type signature of the converter changes.</p>
<p>This is not exotic engineering. It is the same kind of phantom-types trick that finance code uses for currency and that physics code uses for units. The reason RAG code rarely uses it is that the data flowing through is "just text," and "just text" feels like it should not need a type system. The off-by-one is the bill for that assumption.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="chunk-format-changes-are-coordinate-system-changes">Chunk-format changes are coordinate-system changes<a href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#chunk-format-changes-are-coordinate-system-changes" class="hash-link" aria-label="Direct link to Chunk-format changes are coordinate-system changes" title="Direct link to Chunk-format changes are coordinate-system changes" translate="no">​</a></h2>
<p>The next pattern is the one that would have caught the regression on its own deploy: a regression test specifically exercising the citation parser against documents containing known answers, run on every change to the chunk format. Not the eval suite. A targeted contract test against the chunk-to-citation boundary.</p>
<p>The reason this is its own test is that chunk-format changes look small from the chunker's perspective. Adding a prefix is one line of code. The chunker still emits the same count of chunks, with the same approximate token budget, against the same documents. The diff is local. The blast radius is global.</p>
<p>A regression test at the chunk-to-citation boundary takes a document with a known answer, runs it through the full pipeline, and checks that the citation resolves to the span that contains the answer. Not "a citation appeared." Not "the citation parsed cleanly." That the resolved span contains the text. This is two or three documents and a fixture file. It is the cheapest possible investment that would have caught the regression on the feature-flag PR.</p>
<p>If the team also runs the same test under the old chunker as a baseline diff, the change in citation accuracy on the new chunker is visible as a delta. A 100% citation-target accuracy on the old path and a 0% citation-target accuracy on the new path is not a subtle signal. The reason the feature flag rolled is that nobody was reading the right number.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-audit-catches-what-the-eval-underweights">The audit catches what the eval underweights<a href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#the-audit-catches-what-the-eval-underweights" class="hash-link" aria-label="Direct link to The audit catches what the eval underweights" title="Direct link to The audit catches what the eval underweights" translate="no">​</a></h2>
<p>The eval has its own selection bias. It tends to be built from queries the team thought of, on documents the team chose, against answers the team already knew. The auditor, by contrast, was running an adversarial workflow: take a citation, follow it, read the sentence, ask whether it supports the claim. That workflow does not appear in any standard eval harness, because it is the workflow of someone trying to falsify the model's claim rather than someone trying to verify the model's behavior.</p>
<p>The patterns that bring the auditor's workflow inside the eval loop are not new ideas. They are:</p>
<ul>
<li class=""><strong>Atomic-claim decomposition.</strong> Break the model's answer into the smallest standalone claims. Score each claim against its citation independently. This catches the case where the citation supports part of the answer and contradicts another part.</li>
<li class=""><strong>Span-content verification.</strong> After resolving the citation to a span, run an entailment check between the span text and the claim text. A semantic mismatch is a citation failure even if the index resolved cleanly.</li>
<li class=""><strong>Adversarial sampling.</strong> Sample queries from auditor-style workflows: high-severity tickets, regulated-industry edge cases, churn interviews where a customer named the specific kind of question their team kept getting wrong. The eval set evolves toward the cohort least likely to leave.</li>
<li class=""><strong>Per-feature-flag comparison.</strong> When a chunker change rolls behind a flag, the eval should run both branches against the same query set and report a side-by-side delta. Promotion to production requires the delta to be flat or positive on every metric, not just the aggregate.</li>
</ul>
<p>These four patterns together would have surfaced the off-by-one before the auditor did. None of them is expensive in absolute terms. The reason they aren't standard is that each one is owned by a different team — atomic claims by the eval team, span verification by the retrieval team, sampling by product analytics, feature-flag comparison by infra. Nobody owns the citation-correctness contract end-to-end.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-correctness-was-a-coincidence-means">What "correctness was a coincidence" means<a href="https://tianpan.co/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#what-correctness-was-a-coincidence-means" class="hash-link" aria-label="Direct link to What &quot;correctness was a coincidence&quot; means" title="Direct link to What &quot;correctness was a coincidence&quot; means" translate="no">​</a></h2>
<p>The citation chain in a RAG system is a sequence of references. A model emits a reference. A parser resolves the reference to a chunk. A chunk maps back to a span in a source document. The user reads the span. Each link has its own validity check. None of those checks proves the chain.</p>
<p>The chunker shipped a change that broke one link in a way that left every other link still valid. The model still emitted a reference. The parser still resolved it. The span still rendered. The user still read it. The audit was the first check in months that actually walked the chain end-to-end against the meaning of the claim, and the chain failed at the first link in 100% of cases on the documents the new chunker had produced.</p>
<p>The phrase that ought to scare a team here is the one the postmortem will write: the citations were correct for as long as nothing changed. That's not the same as "the citations were correct." That's a coincidence. Two independent systems happened to agree on what the integer <code>1</code> referred to. As soon as one of them shifted its frame, the agreement evaporated, and the only check that would have noticed was the one nobody was running.</p>
<p>The systems thinking that closes the gap is not heroic. Name your coordinate systems. Test your contracts at the boundary, not at the endpoints. Score what the user reads, not what the model emits. Run the auditor's workflow inside the eval loop before the auditor runs it inside your customer's quarterly review.</p>
<p>The citation that contradicts its claim is the cheapest possible escape from the kind of failure that ends with a customer in a regulated industry walking into a meeting with a regulator and a model's output in front of them. The cost of that meeting is the budget you should be willing to spend on the second test.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="citations" term="citations"/>
        <category label="evals" term="evals"/>
        <category label="chunking" term="chunking"/>
        <category label="observability" term="observability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Citation URL That Resolved But No Longer Said What the Model Quoted]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A RAG citation passes link-checks and still loses an audit, because reachability is not fidelity. How to snapshot, hash, and retain cited spans so a transcript survives the source's editor.]]></summary>
        <content type="html"><![CDATA[<p>A RAG agent answers a customer's regulatory question with a tidy paragraph and a citation. The verification layer fetches the URL, sees a <code>200 OK</code>, ticks the box, and ships. Six months later a compliance audit pulls the transcript, clicks the same link, and finds a page that now says the opposite of what the agent quoted. The URL is fine. The quote is fine in the transcript. The two no longer match. The customer's compliance officer asks whether the agent fabricated the quote, and the team cannot prove it didn't, because the only surviving evidence of what the URL used to say is the agent's own assertion of what it said.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Citation%20URL%20That%20Resolved%20But%20No%20Longer%20Said%20What%20the%20Model%20Quoted" alt="" class="img_ev3q"></p>
<p>This is not a hallucination in the usual sense. The model retrieved real content, faithfully extracted a real sentence, and emitted a real URL that still resolves. Every link-checker on earth would call this citation valid. The audit fails anyway, because the verification layer was measuring the wrong property. Reachability is not fidelity. A URL is a pointer to a mutable document under someone else's editorial control, and the moment the document changes, every transcript that quoted it becomes a hallucination report waiting to happen.</p>
<p>A 2016 study of scholarly references found that roughly three out of four URI references in academic publications lead to content that has materially changed since citation. That number predates LLMs by half a decade. Add an agent that quotes from those URLs to thousands of customers a day and the audit trail decays at the same rate as the open web, which is to say: faster than your retention policy is written for.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="url-durability-is-not-source-durability">URL Durability Is Not Source Durability<a href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#url-durability-is-not-source-durability" class="hash-link" aria-label="Direct link to URL Durability Is Not Source Durability" title="Direct link to URL Durability Is Not Source Durability" translate="no">​</a></h2>
<p>The verification layer most teams ship treats a citation as a tuple of (claim, URL) and verifies it by fetching the URL and checking the response code. This is a category error. The claim is a statement about the document at a specific moment in time. The URL is a name that points to whatever currently sits at that location. The two are correlated at quote time and decorrelated forever after.</p>
<p>Three failure modes hide inside this conflation. The page is silently edited in place with no version indicator and the URL keeps resolving — this is the bread-and-butter editorial workflow of most news sites, most regulatory portals, most company-controlled documentation. The page is moved and a redirect returns a different document under the same logical name — common with CMS migrations and acquisitions. The page is removed and replaced with a soft 404 that returns <code>200 OK</code> but says nothing about the original claim — common in compliance contexts where pulled content is the point.</p>
<p>In all three cases, the verification check passes. The cited claim no longer exists at the cited location. The transcript is the only artifact that remembers what the model thought it was quoting, and the transcript's authority is precisely the property in dispute.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-audit-frame-makes-this-worse">Why the Audit Frame Makes This Worse<a href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#why-the-audit-frame-makes-this-worse" class="hash-link" aria-label="Direct link to Why the Audit Frame Makes This Worse" title="Direct link to Why the Audit Frame Makes This Worse" translate="no">​</a></h2>
<p>In a low-stakes consumer product, citation drift is a UX problem: users click through, find something different from what the agent said, and shrug. In a regulated context, the asymmetry is brutal. The team that built the agent has every incentive to surface a citation. The team that audits the agent — sometimes the same team's compliance counterpart, sometimes a regulator, sometimes a customer's legal department — has every incentive to verify against the live source, because that is the only source whose authenticity is independent of the agent's claim about it.</p>
<p>The auditor's reasonable position is that a citation that does not match the cited source is evidence of hallucination. The team's position — that the source used to say what we quoted — is a claim with no evidence other than the transcript, which is the artifact under investigation. The team loses this argument every time, not because the agent fabricated the quote, but because the team's verification layer never preserved the only evidence that would have closed the loop.</p>
<p>The deeper problem is that the team's compliance posture was built on an assumption that does not survive contact with the open web: that a citation's referent is stable for the lifetime of the transcript. Nobody wrote this assumption down. Nobody priced what it would cost if it broke. The contract with the customer says the agent's answers are auditable. The architecture quietly inherits the open web's mutability without telling anyone.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="snapshot-at-quote-time-not-at-audit-time">Snapshot at Quote Time, Not at Audit Time<a href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#snapshot-at-quote-time-not-at-audit-time" class="hash-link" aria-label="Direct link to Snapshot at Quote Time, Not at Audit Time" title="Direct link to Snapshot at Quote Time, Not at Audit Time" translate="no">​</a></h2>
<p>The fix that does the most work for the least architectural cost is to capture the cited content at the moment of quotation and store it alongside the response. When the agent emits a citation, the system fetches the specific paragraph (or larger span) the quote came from, hashes it, and persists both the content and the hash with the transcript. The audit comparison is then between the transcript's quote and the snapshot, not between the transcript's quote and whatever the URL serves today.</p>
<p>This pattern has prior art. The Memento protocol, an HTTP extension that has been around since 2009 and underpins the Wayback Machine, formalizes the distinction between a URI-R (the original resource, mutable) and a URI-M (a memento, a fixed version of the resource at a specific datetime). A citation that points to a URI-M is durable in a way a citation that points to a URI-R is not. Most RAG systems emit URI-Rs because that is what the source page hands them. The system that pairs the URI-R with a private snapshot at quote time is doing in-house what the Memento protocol does institutionally: anchoring the citation to a moment.</p>
<p>Three implementation notes matter. First, snapshot the smallest span that still contains the claim, not the whole page — page-level snapshots inflate storage and complicate audit by including content the model never referenced. Second, hash the snapshot with a content-addressable scheme so the audit can prove the snapshot itself has not been tampered with by the team holding it. Third, the snapshot is now sensitive data — if the source page is paywalled, copyrighted, or contains PII, the team has just absorbed a content-licensing or privacy problem they did not have before. Plan for it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="drift-detection-as-a-first-class-job">Drift Detection as a First-Class Job<a href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#drift-detection-as-a-first-class-job" class="hash-link" aria-label="Direct link to Drift Detection as a First-Class Job" title="Direct link to Drift Detection as a First-Class Job" translate="no">​</a></h2>
<p>Snapshotting at quote time solves the audit problem, but it does not solve the user-facing problem: the customer who clicks the citation today and finds something different from what the agent said. For that, a re-verification job that periodically re-fetches citations and compares them against the stored snapshot is the right primitive.</p>
<p>The output of this job is not a binary pass/fail. It is a drift signal with a few useful gradations:</p>
<ul>
<li class=""><strong>Identical</strong>: source content matches the snapshot byte-for-byte. The citation is as durable as the underlying page.</li>
<li class=""><strong>Cosmetic drift</strong>: whitespace, formatting, or surrounding boilerplate changed; the cited span is intact. The citation is still trustworthy.</li>
<li class=""><strong>Material drift</strong>: the cited span has changed, been moved, or been removed. The transcript's quote is no longer findable at the URL. The user-facing UI should surface this as a "source has changed since this answer was generated" warning, ideally with a link to the snapshot the agent actually quoted from.</li>
<li class=""><strong>Reversal</strong>: the cited span has been edited to say something different in a way that changes the claim's meaning. This is the audit-incident case. The transcript should be flagged for review, and any downstream artifact that derived from this response (a summary, a recommendation, a contract clause) should be re-evaluated.</li>
</ul>
<p>A team that runs this job nightly across the citation index gets a steady signal of how much of the corpus they are quoting is actively eroding under them. That signal is also a procurement input — sources with high drift rates are sources the agent should rely on less, or cite with explicit caveats.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="retention-and-the-lifetime-of-a-claim">Retention and the Lifetime of a Claim<a href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#retention-and-the-lifetime-of-a-claim" class="hash-link" aria-label="Direct link to Retention and the Lifetime of a Claim" title="Direct link to Retention and the Lifetime of a Claim" translate="no">​</a></h2>
<p>The architectural question that the snapshot pattern forces is also the one teams least like to answer: how long should the snapshot live? Citations have a natural retention period — the lifetime of any response that references them. If the team retains transcripts for seven years to satisfy a regulator, the cited snapshots have to be retained for seven years too. The team that snapshots at quote time but cleans up snapshots on a thirty-day rolling window has built an audit trail that decays at the rate of the storage budget rather than at the rate of the obligation.</p>
<p>The procurement contract that names the agent's outputs as auditable is also, by implication, a retention contract on every piece of evidence required to audit them. The team's data engineers and the team's compliance officers usually have not had this conversation. The first time it comes up is when the storage bill exceeds the model bill and the finance team asks why a chatbot is paying for a document warehouse. The honest answer is that the chatbot inherited a regulator's evidentiary requirements the moment it shipped a citation. The storage cost is the cost of being able to defend the answer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="citation-as-a-claim-about-a-moment">Citation as a Claim About a Moment<a href="https://tianpan.co/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#citation-as-a-claim-about-a-moment" class="hash-link" aria-label="Direct link to Citation as a Claim About a Moment" title="Direct link to Citation as a Claim About a Moment" translate="no">​</a></h2>
<p>The architectural realization is that a citation in an LLM response is not a pointer to a source. It is a claim about what the source said at a specific moment, with a URL attached as a convenience. The URL is the easiest part of the claim to verify and the least informative thing about it. The hard part — and the part the verification layer has to actually do — is preserving the content that gave the claim its meaning, in a form that survives the source's editor.</p>
<p>Teams that treat URLs as the unit of citation have shipped an audit trail whose half-life is set by the editorial cadence of every site they quote. Teams that treat the cited span as the unit of citation, snapshot it at quote time, hash it, and retain it alongside the transcript have shipped an audit trail that survives the source. The difference is invisible on the day the citation is emitted. It is the only thing that matters on the day the audit lands.</p>
<p>The fix is not expensive. The expensive thing is the conversation that has to happen first: that the architectural intent of "the agent cites its sources" is a claim the team has been making on the open web's behalf, and the open web has not signed up to be bound by it. The team that closes that gap before the audit lands has built a system that means what its citations say. The team that does not has built a hallucination report that hasn't fired yet.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="llm" term="llm"/>
        <category label="citation-drift" term="citation-drift"/>
        <category label="audit" term="audit"/>
        <category label="compliance" term="compliance"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Codebase Index Your Coding Agent Rebuilt From a Checkout Three Weeks Behind Main]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[When a coding agent's semantic index and its working tree drift apart, the agent grounds confident claims on code that no longer exists — and the failure mode hides in ordinary-looking PRs.]]></summary>
        <content type="html"><![CDATA[<p>A coding agent on your team opens a pull request that calls <code>parseUserToken()</code> four times across two files. The function does not exist in the repository, has not existed for nineteen days, and was replaced by <code>decodeSessionClaim()</code> in a commit your engineers all remember reviewing. The agent did not invent the name. It read the name from its semantic index — a vector store rebuilt from a working copy that was twenty-one days behind <code>main</code>. The agent's edit step, by contrast, ran <code>git pull</code> at session start and operated on fresh code. Two views of the same repository, three weeks apart, and the agent confidently bridged them with code that does not compile against anything real.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Codebase%20Index%20Your%20Coding%20Agent%20Rebuilt%20From%20a%20Checkout%20Three%20Weeks%20Behind%20Main" alt="" class="img_ev3q"></p>
<p>This is the failure mode that doesn't announce itself. The agent ran. The tests appeared to pass. The PR landed. The first reviewer noticed only because a stubbed-out function shared a name with an unrelated helper and tripped the linter. By then the agent had spent a full sprint writing against a phantom version of the codebase, and no one on the team — including the agent — had any signal that something was wrong.</p>
<p>The seam between an agent's <em>understanding</em> of the codebase and the codebase's <em>actual state</em> is a coherence boundary that nobody draws on the architecture diagram. The index is updated by one team on one cadence. The working tree is fetched by a different team on a different cadence. The agent's reasoning lives on the first surface. The agent's actions land on the second. When the two diverge by even a few commits — let alone three weeks — the result is an agent that grounds confident claims on a repository that no longer exists.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-the-drift-gets-in">How the Drift Gets In<a href="https://tianpan.co/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main#how-the-drift-gets-in" class="hash-link" aria-label="Direct link to How the Drift Gets In" title="Direct link to How the Drift Gets In" translate="no">​</a></h2>
<p>The index and the working tree are two ways the platform answers the question "what code is in this repo." Most agent platforms keep them in different places, refresh them on different schedules, and assume the result is the same. It often isn't.</p>
<p>The most common source of drift is a mirror cache. To absorb GitHub rate limits and accelerate cold clones, agent platforms hold a local mirror of each tracked repository that refreshes from <code>origin</code> on an interval — every few minutes, every hour, sometimes longer. The mirror has been the same kind of infrastructure as a CDN for years; tools like <code>gitcache</code> and <code>git-cache-clone</code> are explicit about it. The interval is configurable, and that's where the problem lives. If the refresh job is paused, throttled, misconfigured, or forgotten during a platform migration, the mirror lags origin invisibly. The index pulls from the mirror; the working tree pulls from origin directly. The two diverge.</p>
<p>Other vectors are subtler. A semantic index is durable across sessions for performance reasons — embeddings are expensive to recompute, and most agent platforms reuse them aggressively. After a large branch swap, a dependency bump that rewrites thousands of files, a generated-code regeneration, or an LFS pull, the index can carry orphaned entries pointing at code that no longer exists. Cursor's documentation acknowledges this: when autocomplete starts referencing files you deleted three branches ago, the index has stale entries. The Merkle-tree change detector that Cursor uses to incrementally re-index is efficient at finding <em>modified</em> files, but it doesn't always invalidate aggressively enough on structural rewrites.</p>
<p>A third vector is failed indexing without failed retrieval. The indexer crashes mid-run, leaves a partial index in place, and the agent platform keeps serving search results from whatever was indexed last. The agent has no way to know its results are now selectively missing the last six commits' worth of changes. Search returns confidently. Confidence is the problem.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-agent-does-with-a-stale-view">What the Agent Does With a Stale View<a href="https://tianpan.co/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main#what-the-agent-does-with-a-stale-view" class="hash-link" aria-label="Direct link to What the Agent Does With a Stale View" title="Direct link to What the Agent Does With a Stale View" translate="no">​</a></h2>
<p>A coding agent uses its index for two things: search ("find me the function that handles auth") and grounding ("here's the function signature I'm going to call"). Both are vulnerable to staleness in different ways.</p>
<p>Search degrades quietly. A stale index returns the file that <em>used to</em> implement a feature, and the agent reads it as ground truth. If the file has been split, renamed, or merged into another module, the agent's mental map of where logic lives is wrong from the first search. Subsequent searches don't repair this — they often compound it, because the agent's follow-up queries are conditioned on what the first search returned.</p>
<p>Grounding fails more loudly, but only sometimes. If the agent calls a function that no longer exists, the compiler usually catches it — and that should end the story. But the modern coding-agent loop is "iterate until tests pass," and many implementations of that loop have a fallback when the build fails: stub out the call, mock the return, comment out the failing block. The agent's prompt told it to make tests pass, so it makes the tests pass, by removing the thing that was failing. The stub gets shipped. The reviewer sees a passing CI run and a clean diff and approves.</p>
<p>The most insidious case is partial overlap. The function the agent calls <em>does</em> exist, but with a different signature than the index remembers. The argument order changed. A required parameter was added. A return type was narrowed. The code compiles in some cases and crashes at runtime in others. The agent never gets a clear signal that its grounding was wrong, because the wrongness is intermittent.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-stale-index-doesnt-show-up-in-reviews">Why "Stale Index" Doesn't Show Up in Reviews<a href="https://tianpan.co/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main#why-stale-index-doesnt-show-up-in-reviews" class="hash-link" aria-label="Direct link to Why &quot;Stale Index&quot; Doesn't Show Up in Reviews" title="Direct link to Why &quot;Stale Index&quot; Doesn't Show Up in Reviews" translate="no">​</a></h2>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="ai-engineering" term="ai-engineering"/>
        <category label="coding-agents" term="coding-agents"/>
        <category label="infrastructure" term="infrastructure"/>
        <category label="code-review" term="code-review"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The conversation_id Collision That Swapped Two Users' Contexts at the Gateway]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A flat conversation_id namespace plus drifting per-tier UUID generators can swap two users' contexts at the gateway. Treat conversation IDs with the rigor your payments team applies to transaction IDs.]]></summary>
        <content type="html"><![CDATA[<p>A customer support ticket arrives that reads like a hallucination. The user attached a screenshot: a question they never asked, with their account name at the top, followed by a model response that references files they have never uploaded. The trace looks clean. The model did exactly what was asked of it. The problem is that the question came from a different tenant entirely, and your gateway routed two conversations to the same backend state because their <code>conversation_id</code> values collided.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20conversation_id%20Collision%20That%20Swapped%20Two%20Users%27%20Contexts%20at%20the%20Gateway" alt="" class="img_ev3q"></p>
<p>You do the math on a napkin. UUID v4 has 122 bits of entropy. The birthday-bound probability of any collision in a 50-million-conversation corpus is somewhere south of one in fifty million. You ran the calculation a year ago when you designed the system. The math was correct. The math is still correct. What changed is that two of your backend tiers stopped generating IDs the same way, and the probability the math described was never the probability you were actually running on.</p>
<p>This is the failure mode that ID schemes hide most aggressively: each tier is individually correct, the global system is wrong, and the gap is invisible until the day a user sees another user's data. The fix is not a better generator. It is a different way of thinking about where the ID contract lives.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-probability-you-computed-was-for-the-system-you-used-to-have">The probability you computed was for the system you used to have<a href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#the-probability-you-computed-was-for-the-system-you-used-to-have" class="hash-link" aria-label="Direct link to The probability you computed was for the system you used to have" title="Direct link to The probability you computed was for the system you used to have" translate="no">​</a></h2>
<p>The birthday bound assumes a single generator drawing from a single distribution. The moment you have two backend tiers — each with its own generator, its own randomness source, its own version of the UUID library — you have two distributions, and the joint collision domain is no longer described by the math you ran on a single tier.</p>
<p>The drift modes that show up in production are almost always boring. One tier upgraded its UUID library from a version that called <code>getrandom(2)</code> to a version that lazily fell back to a userspace PRNG when the syscall returned <code>EAGAIN</code> under load. Another tier ran in a container whose base image seeded its CSPRNG once at boot and inherited that seed into every forked worker because the entropy pool was warming up later than the application started. A third tier "improved" performance by caching a request-scoped UUID generator that turned out to draw from a 32-bit space when it was supposed to draw from 122.</p>
<p>Every one of those changes was reviewed locally, passed its own tests, and shipped without incident on the tier that owned it. None of them was reviewed against the joint distribution. The day you discover that one-in-fifty-million has become one-in-fifty-thousand is the day the gateway routes two live conversations to the same backend record.</p>
<p>The lesson is not "use a better RNG." It is that the collision properties of an ID scheme are an emergent property of the composition of all generators that mint into that namespace, and composition is not a property any single tier can audit.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-gateway-is-the-place-where-the-composition-is-observed">The gateway is the place where the composition is observed<a href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#the-gateway-is-the-place-where-the-composition-is-observed" class="hash-link" aria-label="Direct link to The gateway is the place where the composition is observed" title="Direct link to The gateway is the place where the composition is observed" translate="no">​</a></h2>
<p>Your gateway is the only component in the path that sees IDs from every tier on its way to backend state. It is also, almost universally, the component that does the least validation of those IDs. The default pattern is to hash the <code>conversation_id</code> to pick a backend pod, route the request, and let the backend resolve the ID against its store. If two tiers minted the same ID into different backend records, the gateway routes the request to whichever record happens to live on the pod the hash selects. The other record becomes invisible. The user whose conversation got served is somebody else.</p>
<p>Treat the gateway as the enforcement point for the namespace contract. A <code>conversation_id</code> that resolves to two different backend records is a routing error, not a backend error, because it is the gateway that has the global view. The validation that catches this is not subtle. On every request, check whether the ID exists in more than one backing store; if it does, refuse to route and page the on-call. The check is cheap when collisions are rare, which is the operating regime you expect; the alert is loud when collisions are not rare, which is the regime you need to catch.</p>
<p>The objection that this adds a hop to every request is real and the answer is that you can sample. A 1% sample on a steady-state traffic shape will find a collision rate of one-in-fifty-thousand inside a minute, which is the latency you need to convert "users are reading other users' data" from a multi-hour support escalation into a paging-grade incident.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="per-tenant-prefixes-change-the-blast-radius">Per-tenant prefixes change the blast radius<a href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#per-tenant-prefixes-change-the-blast-radius" class="hash-link" aria-label="Direct link to Per-tenant prefixes change the blast radius" title="Direct link to Per-tenant prefixes change the blast radius" translate="no">​</a></h2>
<p>Even if your generator is perfect, the collision blast radius is set by the namespace structure, not by the entropy of any one ID. A flat <code>conversation_id</code> namespace means that any collision is potentially cross-tenant; a <code>tenant_id:conversation_id</code> namespace means that a collision can only happen within a tenant, which converts a security incident into a (still bad, but containable) consistency bug.</p>
<p>The architectural move is to treat tenant isolation as a property of the ID's structure, not of the application logic that consumes the ID. If your IDs carry their tenant in their prefix, then the question "could this ID belong to another tenant" has a single answer at the namespace level, and the application code that handles them can be written without the constant background fear that tenant-checking is a discipline rather than a guarantee. Every place that handles an ID is a place where the tenant check could be forgotten; making the tenant a structural property of the ID removes most of those places.</p>
<p>The objection that this leaks tenant identifiers into URLs and logs is also real, and the answer is that for an agentic product the tenant boundary is already the most important fact about every operation. If you cannot tell from a request which tenant it belongs to, you cannot audit, you cannot rate-limit, you cannot bill, and you cannot investigate cross-tenant incidents. The tenant is going to be in your logs no matter what. Putting it in the ID is the cheapest way to make sure it is in the ID consistently.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-single-allocation-authority-makes-composition-reasonable">A single allocation authority makes composition reasonable<a href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#a-single-allocation-authority-makes-composition-reasonable" class="hash-link" aria-label="Direct link to A single allocation authority makes composition reasonable" title="Direct link to A single allocation authority makes composition reasonable" translate="no">​</a></h2>
<p>The deeper fix for the multi-tier drift problem is to stop letting each tier mint IDs into a shared namespace. Run a single ID-allocation service that owns the namespace and exposes a generation API. Tiers call it. The tiers do not have UUID libraries. They have an HTTP client that asks the allocator for the next ID for a given tenant, gets back a string, and uses it. The allocator's RNG is the only RNG in the system. The allocator's library version is the only library version in the system. The allocator's audit is the only audit you need.</p>
<p>The cost is one network hop on conversation creation, which is almost never on the hot path. The benefit is that your collision properties are now a property of one well-understood component rather than an emergent property of every tier's local choices. When you upgrade the allocator's generator from UUID v4 to UUID v7, you upgrade it everywhere, at once, with a single review. When you discover that a generator had a bad year, you have one place to investigate and one place to fix.</p>
<p>This is the same pattern your payments system already uses. Nobody on the payments team generates a transaction ID inside a checkout service. There is a transaction-allocation authority, it has its own redundancy and audit, and every service that needs a transaction ID asks it for one. The reason payments works this way is that the cross-transaction failure mode of two independently-correct generators is unacceptable, and the cost of a network hop on the rare creation path is trivial compared to the cost of an audit gap. Agentic products have the same cross-conversation failure mode and have not yet adopted the same discipline.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="conversation-ids-are-payment-ids-in-everything-but-name">Conversation IDs are payment IDs in everything but name<a href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#conversation-ids-are-payment-ids-in-everything-but-name" class="hash-link" aria-label="Direct link to Conversation IDs are payment IDs in everything but name" title="Direct link to Conversation IDs are payment IDs in everything but name" translate="no">​</a></h2>
<p>The argument that ties the rest of this together is short. In an agentic product, the <code>conversation_id</code> is the load-bearing primary key of the user's relationship with the model. It indexes the user's memory. It indexes the user's billing. It indexes the audit trail. It is the key on which cross-tenant isolation depends. Every meaningful operation in the system either takes a <code>conversation_id</code> as input or produces one as output. The discipline that surrounds it should be at least equal to the discipline that surrounds a transaction ID.</p>
<p>The mismatch most teams have is that conversation IDs got their identifier scheme decided early, by a single engineer, in the first sprint, when there was one backend and the question of how IDs would compose across tiers was not a question yet. Three years later there are four tiers, the original engineer is on a different team, and the ID scheme is load-bearing for a product that was not the product when the scheme was designed. The rigor never caught up because nobody scheduled the catch-up.</p>
<p>What good looks like, concretely:</p>
<ul>
<li class="">A single allocator owns the conversation namespace and is the only thing in production that mints IDs into it.</li>
<li class="">Every ID carries a tenant prefix that bounds the cross-tenant blast radius even if the random suffix collides.</li>
<li class="">The gateway samples or fully validates that incoming IDs resolve to exactly one backend record and pages on duplicates.</li>
<li class="">The ID scheme has a security review on the same cadence as the payments scheme, asking explicitly "what is the cross-tenant failure mode if any single component drifts."</li>
<li class="">The team that owns the allocator has a runbook for "the generator looks like it regressed," and the runbook has been practiced.</li>
</ul>
<p>None of those are exotic. Three of them are imported wholesale from payments engineering. The reason they have not propagated into agentic stacks is that "conversation_id" sounds like a chat-history detail rather than a primary key, and the teams treating it like a detail are exactly the teams who will write the incident report.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-discipline-transfer-is-the-work">The discipline transfer is the work<a href="https://tianpan.co/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#the-discipline-transfer-is-the-work" class="hash-link" aria-label="Direct link to The discipline transfer is the work" title="Direct link to The discipline transfer is the work" translate="no">​</a></h2>
<p>Treat the <code>conversation_id</code> as the primary key it actually is. Stop letting each tier mint into a shared namespace. Make the gateway validate the composition. Put the tenant in the ID so isolation is a structural property and not a developer-discipline property. Run the security review the payments team has been running for thirty years.</p>
<p>The math on UUID collisions is correct. It is correct for a system you are not running. The system you are running is a composition of generators, and its collision properties are a property of the composition, and the composition is what you should be auditing. The day you treat the conversation namespace with the rigor your payments team applies to transactions is the day this class of incident stops appearing in your weekly review under "investigating."</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="multi-tenant" term="multi-tenant"/>
        <category label="api-gateway" term="api-gateway"/>
        <category label="identifiers" term="identifiers"/>
        <category label="agent-infra" term="agent-infra"/>
        <category label="reliability" term="reliability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Conversation Summary Your Agent Regenerated Each Turn Because the Cache Key Included a Timestamp]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A one-line debugging change added a timestamp to a summary cache key and silently tripled the LLM bill for two weeks — a study in why cache keys are a contract, not plumbing, and why hit rate must be a first-class signal.]]></summary>
        <content type="html"><![CDATA[<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Conversation%20Summary%20Your%20Agent%20Regenerated%20Each%20Turn%20Because%20the%20Cache%20Key%20Included%20a%20Timestamp" alt="" class="img_ev3q"></p>
<p>A cache that is being written to but never read from is not a cache. It is a logging system with extra latency, billed by the kilobyte. And the cruelest version of this failure mode is the one where the cache looks healthy from every angle except the one that matters: the <code>set</code> calls succeed, the <code>get</code> calls return quickly, the keys are well-formed, the values are valid, the TTLs are sensible. The only thing wrong is that no <code>get</code> call ever finds the key a previous <code>set</code> call wrote, because a single field in the key changes every time it is computed.</p>
<p>This is the story of a debugging session that added a timestamp to a cache key "so I can tell which cache entry I'm looking at," and the system that quietly paid for fourteen extra LLM calls per conversation for two weeks before anyone noticed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-setup-the-cache-was-built-for">The Setup the Cache Was Built For<a href="https://tianpan.co/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp#the-setup-the-cache-was-built-for" class="hash-link" aria-label="Direct link to The Setup the Cache Was Built For" title="Direct link to The Setup the Cache Was Built For" translate="no">​</a></h2>
<p>The agent in question handled long-running customer support conversations. Average conversation length crept up through the year as users learned the product could carry context across many turns, and the team eventually hit the ceiling of the model's context window often enough to need a strategy. They picked the obvious one: a running summary, regenerated as the conversation grew, prepended to each new turn in place of the older messages.</p>
<p>The summary itself was generated by a separate model call. Cheaper model, terse prompt, structured output. It cost a few cents per generation, which sounded fine in isolation, but multiplied across the conversation volume it would have been brutal if every turn paid for it. So the team did the right thing and cached it.</p>
<p>The cache key was straightforward:</p>
<p><code>hash(conversation_id, last_message_id)</code></p>
<p>The semantics were exactly what you'd want. Two turns that produce the same summary input produce the same key. The summary for "conversation 47, after message 12" is computed once and reused on every subsequent read until message 13 arrives, at which point the key changes and a new summary is computed. The hit rate sat at 94% for months, which is roughly the ratio of "turns that read an existing summary" to "turns that mint a new one," and it was approximately what the math predicted.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-debugging-session-that-added-a-field">The Debugging Session That Added a Field<a href="https://tianpan.co/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp#the-debugging-session-that-added-a-field" class="hash-link" aria-label="Direct link to The Debugging Session That Added a Field" title="Direct link to The Debugging Session That Added a Field" translate="no">​</a></h2>
<p>A junior engineer was investigating an unrelated bug where the summary occasionally seemed stale. The actual cause turned out to be a race condition in the upstream message store, but during the investigation, the engineer wanted a quick way to disambiguate cache entries in a debugger. They added a <code>cached_at</code> timestamp to the cache key.</p>
<p>Their reasoning was reasonable in context. "I keep looking at two entries and I can't tell which one I just wrote." The timestamp meant every write produced a visibly distinct key, and they could correlate cache contents to logs by the timestamp suffix. The PR did exactly what it said it did. The reviewer saw a small change in a cache layer, a one-line addition, no test changes required because the cache had no tests beyond "the round-trip works," and approved it.</p>
<p>The race-condition bug was eventually fixed elsewhere. The timestamp field was forgotten. The cache layer continued to function as a write-through store: every call wrote a new entry, returned the freshly computed value, and moved on. From the outside, nothing looked wrong. The summary endpoint returned correct results. Latency was a little higher, but well within the noise band. No errors were thrown.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-two-weeks-nobody-noticed">The Two Weeks Nobody Noticed<a href="https://tianpan.co/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp#the-two-weeks-nobody-noticed" class="hash-link" aria-label="Direct link to The Two Weeks Nobody Noticed" title="Direct link to The Two Weeks Nobody Noticed" translate="no">​</a></h2>
<p>The cache hit rate dropped from 94% to 0% the day the change shipped. The LLM bill for summary generation tripled across the next two weeks. The team noticed the cost spike during the monthly finance review, but their first reading was a product-level story rather than a system-level one: "Conversations are getting longer, more of them are crossing the compression threshold, so we're doing more summaries." It was a coherent narrative. It fit the trend line of the prior six months. It just happened to be wrong.</p>
<p>The actual diagnosis came when an engineer profiled a single fifteen-turn conversation end to end and counted fourteen summary generations against a baseline of one. The summary for turns 3 through 14 had been cached and reused under the old key scheme; under the new scheme, each turn produced a key that no previous turn had written, so the cache was effectively cold on every read.</p>
<p>A few things made the misdiagnosis last as long as it did:</p>
<ul>
<li class="">The cache layer's own metrics page showed "writes per second" and "reads per second" but not "hit rate," because nobody had wired hit rate into the dashboard when the cache was built. The team had been operating on the assumption that the cache was working because the application was working.</li>
<li class="">The LLM provider's billing dashboard aggregates by model, not by call site. The increase showed up as "more calls to the summary model," which was true but uninformative.</li>
<li class="">The conversation length distribution actually had drifted longer. There was a real, smaller secondary signal that confirmed the team's first hypothesis, which meant the cache regression hid inside a real trend.</li>
<li class="">The summary endpoint had no SLO that would have caught the latency drift. Each individual call was within budget. The aggregate cost was the symptom, and the cost dashboard was only reviewed monthly.</li>
</ul>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="llm-infrastructure" term="llm-infrastructure"/>
        <category label="caching" term="caching"/>
        <category label="observability" term="observability"/>
        <category label="cost-optimization" term="cost-optimization"/>
        <category label="context-engineering" term="context-engineering"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Cost Dashboard Your Finance Team Built That Excluded the Embeddings Re-index]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Per-feature dashboards track token spend. Per-provider dashboards track invoices. The quarterly embeddings re-index falls between both and lands in an unowned infrastructure bucket — where 40% of AI spend goes to die unreviewed.]]></summary>
        <content type="html"><![CDATA[<p>Your finance team built a beautiful AI cost dashboard. Token spend, sliced by feature. Embedding spend, sliced by provider. Every quarter, the per-feature pane gets reviewed in a leadership meeting and somebody asks why the support-chat workflow is up 12%, and a product manager has a defensible answer. Every quarter, the per-provider pane gets reviewed in an infra meeting and somebody asks why OpenAI is up 8%, and a platform engineer has a defensible answer. And every quarter, the line that actually doubles your AI bill — the corpus re-index — lands in a third bucket called "infrastructure" that nobody reviews because nobody owns it.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Cost%20Dashboard%20Your%20Finance%20Team%20Built%20That%20Excluded%20the%20Embeddings%20Re-index" alt="" class="img_ev3q"></p>
<p>That bucket is where forty percent of your AI spend goes to die unattributed. The teams who could have optimized it never see it. The teams who see it can't tell you which feature it serves. The dashboard is honest about every cost it can explain and silent about the cost it can't, which is exactly the cost that matters most.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-two-axes-your-dashboard-is-built-on">The Two Axes Your Dashboard Is Built On<a href="https://tianpan.co/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex#the-two-axes-your-dashboard-is-built-on" class="hash-link" aria-label="Direct link to The Two Axes Your Dashboard Is Built On" title="Direct link to The Two Axes Your Dashboard Is Built On" translate="no">​</a></h2>
<p>Per-feature cost attribution made sense as the headline view because it answered the question executives actually ask: "what is the support copilot costing us?" The architectural move that made it possible was small — a <code>feature_id</code> tag on every LLM call, propagated through the gateway, summed nightly. Practitioners writing about LLM FinOps in 2026 keep returning to this one decision because it is the single line of code that determines whether per-feature accounting is possible at all.</p>
<p>Per-provider attribution made sense as the second view because the procurement team owns vendor relationships and needs to negotiate against a real number. OpenAI rolls up. Anthropic rolls up. Voyage rolls up. The infra team can compare unit prices, talk to account reps, and decide when to migrate.</p>
<p>Both views are correct. Both views are useful. Neither view sees a quarterly re-index.</p>
<p>The reason is structural: a re-index is not a request. It does not pass through your gateway with a <code>feature_id</code> header. It does not show up in your per-call telemetry. It runs as a batch job — initiated by a platform engineer, often outside business hours, against a corpus owned by no single product team — and it bills as a single fat line item in your provider's invoice on the first of the month. Your per-feature dashboard cannot see it because nothing tagged it. Your per-provider dashboard sees it but cannot say what it was for.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-re-index-is-where-the-money-actually-is">Why the Re-index Is Where the Money Actually Is<a href="https://tianpan.co/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex#why-the-re-index-is-where-the-money-actually-is" class="hash-link" aria-label="Direct link to Why the Re-index Is Where the Money Actually Is" title="Direct link to Why the Re-index Is Where the Money Actually Is" translate="no">​</a></h2>
<p>The numbers vary by stack but the shape is consistent. Re-embedding a hundred-million-token corpus with a frontier embedding model lands somewhere between five and fifteen thousand dollars in API charges alone, before the compute spent reading, chunking, and writing back to the vector store. Do that quarterly because the corpus drifts, do it again whenever you upgrade the embedding model, do it again whenever you change chunking strategy, and you have a recurring expense that exceeds the entire month-over-month token spend of small features.</p>
<p>Reports from teams who have actually paid these bills describe the same pattern: infrastructure costs grow faster than per-call costs because per-call costs are visible and per-call costs get optimized, while the batch costs are invisible and untouched. Engineers writing about hidden costs of in-house memory systems point out that the embedding API price is negligible — pennies per million tokens — but the operational tax of running the re-index pipeline, the egress out of the vector store, the storage churn, the engineer-weeks of validation, dwarfs the API line by an order of magnitude. The headline number on the OpenAI bill is the smallest part of the actual cost.</p>
<p>When you ask the team that runs the re-index how often it happens, you get an answer like "every quarter, give or take." When you ask why every quarter, you get an answer like "because the model rev came out" or "because the index quality dropped" or "because we wanted to try a smaller embedding dimension." None of those answers are tied to a product roadmap. The re-index cadence is decided by the embedding model release schedule and the patience of the platform engineer who notices recall degrading, neither of which is a budget owner.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bucket-named-infrastructure-is-where-ownership-goes-to-hide">The Bucket Named "Infrastructure" Is Where Ownership Goes to Hide<a href="https://tianpan.co/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex#the-bucket-named-infrastructure-is-where-ownership-goes-to-hide" class="hash-link" aria-label="Direct link to The Bucket Named &quot;Infrastructure&quot; Is Where Ownership Goes to Hide" title="Direct link to The Bucket Named &quot;Infrastructure&quot; Is Where Ownership Goes to Hide" translate="no">​</a></h2>
<p>There is a recurring pattern in cloud cost reporting that practitioners describe with weary specificity: the costs that get attributed are the costs that someone has a reason to attribute. Per-feature spend gets attributed because product managers need defensible budgets. Per-provider spend gets attributed because procurement needs leverage in negotiations. Everything else lands in a residual bucket — call it "infrastructure," call it "platform," call it "shared" — that is by construction unowned. The bucket exists because the alternative is admitting that a meaningful fraction of your AI spend has no owner, which is awkward in a board deck.</p>
<p>The unowned bucket has a perverse property: it is the easiest line to optimize and the hardest line to motivate optimizing. A retrieval team that knew its re-index cost would aggressively investigate smaller embedding dimensions, drift-adapter patterns that map new query embeddings into legacy space, lazy re-embedding strategies that defer cost to read time. A retrieval team that does not know its re-index cost reruns the same pipeline every quarter with no review because nobody is asking. The dollars are large; the political pressure is zero. That combination is what compounds into a quietly enormous bill.</p>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="finops" term="finops"/>
        <category label="embeddings" term="embeddings"/>
        <category label="rag" term="rag"/>
        <category label="cost-attribution" term="cost-attribution"/>
        <category label="leadership" term="leadership"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Data Residency Contract Your Provider Honored at the API Boundary and Broke at the Cache]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Regional API endpoints commit to where your request goes, not where the cached prefix bytes that satisfy it live. The auditable boundary and the cache placement boundary are governed by different SLAs — and the gap is where compliance posture breaks.]]></summary>
        <content type="html"><![CDATA[<p>Your residency audit traced every outbound request from the tenant's traffic, watched it terminate on a hostname in Frankfurt, and signed off. The audit was correct about everything it measured. It was also looking at the wrong layer. The request went to the EU. The bytes that satisfied the request — the cached prefix the provider hashed and pulled from the nearest available node — lived in us-east-1. Your regional endpoint promised you a destination. The cache promised nothing, because the cache was a different product, governed by a different SLA, designed for cost rather than for compliance.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Data%20Residency%20Contract%20Your%20Provider%20Honored%20at%20the%20API%20Boundary%20and%20Broke%20at%20the%20Cache" alt="" class="img_ev3q"></p>
<p>The customer's auditor caught it. Not yours. A different vendor's incident report mentioned that prompt cache placement was decoupled from inference region, and the customer's GRC team asked the obvious follow-up question: where do our prefixes go? The contract amendment to close the gap took ninety days. The renewal got suspended. The team that wrote the integration had done nothing wrong by the documentation they were handed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-two-boundaries-are-not-the-same-boundary">The Two Boundaries Are Not the Same Boundary<a href="https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#the-two-boundaries-are-not-the-same-boundary" class="hash-link" aria-label="Direct link to The Two Boundaries Are Not the Same Boundary" title="Direct link to The Two Boundaries Are Not the Same Boundary" translate="no">​</a></h2>
<p>A regional API endpoint is a routing commitment. You point your client at <code>eu-frankfurt.provider.com</code> and the request terminates on infrastructure inside the region. That is the boundary your network-level audit can see and certify. Outbound packets, destination IPs, TLS handshake — all observable, all auditable, all clean.</p>
<p>A prompt cache is something else. It is a content-addressed store, keyed by a hash of the prefix (the system prompt, the tool schema, the few-shot examples) so identical prefixes from any tenant can reuse the same entry. The whole point of the cache is to avoid recomputing attention states the provider has already computed for someone. That economic logic only works if the cache is large, shared, and placed near compute capacity rather than near the request origin. A per-region cache that mirrors your routing topology would defeat the savings model.</p>
<p>So the provider builds the cache as a global pool. The prefix gets hashed, the hash gets written to whichever node has eviction headroom, and that node is selected by capacity heuristics that do not know your contract exists. Your EU-tenant request terminates in Frankfurt. The cache write goes to us-east. The audit was looking at the front door. The bytes left through a window in the back.</p>
<p>This is not negligence. The two systems were designed by different teams against different requirements. The inference layer ships with a regional commitment because customers ask for one. The cache layer ships with a cost commitment because the per-token economics demand one. Neither team is wrong about its own SLA. The customer is wrong about thinking the two SLAs compose.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-inventory-misses-it">Why the Inventory Misses It<a href="https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#why-the-inventory-misses-it" class="hash-link" aria-label="Direct link to Why the Inventory Misses It" title="Direct link to Why the Inventory Misses It" translate="no">​</a></h2>
<p>The compliance team's residency control catalog has an entry for inference. It probably does not have an entry for cache. The reason is mundane: when the data inventory was built, the cache was either a private optimization the provider had not yet exposed or it was a feature with no per-region commitment to inventory. The inventory captured what was inventoryable. Anything the provider added later, sold as a free optimization, and enabled by default lives outside the catalog.</p>
<p>The pattern repeats across providers. Read the residency commitment carefully and you find clauses like <em>"Extended prompt caching in regions that do not support regional processing may require that we process and temporarily store customer content outside of the region to deliver the services."</em> That sentence is a complete description of the leak. It is also buried in a help-center article, not in the master agreement the GRC team reviews at procurement time. The contract the customer signed talks about inference. The footnote that mentions cache placement is two clicks deeper.</p>
<p>The other failure mode is just as common. Customers route through a hyperscaler-mediated path — Bedrock Frankfurt, Vertex EU — believing the hyperscaler's regional commitment subsumes the model provider's. It usually does for the inference call. It does not always for ancillary features. Bedrock's documentation on prompt caching includes the disclaimer that <em>"caches are regional, so you might send two identical requests to the same inference profile and have the second one be a cache miss if they were routed to different regions"</em> — which is the same fact from the other direction. If the provider does honor a regional cache, you pay for it in cache miss rate. If the provider does not, you pay for it in the residency gap.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-residency-diagram-actually-covers">What the Residency Diagram Actually Covers<a href="https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#what-the-residency-diagram-actually-covers" class="hash-link" aria-label="Direct link to What the Residency Diagram Actually Covers" title="Direct link to What the Residency Diagram Actually Covers" translate="no">​</a></h2>
<p>Draw the residency diagram you would show an auditor. A box labeled <em>client</em>, an arrow into a box labeled <em>EU endpoint</em>, an arrow into a box labeled <em>inference cluster (eu-frankfurt)</em>. The diagram closes; the bytes stay in the region. The auditor signs.</p>
<p>Now draw the implementation diagram. Same client, same EU endpoint, same inference cluster. But before the cluster, there is a step labeled <em>cache lookup</em>. Before the response goes out, there is a step labeled <em>cache write</em>. Both of those steps talk to a service labeled <em>global prompt cache</em>, and that service has shards in regions the residency diagram does not list. Some of those shards hold the prefix of every request your tenant has ever made. Some of them hold the response, too, if the provider caches output.</p>
<p>The first diagram is the one your contract covers. The second diagram is the one your data flows through. The compliance posture you have is a function of the first. The compliance posture you actually need is a function of the second. The gap between them is the cache layer, and no amount of more careful routing can close it from your side.</p>
<p>There is a research literature on this gap. The arXiv paper that introduced <em>MemPool</em> — an elastic memory pool managing distributed KV caches across serving instances — describes a global scheduler that enhances cache reuse through a global prompt-tree-based locality-aware policy. The locality the scheduler optimizes for is <em>prefix locality</em>, not <em>tenant locality</em>. A separate NDSS paper on prompt leakage via shared KV-cache notes that seven of eight surveyed LLM providers share caches globally across users. Both papers are about systems-level efficiency. Both inadvertently describe a residency hazard.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-patterns-that-close-the-gap">The Patterns That Close the Gap<a href="https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#the-patterns-that-close-the-gap" class="hash-link" aria-label="Direct link to The Patterns That Close the Gap" title="Direct link to The Patterns That Close the Gap" translate="no">​</a></h2>
<p>There is no architectural trick that fixes this from the consumer side alone. Every closure path requires either contractual change with the provider or a deliberate sacrifice of the optimization the cache exists to deliver. Pick the one whose cost you can stomach.</p>
<p><strong>Amend the contract to name cache placement.</strong> The contract that names <em>inference</em> residency does not name <em>cache</em> residency. The fix is to add a clause that does — explicitly per region, with a provider certification that the cache layer's residency is audited separately from the inference layer's. Enterprise contracts at major providers can carry this language; it is not always offered by default, and procurement has to ask. The 90-day amendment cycle is the typical cost. Build the slack into your renewal window.</p>
<p><strong>Opt out of the shared cache for regulated traffic.</strong> Every major provider lets you disable prompt caching per request, usually via a header or a parameter. Disabling it for EU-tenant traffic trades the token savings for the residency guarantee. The math depends on cache hit rate; if your prefixes are stable and reused often, the cost is real. If your prefixes change per request anyway, the cost is rounding. Compute it before assuming it is too expensive.</p>
<p><strong>Audit the cache layer like you audit the inference layer.</strong> A residency review that only traces outbound request destinations is reviewing the wrong layer. Extend the review to trace a sample prefix from request through cache write to verify the destination. The provider will not always give you the introspection you need. Push for it. The fact that you cannot see the cache layer is itself an audit finding.</p>
<p><strong>Treat every "free optimization" as a contract surface.</strong> Prompt cache is one example. Model routing, request batching, sub-processor changes, default content-safety filtering — all of these are features the provider enables by default to improve cost or quality, and any of them can move bytes across boundaries your contract names. The architectural review at procurement should enumerate them and certify each against the residency catalog. The ones not on the catalog are the ones to ask about.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-architectural-realization">The Architectural Realization<a href="https://tianpan.co/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#the-architectural-realization" class="hash-link" aria-label="Direct link to The Architectural Realization" title="Direct link to The Architectural Realization" translate="no">​</a></h2>
<p>The provider's regional endpoint is a commitment about where your request goes. It is not a commitment about where the bytes that satisfy your request live. Those are different statements about different layers, and the team that reads the first as a guarantee of the second has built a compliance posture that depends on a topology the provider never disclosed.</p>
<p>This generalizes beyond prompt cache. The same shape appears in CDN edge caching for non-AI workloads, in cross-region replication of feature stores, in queue-based message delivery where the queue's regional placement is independent of the producer's and consumer's. In every case, the auditable boundary (request routing) and the unauditable boundary (downstream storage placement) are governed by different SLAs with the same provider, and the customer's compliance posture is only as strong as the weakest of the two.</p>
<p>The defensive posture is to assume the cache topology is undisclosed, ask explicitly about it at contract time, and treat every default-on optimization as a candidate boundary crossing until the provider has put the residency commitment in writing for that specific feature. The offensive posture is to design the procurement review around enumerating cross-boundary surfaces rather than enumerating endpoints. The two postures arrive at the same checklist; only the second one catches the next feature the provider rolls out after the contract is signed.</p>
<p>The team that reads the residency contract as a routing guarantee builds a control surface that depends on a vendor disclosing what they are doing. The team that reads it as a layer-by-layer commitment builds one that depends on a vendor agreeing to what they will not do. The second posture is the one that survives the next product launch.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="ai-engineering" term="ai-engineering"/>
        <category label="data-residency" term="data-residency"/>
        <category label="compliance" term="compliance"/>
        <category label="prompt-caching" term="prompt-caching"/>
        <category label="gdpr" term="gdpr"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Debug Logger That Put Your System Prompt in a Customer-Readable Audit Feed]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A field your platform team added for incident triage ended up in a tenant audit export — the disclosure required no attacker, just two correct decisions composing into a leak.]]></summary>
        <content type="html"><![CDATA[<p>A security-conscious customer pulled their tenant's audit export, opened the JSON, and read the verbatim refusal policy, retrieval pipeline structure, and a handful of internal product identifiers from a field called <code>llm.request.system</code>. No exploit. No prompt injection. No jailbreak. Just a log field your platform team added six months earlier so engineers could correlate prompt versions with incidents — surfaced through a feed your enterprise team had separately opened to tenants for SOC 2 reasons.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Debug%20Logger%20That%20Put%20Your%20System%20Prompt%20in%20a%20Customer-Readable%20Audit%20Feed" alt="" class="img_ev3q"></p>
<p>The disclosure happened during a normal Wednesday afternoon. Your security team got paged by the customer, not by an alert. The incident timeline doesn't show a deploy on the day of the leak — the misconfiguration shipped on the day the audit feed expanded its field allowlist, which was a different team, a different sprint, and a different ticket. Both reviewers signed off on what they were looking at. Neither was looking at the composition.</p>
<p>This is the failure mode that prompt-extraction research keeps treating as an adversarial problem when it is increasingly a configuration problem. Recent studies show system prompt extraction succeeding around 60% of the time across enterprise AI assessments, and the published mitigations are still framed as "harden the model against the user." But the leaks that show up in postmortems aren't from cleverly-worded jailbreaks. They are from the system prompt being written to a place your access-control model treats as ordinary metadata.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-seam-nobody-owns">The seam nobody owns<a href="https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#the-seam-nobody-owns" class="hash-link" aria-label="Direct link to The seam nobody owns" title="Direct link to The seam nobody owns" translate="no">​</a></h2>
<p>A debug field's lifecycle has at least three handoffs that look uncontroversial in isolation.</p>
<p>The platform team adds <code>llm.request.system</code> to a structured log to support a real engineering need: correlating an in-flight prompt version with an incident. This is a defensible decision. Without it, you cannot answer "which version of the system prompt produced this answer" during an outage, and you cannot retire a bad prompt with confidence.</p>
<p>The observability team ingests the field into the trace store the same way it ingests every other field — by type. It is a string. Strings have a configured redactor that scrubs email addresses, credit card patterns, and anything matching a few PII regexes. The redactor does not recognize a system prompt because a system prompt does not look like PII. It looks like product copy.</p>
<p>The enterprise team, working from a different roadmap, extends the customer-visible audit feed to surface request-level fields so tenants can satisfy their own auditors. The allowlist is built by the product manager who knows which fields the customer is asking for. They include <code>llm.request.system</code> because tenants who run multi-model evaluations want to see which prompt their queries hit. The PM treats it like a debug field. The customer's auditor treats it like documented vendor behavior. Both are correct from their respective vantage points.</p>
<p>None of those three reviewers had the full picture. The platform team thought of the log as engineer-visible. The observability team thought sensitivity meant PII. The enterprise team thought the feed was scoped to a tenant. None of them named who owned the question "is the system prompt sensitive across all of these surfaces?" because that question crossed every team's edges.</p>
<p>The OWASP LLM Top 10 entry on system prompt leakage explicitly tells you to treat the system prompt as potentially public, and to never rely on it as a security control. That is good advice for what the prompt <em>contains</em>. It is not advice that helps you when the question is whether the prompt itself is the disclosure. A refusal policy isn't sensitive because it hides a credential. It is sensitive because it is product surface area — the encoded shape of how your assistant says no, what tools it has, what topics it routes around, and what your retrieval pipeline considers retrievable. That is competitive material. Sometimes it is regulatory material.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-redactor-missed-it">Why the redactor missed it<a href="https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#why-the-redactor-missed-it" class="hash-link" aria-label="Direct link to Why the redactor missed it" title="Direct link to Why the redactor missed it" translate="no">​</a></h2>
<p>A PII redactor is trained on the assumption that sensitive data has a recognizable surface form. Phone numbers have shapes. Emails have shapes. Even API keys and JWTs have recognizable prefixes and entropy distributions. The reason regex- and ML-based redactors achieve any precision at all is that they exploit those structural priors.</p>
<p>A system prompt has the structural prior of "an instruction in your product's voice." It is indistinguishable from documentation, from a marketing FAQ, from a support macro. The redactor sees something that looks like content and waves it through. The Safe Observability research from the OpenTelemetry community has been pushing for hierarchical, residency-aware classification precisely because the regex era cannot solve this — sensitivity is a property of the field's provenance, not the field's shape.</p>
<p>This is why field-typed sensitivity tags are the closest mechanism to a real fix. The platform team that creates <code>llm.request.system</code> should be required to attach a classification at field-definition time, not at log-line time. The classification should be <code>confidential-product</code> or <code>model-attribution-only</code> or whatever your taxonomy is — but the absence of a tag should be a default-deny rather than a default-allow.</p>
<p>Datadog and similar log platforms have shipped APIs for restricting access to log paths, but the restriction is opt-in. The team adding the field has to remember that the field is sensitive and to wire up the restriction. Memory is not a security control. Defaulting unknown fields to the most permissive tier is what produces the seam.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-composition-rule">The composition rule<a href="https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#the-composition-rule" class="hash-link" aria-label="Direct link to The composition rule" title="Direct link to The composition rule" translate="no">​</a></h2>
<p>The harder lesson is that two independently-correct decisions about visibility compose into a disclosure, and your existing review processes are not set up to catch compositions.</p>
<p>A change request to extend the audit feed allowlist is a small ticket. The reviewer asks: are these fields safe to share with the tenant? They look at each field in isolation, see a string, see that the field already has redaction applied for PII, see no flag from the data classification tool, and approve. The reviewer for the original <code>llm.request.system</code> change asked: is this field safe to log internally? They confirmed it does not contain user PII, confirmed engineers need it for incident triage, and approved.</p>
<p>Neither reviewer is the reviewer for the composition. There is no "audit feed × LLM field" reviewer in your org chart, and there shouldn't be — you cannot add a reviewer for every cross-product of features. The mechanism has to be the field's own classification, traveling with the field across systems. Treat sensitivity tags the way you treat type signatures: a function that accepts a string from one system has no idea what the string means unless the type tells it, and "string" is not a useful type.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-changes-when-prompts-are-product-surface">What changes when prompts are product surface<a href="https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#what-changes-when-prompts-are-product-surface" class="hash-link" aria-label="Direct link to What changes when prompts are product surface" title="Direct link to What changes when prompts are product surface" translate="no">​</a></h2>
<p>If you accept that system prompts are part of the product — that they encode behavior the way a config file encodes behavior — a handful of decisions move:</p>
<p>System prompts become version-controlled artifacts with the same review discipline as your auth layer. They get diffed. They get changelog entries. They get attribution. You probably already do this informally. Making it formal means the prompt has a known set of consumers — your inference layer, your eval harness, your A/B framework — and any new consumer requires a security review, the same way adding a new consumer of your session-token table would.</p>
<p>Logging fields that reference prompts get sensitivity tags at definition time. A CI check fails the PR if a new field name matches a <code>llm.request.*</code> or <code>agent.system.*</code> pattern without a tag. The check does not need to understand the field's semantics — it needs to enforce that the engineer making the change made an explicit classification decision. The PR description carries the decision into the review.</p>
<p>The customer-visible audit feed and the engineer-visible trace store have separately-maintained allowlists, and both default-deny. Adding a field to either is a deliberate act. The team that owns the audit feed has a documented process for what kinds of fields a tenant should see, and that process explicitly lists "any LLM request or response field" as requiring classification review.</p>
<p>Red-team passes that previously focused on prompt extraction at inference time also run against your customer-facing audit feeds, log exports, and trace UIs. The red-team script is trivial — look for prompt-shaped strings in any tenant-accessible export — and it catches the exact failure mode that defeats your inference-layer mitigations. If your existing pen-test coverage doesn't include this, the cost of adding it is one engineer-day.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-architectural-lesson-the-incident-review-will-rediscover">The architectural lesson the incident review will rediscover<a href="https://tianpan.co/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#the-architectural-lesson-the-incident-review-will-rediscover" class="hash-link" aria-label="Direct link to The architectural lesson the incident review will rediscover" title="Direct link to The architectural lesson the incident review will rediscover" translate="no">​</a></h2>
<p>Every postmortem for this category lands on a version of the same finding: the disclosure required no attacker. The combined system did the work an attacker would have had to do. The model never extracted the prompt because the prompt was never asked. The audit feed handed it over.</p>
<p>The temptation in the writeup is to recommend a tighter audit feed allowlist and a smarter redactor. Both are correct as immediate remediations. Neither addresses the underlying property: that an LLM application has many more surfaces that touch the prompt than a non-LLM application has surfaces that touch its config. Every observability tool, every replay harness, every eval pipeline, every A/B logger sees the prompt because the prompt is what made the call interesting to log. Each of those is a potential exfiltration path, and each one's reviewer is a different person.</p>
<p>The architectural move is to push classification into the field's origin and force it to travel. The logger that emits <code>llm.request.system</code> should be calling an API that requires a sensitivity tag and that refuses to emit if the tag is missing. The trace store should refuse to index untagged fields above a certain pattern threshold. The audit feed should refuse to surface fields above a certain sensitivity tier without an explicit tenant-export approval recorded against a ticket. None of these are novel mechanisms. They are how you already treat session tokens, payment data, and PII. The shift is recognizing that the prompt belongs in that company.</p>
<p>Prompt extraction is not a research problem you have because attackers got clever. It is an architectural problem you have because your system prompts are valuable product assets currently being treated as debug strings. The next incident in your industry will be one of the two — a customer noticing the field in their audit feed and quietly archiving it, or a competitor reading your refusal policy out of a log export they paid for. The first you'll see in your support queue. The second you may never see at all. The teams that close this gap before either happens are the ones that started classifying their LLM fields like they classify their tokens — by where the field was born, not by what the field looks like.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="llm-security" term="llm-security"/>
        <category label="observability" term="observability"/>
        <category label="audit-logs" term="audit-logs"/>
        <category label="prompt-leakage" term="prompt-leakage"/>
        <category label="ai-engineering" term="ai-engineering"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Deprecation Date That Moved While It Sat in Your Backlog]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A provider can amend a deprecation date in place with no diff and no notification. The team that filed the original date in the deferred bucket finds out the way support tickets find them.]]></summary>
        <content type="html"><![CDATA[<p>The deprecation notice arrived on a Tuesday with a sunset date six months out. Your platform team logged it in the dependency tracker with a "Q3 cutover" label and a yellow severity. It joined two other migrations already in the queue. Three weeks later, the provider amended the date inside the same URL, no diff, no inbox notification, just a quietly updated paragraph that pulled the sunset sixty days earlier into the middle of your code freeze.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Deprecation%20Date%20That%20Moved%20While%20It%20Sat%20in%20Your%20Backlog" alt="" class="img_ev3q"></p>
<p>The lifecycle page you treated as a planning document was always a contract clock. The only thing that changed is which team's calendar it controlled — and the team that owns it is not yours.</p>
<p>The pattern is depressingly consistent across providers. A model your highest-value workload depends on gets a deprecation banner with a date in the future. You file it. You triage it against the other work. You give it the seriousness that the original date implied. Then the date moves, and the version that moves it is the only version that exists, because the page is the source of truth and the page just got rewritten in place.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-deferred-bucket-is-a-bet-on-the-vendors-memory">The Deferred Bucket Is a Bet on the Vendor's Memory<a href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#the-deferred-bucket-is-a-bet-on-the-vendors-memory" class="hash-link" aria-label="Direct link to The Deferred Bucket Is a Bet on the Vendor's Memory" title="Direct link to The Deferred Bucket Is a Bet on the Vendor's Memory" translate="no">​</a></h2>
<p>Every engineering org has a triage bucket called "deferred," or "Q3," or "after the migration," that is functionally equivalent to "we trust this won't bite us before we get to it." That bucket works for internal work, where the deadline is a thing your team controls and can renegotiate. It breaks for external deadlines, where the only renegotiation channel is a vendor account manager who has no incentive to give you slack.</p>
<p>A model deprecation notice belongs in a different bucket. It is closer to a regulatory deadline than a feature request. The vendor has already committed to the sunset internally — there is a roadmap that says the GPU capacity backing your model is being repurposed, there is a successor model whose adoption metrics depend on starving the predecessor, there is a finance line item that depends on shutting down inference for an older architecture by a specific quarter. The published date is the soft end of a range the vendor has already narrowed internally. Treating it as a planning hint is a category error.</p>
<p>The fix is to route deprecation notices through whatever pipe your team uses for hard external deadlines. If your team has a runbook for SOC 2 audit prep, deprecation notices should land in that same runbook. The cutover gate should be named the day the deprecation lands, not the week before the date you optimistically assumed it would stay.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-page-is-the-system-of-record-and-it-edits-in-place">The Page Is the System of Record, and It Edits in Place<a href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#the-page-is-the-system-of-record-and-it-edits-in-place" class="hash-link" aria-label="Direct link to The Page Is the System of Record, and It Edits in Place" title="Direct link to The Page Is the System of Record, and It Edits in Place" translate="no">​</a></h2>
<p>Software engineers have an instinctive expectation that important documents are versioned. The lifecycle pages most providers publish do not honor that expectation. There is one URL. The content at that URL is mutable. There is no public changelog of edits to the deprecation table. There is no commit history. There is, in many cases, no email when the sunset date changes — the email when the deprecation is first announced is treated as sufficient, and amendments are silent.</p>
<p>This is a remarkable architectural detail to anchor production decisions on. The team that depends on a date does not own the page where the date lives. The team that owns the page treats it as marketing-adjacent documentation that they can revise for clarity. The two teams have wildly different priors about how stable that field is, and the gap between those priors is where production incidents are born.</p>
<p>The mitigation is mechanical: poll the page on a schedule, diff the content, and alert when any field in the deprecation table changes. The polling cadence should be days, not weeks — daily is reasonable, hourly is paranoid but defensible if the workload depends on a model whose deprecation would be expensive to absorb. The diff target should be the entire table, not just the model rows you currently consume, because a row's appearance and disappearance both matter. When the alert fires, the action is not "discuss in the next sync." The action is to open the runbook and re-evaluate the cutover gate against the new date.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="eval-scores-on-the-canonical-set-do-not-authorize-the-flip">Eval Scores on the Canonical Set Do Not Authorize the Flip<a href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#eval-scores-on-the-canonical-set-do-not-authorize-the-flip" class="hash-link" aria-label="Direct link to Eval Scores on the Canonical Set Do Not Authorize the Flip" title="Direct link to Eval Scores on the Canonical Set Do Not Authorize the Flip" translate="no">​</a></h2>
<p>The accelerated date forces the cutover plan to compress. The version that assumed two weeks of A/B traffic collapses to a weekend of dual-write and a Monday-morning flip. The team needs a green light, and the artifact that produces green lights is the eval suite. The eval suite returns a number above threshold. The flip is authorized.</p>
<p>The eval suite is measuring a fixed distribution. It was constructed against the traffic patterns and intent mix that existed when the labels were written, weeks or months ago. Production traffic is a live distribution that shifts continuously, and the part of it that breaks customer trust is rarely the median — it is the tail, the customer segments whose queries cluster in regions of the input space that the eval set under-samples.</p>
<p>The two failure modes here are independent and cumulative. The eval set is a snapshot, so it lags the distribution. The eval set is also a curated representation, so it under-represents the segments that the labelers found tedious, ambiguous, or hard to score. A model that scores close on the eval set can produce a tripled error rate on a specific customer segment whose queries the eval set treated as outliers. The signal that catches this is not the eval score. The signal is production error metrics segmented by customer cohort, watched on the day of the cutover and the week after.</p>
<p>The patch is to augment the eval set with stratified samples from each customer segment the workload serves, weighted toward segments whose queries are atypical. A pre-cutover dry run that routes one percent of production traffic through the replacement model from the day the deprecation lands gives the eval set six months of real-distribution feedback before the flip becomes mandatory. The team that runs this dry run finds out about the long-tail regression in the first week, not the sixth day after the flip.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-customer-segment-whose-error-rate-tripled">The Customer Segment Whose Error Rate Tripled<a href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#the-customer-segment-whose-error-rate-tripled" class="hash-link" aria-label="Direct link to The Customer Segment Whose Error Rate Tripled" title="Direct link to The Customer Segment Whose Error Rate Tripled" translate="no">​</a></h2>
<p>There is a specific failure pattern worth naming because it has happened more than once. The cutover ships on Monday. By Tuesday, top-line error metrics look fine. By Friday, they still look fine. On the following Wednesday, a single customer's support volume crosses a threshold and pages the support oncall, who escalates to engineering, who finds that the customer's specific workflow has been failing at three times the previous rate since the flip.</p>
<p>The customer's traffic was a small enough fraction of total volume that its tripled error rate did not move the aggregate metric. The aggregate metric was the one being watched. The cohort breakdown existed in the dashboard but was not on the alert path. The six days between the flip and the page were six days during which the customer's users hit a degraded experience without anyone on the team knowing.</p>
<p>The mitigation here is to wire cutover dashboards to fire on per-cohort regressions, not just aggregate ones. The threshold should be tighter for cohorts representing meaningful contract value and tighter still for cohorts whose churn signal is harder to recover than acquisition cost. The first week after a model swap is the window when these alerts should be most sensitive, because that is when the highest-impact regressions surface, and the team's attention is already mobilized.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-lifecycle-page-is-a-vendor-system-of-record">The Lifecycle Page Is a Vendor System of Record<a href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#the-lifecycle-page-is-a-vendor-system-of-record" class="hash-link" aria-label="Direct link to The Lifecycle Page Is a Vendor System of Record" title="Direct link to The Lifecycle Page Is a Vendor System of Record" translate="no">​</a></h2>
<p>Step back from the specific incident, and the architectural realization is uncomfortable. A production system you operate depends on a date stored in a system of record your team does not own, owned by a vendor whose incentive is to optimize their own roadmap and whose process for amending the date does not include notifying you. The team that does not poll the page on a schedule has built a release calendar that depends on the vendor remembering to notify them. The vendor will not remember every time.</p>
<p>This is the same shape as every vendor dependency that bit teams in earlier eras of software — the SaaS API whose breaking changes shipped behind a "minor version bump," the CDN whose IP ranges changed without an updated allowlist, the third-party SDK whose deprecation policy was a single sentence in a release note. The mature pattern in those domains is the same pattern that applies here: poll the source of truth, diff it on every poll, alert on every change, and treat the alert as a signal to re-evaluate the plan rather than as noise to dismiss.</p>
<p>A team that internalizes this stops thinking of the deprecation page as a planning document and starts thinking of it as a control plane operated by a vendor. The control plane sends signals at times the vendor chooses. The team's job is to receive those signals on the timescale the signals are actually sent, not on the timescale the team's quarterly planning cycle would prefer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="build-the-cutover-gate-the-day-the-deprecation-is-filed">Build the Cutover Gate the Day the Deprecation Is Filed<a href="https://tianpan.co/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#build-the-cutover-gate-the-day-the-deprecation-is-filed" class="hash-link" aria-label="Direct link to Build the Cutover Gate the Day the Deprecation Is Filed" title="Direct link to Build the Cutover Gate the Day the Deprecation Is Filed" translate="no">​</a></h2>
<p>The single highest-leverage change is structural. The moment a deprecation notice lands for a model your workload depends on, three things happen on the same day: a runbook entry is created that names the cutover gate, a fallback-traffic dry run is configured to route a small fraction of production through the replacement model, and a polling job is added to watch the lifecycle page for any field changes on that row. None of these wait for the next sprint. None of these get triaged into the deferred bucket.</p>
<p>The runbook entry is the artifact that survives the migration backlog reshuffles. It names the specific eval cohorts, the specific dashboards, the specific cutover window, and the specific rollback procedure. It is owned by a specific person, not a team, because the deferred bucket fills with team-owned tickets that no individual is accountable for. The polling job catches the silent amendment. The dry run accumulates the long-tail signal the canonical eval set will not produce.</p>
<p>The cost of all of this is small. The cost of not doing it is six days of degraded experience for a customer segment whose tripled error rate did not move the top-line metric. The provider's lifecycle page will continue to update in place. The vendor will continue to amend dates without notification. The only variable the team controls is whether the change registers the day it happens or the day support volume forces the page.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="llm-ops" term="llm-ops"/>
        <category label="vendor-risk" term="vendor-risk"/>
        <category label="model-lifecycle" term="model-lifecycle"/>
        <category label="incident-response" term="incident-response"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Downstream API That Kept Writing After the User Cancelled the Conversation]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Hitting stop closes the LLM stream cleanly. It does not stop the HTTP request the tool already opened to a third party that has no idea the conversation ended. Here is why AbortSignal stops at the socket, and what to build at the commit boundary instead.]]></summary>
        <content type="html"><![CDATA[<p>The user hits stop. The browser closes the SSE connection. Your AI SDK fires <code>onAbort</code>. The agent runtime sees the signal, stops requesting more tokens from the model, and tears down its loop. From inside your codebase, the cancellation looks crisp. Every subsystem you can see is doing the right thing.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Downstream%20API%20That%20Kept%20Writing%20After%20the%20User%20Cancelled%20the%20Conversation" alt="" class="img_ev3q"></p>
<p>Meanwhile, two seconds earlier, the model emitted a tool call. The runtime dispatched it. The tool's <code>execute</code> function opened a TCP connection to a third-party API and posted a payload. That HTTP request is still in flight, the third party's server is still processing it, and the third party has no way of knowing that the conversation it is serving no longer exists. The write commits. The user's mental model says they escaped the action by hitting stop. The downstream system's database says otherwise.</p>
<p>This is the failure mode that lives in the gap between in-process cancellation and remote cancellation. Most engineers reason about <code>AbortController</code> as if it propagates the way Go's <code>context.Context</code> does — a single token that fans out through every goroutine in the call graph and trips the cancellation channel at every blocking operation simultaneously. That reasoning is correct inside your process. It is mostly wrong across the network. A cancellation that traverses two HTTPS hops, an L7 load balancer, and a vendor's queue is no longer a cancellation. It is a hope.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="abortsignal-stops-your-code-not-your-dependencies-code">AbortSignal stops your code, not your dependencies' code<a href="https://tianpan.co/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation#abortsignal-stops-your-code-not-your-dependencies-code" class="hash-link" aria-label="Direct link to AbortSignal stops your code, not your dependencies' code" title="Direct link to AbortSignal stops your code, not your dependencies' code" translate="no">​</a></h2>
<p><code>AbortController</code> is a Web Platform primitive designed to interrupt blocking work that runs inside your runtime. When you wire it into <code>fetch</code>, you are asking <em>your runtime</em> to close the TCP socket and reject the promise. That is what it does. It is genuinely useful: the GPU at the provider notices the socket close within a few hundred milliseconds and stops generating tokens, which is why streaming LLM cancellation works as well as it does for the inference itself.</p>
<p>But the moment your tool's <code>execute</code> function dispatches an HTTP request to a third party that is <em>not</em> the LLM provider — Stripe, Mailgun, Salesforce, your own internal service, anything that performs a side effect — the cancellation contract changes. Closing the connection to a write endpoint does one of three things, and which one depends on the server's implementation, not yours:</p>
<ul>
<li class="">The server detects the closed socket <em>before</em> the handler reaches its commit point and aborts. This is the case you are subconsciously assuming.</li>
<li class="">The server detects the closed socket <em>after</em> commit, attempts to flush the response, fails to flush, and writes a log entry saying "client gone." The write already landed.</li>
<li class="">The server does not detect the closed socket at all because the request was enqueued for asynchronous processing. A worker downstream picks the message off a queue ten seconds later and executes it against a conversation that closed a long time ago.</li>
</ul>
<p>Three different outcomes, only one of which matches the user's expectation. The runtime cannot distinguish between them because the signal stops at the socket. There is no equivalent of <code>ctx.Done()</code> that the third party can listen to.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="cancellation-tokens-do-not-cross-process-boundaries-unless-you-design-them-in">Cancellation tokens do not cross process boundaries unless you design them in<a href="https://tianpan.co/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation#cancellation-tokens-do-not-cross-process-boundaries-unless-you-design-them-in" class="hash-link" aria-label="Direct link to Cancellation tokens do not cross process boundaries unless you design them in" title="Direct link to Cancellation tokens do not cross process boundaries unless you design them in" translate="no">​</a></h2>
<p>Go practitioners learn this lesson the hard way the first time they wire <code>context.Context</code> across a service boundary. Inside a process, cancelling the parent context immediately closes the <code>Done()</code> channel on every derived context, and every goroutine that selects on it returns within microseconds. Across a service boundary, the context value vanishes — there is no field in HTTP or gRPC's standard envelope that carries "this request has been cancelled by an upstream client."</p>
<p>You can approximate it. You can propagate a deadline header that downstream services check on each operation. You can issue an out-of-band <code>DELETE /jobs/{id}</code> after the original POST. You can include a cancellation token in the original request that the server polls before each commit point. All of these are explicit protocols you have to design, document, and enforce on both sides of the wire.</p>
<p>LLM tool-use frameworks ship with none of these protocols. The <code>fetch</code> inside the tool is identical to a fire-and-forget HTTP call. The AI SDK's <code>abortSignal</code> lives entirely on the client side of that fetch. When the SDK passes the signal into the tool's <code>execute</code> function, the <em>runtime</em> knows about the cancellation, but the destination has no awareness that the work it is doing has been abandoned. Worse, the runtime's abort might fire after the request body has already been transmitted, leaving the server in a state where it is processing a request whose initiator has hung up.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-async-work-that-outlives-the-conversation">The async work that outlives the conversation<a href="https://tianpan.co/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation#the-async-work-that-outlives-the-conversation" class="hash-link" aria-label="Direct link to The async work that outlives the conversation" title="Direct link to The async work that outlives the conversation" translate="no">​</a></h2>
<p>The harshest variant of this failure shows up when the tool's downstream API is asynchronous. The tool's <code>execute</code> function does not actually perform the side effect — it enqueues it. It calls something like <code>POST /workflows/run</code> and gets back <code>202 Accepted</code> with a run ID. From the runtime's perspective, the tool returned successfully. From the third party's perspective, a workflow is now scheduled to execute, possibly minutes from now, possibly on a different machine.</p>
<p>If the user aborts at the moment the tool returns, the runtime cancels cleanly. The conversation closes. The user's session ends. The third party's worker queue does not know any of this. It picks up the job on its own schedule and runs it against state the user thinks they have escaped. The side effect commits <em>minutes after the user closed the tab</em>.</p>
<!-- -->
<div class="loading_VaNF">Loading…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="agents" term="agents"/>
        <category label="cancellation" term="cancellation"/>
        <category label="tool-use" term="tool-use"/>
        <category label="reliability" term="reliability"/>
        <category label="idempotency" term="idempotency"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Embedding Deprecation That Halved Your Retrieval Recall Without a Deploy]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A deprecated embedding endpoint that quietly routes to a 'compatibility' successor can halve your retrieval recall without a deploy. Here's why query/document embedding mismatch is the silent killer of RAG, and how to pin endpoints to the corpus they produced.]]></summary>
        <content type="html"><![CDATA[<p>The most expensive embedding bug a RAG system can ship is the one where nothing in your repository changes. Your retrieval code is the same. Your index is the same. Your query path is the same. And one Tuesday in week six, somebody notices that the answers used to be better.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Embedding%20Deprecation%20That%20Halved%20Your%20Retrieval%20Recall%20Without%20a%20Deploy" alt="" class="img_ev3q"></p>
<p>The provider posted a sunset notice for the embedding family your index was built against twelve months ago. The platform team filed it in a deprecations dashboard with a year of runway and moved on. The sunset path wasn't a hard cutoff — it was a quiet quality regression where the deprecated endpoint started routing to a "compatibility" successor that returned vectors in the same dimensionality and a subtly different semantic geometry. Query embeddings began drifting against the corpus you embedded a year ago. Recall@10 on your standing eval slid by 47% over six weeks. The team only traced it back when an unrelated quality dashboard crossed a threshold, dragging a senior engineer into a root-cause exercise that ended at an embedding endpoint no one on the call had touched in a year.</p>
<p>This post is about the architectural mistake underneath that incident: treating embedding endpoints as fungible URLs instead of as versioned dependencies pinned to the corpus they produced. Providers do not always own up to behavior changes with a version bump, deprecation timelines are upper bounds rather than firm dates, and your retrieval recall is being renegotiated by a vendor on a cadence your eval cannot see.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-compatibility-is-the-most-dangerous-word-in-a-deprecation-notice">Why "compatibility" is the most dangerous word in a deprecation notice<a href="https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#why-compatibility-is-the-most-dangerous-word-in-a-deprecation-notice" class="hash-link" aria-label="Direct link to Why &quot;compatibility&quot; is the most dangerous word in a deprecation notice" title="Direct link to Why &quot;compatibility&quot; is the most dangerous word in a deprecation notice" translate="no">​</a></h2>
<p>A version bump from <code>embeddings-v1</code> to <code>embeddings-v2</code> is loud. Your client code changes, your index documentation changes, your tickets pile up, and somebody runs the eval. The system has a chance to surface the regression at the moment of the change.</p>
<p>A "compatibility" successor is the opposite. The provider keeps the URL, keeps the dimensionality, and keeps the response envelope. The only thing that changes is the function from text to vector. Same input, slightly different output, same shape. To every line of your client code, the call looks identical to what it looked like yesterday.</p>
<p>That is exactly the problem. Cosine similarity and dot products are only meaningful when both sides of the comparison live in the same space. The moment your query embeddings come from a different model than your document embeddings, the geometry that makes neighborhoods meaningful breaks. Practitioners writing about this describe it as "comparing apples to oranges": the numbers still come back, the index still returns ten neighbors, and most of the neighbors are now wrong in ways nothing in your stack will detect.</p>
<p>The damage is bounded by how much the two models disagree, which is bounded by how aggressively the provider tuned the successor. Some "compatibility" successors are calibrated to be near-isomorphic; others are not. You do not get to choose, and you usually do not get told.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="deprecation-as-a-behavior-surface-not-a-date">Deprecation as a behavior surface, not a date<a href="https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#deprecation-as-a-behavior-surface-not-a-date" class="hash-link" aria-label="Direct link to Deprecation as a behavior surface, not a date" title="Direct link to Deprecation as a behavior surface, not a date" translate="no">​</a></h2>
<p>Most teams treat a deprecation date as a deadline: at time T, the endpoint stops working. That mental model lets you fund the migration by burning down to T. It is a useful model for hard cutoffs.</p>
<p>It is the wrong model for soft cutoffs. A provider managing a fleet across millions of customers will optimize for availability — they would rather keep your client receiving 200 responses than break you with a 410. So the deprecation path becomes a curve: between the announcement and the official retirement, the endpoint's behavior is renegotiated to make the underlying infrastructure cheaper to operate. Quality of returned vectors is one of the knobs available.</p>
<p>Providers tend to describe these curves in the language of "feature parity adjustments" or "infrastructure improvements." The release notes are technically accurate. They are also useless as a signal that your retrieval system just got worse. The actual contract you have with the endpoint, in the deprecation window, is "we will return a vector of the same shape" — not "we will return a vector from the same semantic distribution."</p>
<p>The team that owns retrieval needs to internalize that announced sunset dates are upper bounds, and that the contract during the runway is shape-stable, not semantics-stable. Major providers now turn over models on a twelve-to-eighteen month cadence, and several have shipped two-to-four-week deprecation windows for accelerated retirements. The slack you think you have is shorter than the announcement implies, and the behavior is moving inside the window.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-asymmetric-eval-blindspot">The asymmetric eval blindspot<a href="https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#the-asymmetric-eval-blindspot" class="hash-link" aria-label="Direct link to The asymmetric eval blindspot" title="Direct link to The asymmetric eval blindspot" translate="no">​</a></h2>
<p>Almost every team has an eval that benchmarks an embedding model. Almost none of them have an eval that benchmarks a deployed index. The difference is what makes this failure mode silent.</p>
<p>Standard embedding evaluation looks like this: take a labeled query set, embed the queries with model X, embed the corpus with model X, run retrieval, score recall@k. When you upgrade to model Y, you re-embed both sides with Y and re-score. The eval correctly tells you whether Y is better than X for retrieval — when both sides live in Y-space.</p>
<p>That is not what happens in production. In production, your document embeddings were written to the index a year ago, at the cost of whatever it cost to embed your entire corpus that quarter. Re-embedding the corpus is a project. So the "eval" that actually matches production is: queries embedded by today's endpoint, against documents embedded by last year's endpoint, scored by today's labels. Most teams never run that eval, because they never set up the infrastructure to embed queries from "today's endpoint" against vectors from "last year's endpoint" — they have one endpoint, and they trust it.</p>
<p>When the provider quietly migrates that endpoint to a compatibility successor, "today's endpoint" stops being "last year's endpoint" — but only at query time. The corpus side is frozen in the index. Your eval, if you run it the standard way (re-embed both sides), will look fine, because it puts both sides back in the same space. Your production traffic, which can only re-embed one side, will degrade.</p>
<p>The asymmetry between corpus-side embeddings (paid for once, hard to redo) and query-side embeddings (paid for per request, automatically reflects today's endpoint) is exactly where a silent provider migration hides. Any eval that does not preserve that asymmetry is testing the wrong system.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="patterns-that-pin-the-contract-you-actually-need">Patterns that pin the contract you actually need<a href="https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#patterns-that-pin-the-contract-you-actually-need" class="hash-link" aria-label="Direct link to Patterns that pin the contract you actually need" title="Direct link to Patterns that pin the contract you actually need" translate="no">​</a></h2>
<p>If the contract you need is "query and document embeddings come from the same model," and the provider only contracts to "vector of the same shape," then the gap is yours to close. A few patterns are worth standing up before you need them.</p>
<p><strong>Pin the embedding model version at the corpus.</strong> Every document in your index gets a sidecar field: <code>embedding_model_id</code>, <code>embedding_model_version</code>, <code>embedding_endpoint_url</code>, and ideally a content hash of a fixed canary string embedded by that endpoint at indexing time. Your write path refuses to insert a vector without these fields. Your read path refuses to score query embeddings against documents whose model identifier does not match the one your query came from. The error is loud and immediate, not a silent recall slide.</p>
<p><strong>Treat the canary string as a contract test.</strong> Pick a small set of fixed strings — twenty to fifty of them — embed each one at the time you build the index, and store the resulting vectors as a contract artifact. At query time, on a sampled fraction of requests, re-embed one of the canary strings, take its cosine similarity to the stored vector, and assert it is above a threshold like 0.9999. The moment the provider's endpoint starts returning materially different vectors for known-fixed input, the assertion fires. This is the cheapest behavior-change detector you can deploy against a provider you do not control.</p>
<p><strong>Monitor recall@k on slope, not just on level.</strong> A standing labeled query set (two hundred to five hundred query-document pairs is enough to be useful) should run every night against the live retrieval path — query embeddings from today's endpoint, documents from the index as it actually exists — and report recall@k. Alert not on the absolute number but on the multi-day rolling slope. A 47% slide over six weeks is invisible to any threshold-based alert if the threshold was set when the system was healthy; it is glaring on a trend.</p>
<p><strong>Run a forced-cutover register, not a deprecation tracker.</strong> A deprecations dashboard that records "provider said this is going away on date T" rewards procrastination. A cutover register that records "we will fully migrate off this endpoint by date T-minus-90" puts the burden on your timeline. The forced cutover date should be calculated backward from the provider's date with a margin that accounts for the corpus you have to re-embed, the eval you have to re-run, and the index you have to swap. If you cannot meet the date, you find out early enough to negotiate with the provider, not late enough to be the one calling for a hotfix.</p>
<p><strong>Plan the re-embedding project as infrastructure, not a sprint task.</strong> Re-embedding a corpus that took a quarter to build will take comparable time and cost. Modern guidance treats this as a major infrastructure event: a parallel index, a dual-write phase, a shadow query phase that compares old-index and new-index results, a cutover with a rollback plan, and a deprecation of the old index only after the new one has carried full traffic for a week. The work is large enough that if you discover the need on a Friday because recall just collapsed, you are already in trouble.</p>
<p>For teams that genuinely cannot re-embed in time, recent research has explored learnable transformation layers that map new-model query embeddings into the legacy index's space, recovering most of the recall of a full re-embed at the cost of a small latency overhead. These are useful as bridges during a migration but not as a substitute for one — they paper over the geometry mismatch rather than fixing it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-architectural-lesson-under-the-incident">The architectural lesson under the incident<a href="https://tianpan.co/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#the-architectural-lesson-under-the-incident" class="hash-link" aria-label="Direct link to The architectural lesson under the incident" title="Direct link to The architectural lesson under the incident" translate="no">​</a></h2>
<p>There is a generalization worth taking from this: the contract surface of a provider API is everything the provider can change without changing the URL. For embedding endpoints, that surface includes the actual mapping from text to vector, which is precisely the thing your retrieval system depends on. Treating the URL as the dependency is the bug. The dependency is the mapping, and the URL is just the way you call it.</p>
<p>The same generalization applies to anything you index against a model's output: re-rankers whose scores you cache, classifiers whose labels you persist, summarizers whose outputs are joined to a downstream pipeline. Each of these is a place where you wrote the model's output to a slow store, and your query path is reading from a fast endpoint that owes you only shape, not semantics. Provider behavior changes inside the window between announcement and retirement are not unusual — they are the rule. The question is whether your system can tell when they happen.</p>
<p>Retrieval systems that get this right have three things in common. They tag every stored vector with the model identity that produced it. They run a behavior-change canary that fires on geometry drift, not on uptime. And they treat their own forced-cutover date as the deadline that matters, with the provider's sunset date as a soft upper bound. Teams that do not have those three things are running a recall floor that a vendor is renegotiating on a cadence the team's eval cannot see — and a year from now, somebody on the team will spend a long afternoon tracing a quality regression back to a URL that has not changed in any commit.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="embeddings" term="embeddings"/>
        <category label="retrieval" term="retrieval"/>
        <category label="vendor-risk" term="vendor-risk"/>
        <category label="observability" term="observability"/>
        <category label="deprecation" term="deprecation"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The Eval Harness That Ran on Yesterday's Prompt Template After Your Team Shipped a New One]]></title>
        <id>https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one</id>
        <link href="https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[An eval suite that grades the wrong prompt version reports green on a broken release. The fix is not faster cache invalidation — it is content-addressed prompt hashes that make eval/prod drift impossible to express.]]></summary>
        <content type="html"><![CDATA[<p>The incident timeline reads cleanly. At 9:02 your platform team pushed <code>prompt-template@v38</code> to the config service. At 11:14 your dashboards showed everything green. At 16:51 someone in support flagged a spike in escalations. At 17:03 you opened the eval suite, found a regression score of 0.34, and rolled back. The post-mortem says "caught in eight hours, no customer harm beyond the 0.04% who saw it." Engineering leadership applauds the response time.</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=The%20Eval%20Harness%20That%20Ran%20on%20Yesterday%27s%20Prompt%20Template%20After%20Your%20Team%20Shipped%20a%20New%20One" alt="" class="img_ev3q"></p>
<p>It is wrong. The regression was caught in zero hours. The eval suite running at 17:03 was the same eval suite running at 09:03. It had been pointed at <code>v37</code> the entire time. The harness loaded the template from your config service at process startup, cached the rendered prompts as Python objects in module-level scope, and never reread the source. Your live traffic moved to <code>v38</code> at 9am. Your eval moved at 17:03, when someone restarted the worker pool to "rerun the regression." Eight hours of customer interactions ran against a prompt that no eval had ever scored, while the eval kept grading a prompt that no production request was using.</p>
<p>This is the failure mode no dashboard catches because both systems report success on their own terms. The eval suite is healthy: it ran, it produced scores, it gated nothing because nothing asked it to gate. The prompt versioning system is healthy: <code>v38</code> is the active version, request logs confirm it, the canary at 5% finished without alarms. The thing that broke is the link between them — the assumption that "the eval is running against the prompt in prod" — and links are not instrumented because nobody owns them.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-cache-that-nobody-knew-was-a-cache">The cache that nobody knew was a cache<a href="https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#the-cache-that-nobody-knew-was-a-cache" class="hash-link" aria-label="Direct link to The cache that nobody knew was a cache" title="Direct link to The cache that nobody knew was a cache" translate="no">​</a></h2>
<p>Configuration services exist to remove the latency of reading a config file on every request. You centralize prompts in something like LaunchDarkly's AI configs, Braintrust's prompt library, or an internal service backed by Redis. You expose a client SDK that fetches the latest version. You document that the client is "fast" because it caches. What "caches" usually means in practice: the client fetches once at construction time and then trusts the in-memory copy until the process restarts.</p>
<p>That contract is fine for prompts that change at deploy cadence — every push restarts every worker, the cache invalidates implicitly, nobody notices. It fails the moment prompts ship independently of code. The whole point of moving prompts to a config service was to let prompt engineers iterate without a code deploy. Which means the cache invalidation question is now load-bearing, and the answer your SDK provides — "restart the process" — is incompatible with the workflow the platform was built to enable.</p>
<p>The eval harness inherits this defect by accident. It uses the same SDK, holds the same cached copy, and runs on a long-lived worker pool that exists precisely to avoid the cost of rebuilding the eval graph on every run. The longer the worker stays alive, the more confident you get that "the eval pipeline is stable," and the further the cached template drifts from production. Stability of the harness is precisely what produces the drift.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-metric-that-measures-itself">The metric that measures itself<a href="https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#the-metric-that-measures-itself" class="hash-link" aria-label="Direct link to The metric that measures itself" title="Direct link to The metric that measures itself" translate="no">​</a></h2>
<p>The deeper problem is that the regression score reported by a stale eval is internally consistent. The eval grades <code>v37</code> against the golden dataset. <code>v37</code> was tuned against that dataset. The score is 0.91. It has been 0.91 for weeks. The score will continue to be 0.91 as long as the eval keeps grading <code>v37</code>, no matter what <code>v38</code> or <code>v39</code> does in production. There is no anomaly to alert on because the only thing changing in the world the eval can see is sampling noise.</p>
<p>You can confirm this with a thought experiment. If the prompt service silently returned <code>v37</code> to every consumer for the rest of the year — eval, prod, canary, everyone — your dashboards would not flicker. Your eval scores would stay flat. Your prompt versioning UI would show <code>v38</code> as "active." The metric you trust to catch regressions has no opinion about whether the system it is grading is the system you are running. It cannot have an opinion, because nothing in its input forces it to notice.</p>
<p>This is the structural property the eval/prod gap rests on: offline evals validate a fixed artifact against a fixed dataset. They are designed not to vary. When the artifact under test silently decouples from the artifact in production, the design that makes offline evals reproducible is the same design that makes them blind to the decoupling.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-graded-the-wrong-system-actually-costs">What "graded the wrong system" actually costs<a href="https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#what-graded-the-wrong-system-actually-costs" class="hash-link" aria-label="Direct link to What &quot;graded the wrong system&quot; actually costs" title="Direct link to What &quot;graded the wrong system&quot; actually costs" translate="no">​</a></h2>
<p>The eight-hour window matters less than the conclusions drawn during it. Product asks "did the new prompt help?" and the eval says "no change." Engineering asks "should we ship v39?" and the eval says "v38 is fine, keep iterating." A prompt engineer looks at the v38-vs-v37 comparison in the eval dashboard, sees no meaningful delta, and concludes the change was a wash — which feels like permission to ship the next change on top, because the last one was neutral.</p>
<p>By the time someone in support surfaces real-world behavior, the team has stacked three more prompt changes on a base they thought was neutral and was actually a regression. The rollback is not "revert v38." It is "figure out which of v38, v39, v40, and v41 was the one that broke things, given that none of them were ever graded against the dataset everyone thinks they were graded against."</p>
<p>The recovery cost is not the eight hours of customer impact. It is the entire week of prompt iteration whose evaluation evidence is now invalidated, and the engineering trust in the eval scoreboard, which does not come back quickly once people have seen it report green on a broken release. The cheap framing — "our eval lagged by eight hours" — hides the expensive reality, which is that every prompt change shipped during that window has to be re-evaluated by hand, and the team will second-guess the eval for months.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="forcing-the-link-to-be-real">Forcing the link to be real<a href="https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#forcing-the-link-to-be-real" class="hash-link" aria-label="Direct link to Forcing the link to be real" title="Direct link to Forcing the link to be real" translate="no">​</a></h2>
<p>Fixing the cache is the obvious move and the wrong place to start. You can make the SDK poll for changes every thirty seconds and you still have not answered the question "is the eval grading what production is running right now?" You have only reduced the window in which the answer is no.</p>
<p>The fix that holds up is to make the eval grade what production ran, not what the config service currently says. Concretely: every prompt render in production emits the resolved template hash alongside the response. The eval harness, when it picks up a sample of production traces to score, reads the template hash from the trace and rehydrates that exact template — not the one in its cache, not the one currently active. The eval becomes a function of "the prompt this request actually saw," which makes drift impossible to express. If the harness cannot find the template it needs, it fails loudly instead of silently substituting yesterday's copy.</p>
<p>The same logic applies to offline regression runs. The CI gate on a prompt change should not ask "did the harness score v38." It should ask "did the harness score the hash that v38 resolves to in the same environment that prod resolves it in." Pin the artifact at submission time. Treat the prompt version like a model version: immutable, content-addressed, joined to evaluation results by hash, not by name. The active-pointer indirection is what lets the eval and prod drift apart; remove the indirection and the drift becomes a compile error rather than a silent reporting bug.</p>
<p>For the harness process itself, the discipline is the discipline of any long-running consumer of mutable upstream state: either restart on every run, or treat the cache as a derived view that the upstream is responsible for invalidating. The middle ground — "I'll just hold a reference and assume it stays fresh" — is the configuration of every incident in this category. Pick a side. The eval suite that restarts cold on every invocation costs more compute and never lies about which version it graded; the eval suite that runs warm and stale costs nothing and lies whenever it matters most.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-audit-that-catches-the-next-one">The audit that catches the next one<a href="https://tianpan.co/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#the-audit-that-catches-the-next-one" class="hash-link" aria-label="Direct link to The audit that catches the next one" title="Direct link to The audit that catches the next one" translate="no">​</a></h2>
<p>The reason this failure mode keeps recurring is that it has no surface in any standard runbook. There is no metric called "eval-prod prompt skew." There is no alert that fires when the harness cache age exceeds the deploy cadence. The team's mental model is that "the eval is the eval" and "the prompt is the prompt," and the integration between them is treated as a wire, not a system.</p>
<p>A short audit makes the wire visible. Pick any production trace from the last hour. Extract the prompt template hash. Look up the same trace in the eval scoreboard for the same time window. Confirm that the hash the eval used matches the hash production used. If your system cannot answer this question in under five minutes, the eval/prod link is implicit, which means it is decoupled in a way nobody has noticed yet. The next stale-eval incident is already in flight; you just have not seen the support ticket for it.</p>
<p>The teams that survive this category do not have smarter evals. They have eval pipelines that refuse to produce a score without proof of what they graded, and they treat that proof as part of the deliverable. The score on its own is a number. The score plus the prompt hash plus the model version plus the dataset commit is an artifact. Anything less is the dashboard from the timeline above — green for eight hours on a system that nobody in your eval pipeline had actually looked at.</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="llm-evals" term="llm-evals"/>
        <category label="prompt-engineering" term="prompt-engineering"/>
        <category label="observability" term="observability"/>
        <category label="mlops" term="mlops"/>
        <category label="incident-postmortem" term="incident-postmortem"/>
    </entry>
</feed>