<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Vatan blog</title>
    <link>https://vatan.one/blog</link>
    <atom:link href="https://vatan.one/blog/feed.xml" rel="self" type="application/rss+xml" />
    <description>Notes from building and running an enterprise AI platform.</description>
    <language>en-gb</language>
    <lastBuildDate>Mon, 14 Sep 2026 09:00:00 GMT</lastBuildDate>
    <item>
      <title>One automatic rollback, minute by minute</title>
      <link>https://vatan.one/blog/one-automatic-rollback-minute-by-minute</link>
      <guid isPermaLink="true">https://vatan.one/blog/one-automatic-rollback-minute-by-minute</guid>
      <pubDate>Mon, 14 Sep 2026 09:00:00 GMT</pubDate>
      <dc:creator>Ehsan Gazar</dc:creator>
      <category>In production</category>
      <description>A release on 14 September 2026 failed verification and rolled itself back. What the check caught, why it was right, and why verify runs again afterwards.</description>
      <content:encoded>&lt;p&gt;&lt;em&gt;A green tick should mean production was checked after the release, and a rollback nobody re-verifies is another unverified change.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A deploy goes green. The tick means the platform accepted the new images. Nobody has looked at production yet, and later a customer does.&lt;/p&gt;
&lt;p&gt;A green tick that means &amp;quot;queued&amp;quot; is a statement of intent. I want ours to mean production was checked after the release.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline-in-order&quot;&gt;The pipeline, in order&lt;/h2&gt;
&lt;p&gt;Every push to main runs five stages in a fixed order. Rollback exists only as a response to the last one failing.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Check&lt;/strong&gt; is what the repository decides alone: types, lint, tests, and every image built.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Preflight&lt;/strong&gt; asks production what only production knows, including which commit to go back to.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Migrate&lt;/strong&gt; moves the database schema forward, before any new image starts.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Deploy&lt;/strong&gt; pins every application to one exact commit.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Verify&lt;/strong&gt; asks the same questions from outside, over real TLS, against the public hostnames.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;what-happened-on-14-september-2026&quot;&gt;What happened on 14 September 2026&lt;/h2&gt;
&lt;p&gt;All times below are UTC.&lt;/p&gt;
&lt;p&gt;One push carried two commits. The head, &lt;code&gt;66cca8e&lt;/code&gt;, changed the homepage header and hero. The commit under it, &lt;code&gt;fec5207&lt;/code&gt;, rewrote the platform pages.&lt;/p&gt;
&lt;p&gt;Check and the image builds passed between 14:50 and 14:51. At 14:52:01 the release began pinning every application to &lt;code&gt;66cca8e&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;At 14:54:53 verify failed on a single check. The served bundle no longer contained one sentence: &amp;quot;See it against your own traffic.&amp;quot;&lt;/p&gt;
&lt;p&gt;Rollback started at 14:55:04. The console went back to the previous commit, &lt;code&gt;7645e9a&lt;/code&gt;, then the services behind it. At 14:58:18 it reported &amp;quot;production is back on the previous commit&amp;quot;. Verify then ran again against the rolled-back estate and passed at 14:58:38.&lt;/p&gt;
&lt;h2 id=&quot;why-a-missing-sentence-fails-a-release&quot;&gt;Why a missing sentence fails a release&lt;/h2&gt;
&lt;p&gt;The site once carried sentences that were not true. For each one, verify runs an &lt;strong&gt;absence check&lt;/strong&gt;: a test that a known false sentence is gone from the served bundle.&lt;/p&gt;
&lt;p&gt;An absence check has a blind spot. It passes on a build that is simply missing the page, because an empty page contains no false sentences either.&lt;/p&gt;
&lt;p&gt;So for each correction, verify also requires the true sentence that replaced the false one. &amp;quot;See it against your own traffic.&amp;quot; is one of those. When it is missing, the failure text says why that matters: &amp;quot;a corrected page is not in this build, so the absence checks above prove nothing&amp;quot;.&lt;/p&gt;
&lt;p&gt;An absence check needs a presence check beside it, or it passes on nothing.&lt;/p&gt;
&lt;h2 id=&quot;was-the-rollback-right&quot;&gt;Was the rollback right?&lt;/h2&gt;
&lt;p&gt;The obvious story is that improved copy tripped a brittle check. I checked the history, and that story is wrong.&lt;/p&gt;
&lt;p&gt;The homepage tour section had been removed earlier that afternoon, in &lt;code&gt;8176b47&lt;/code&gt;. That commit never touched the sentence. Its own run failed at check and never released.&lt;/p&gt;
&lt;p&gt;The sentence was removed by &lt;code&gt;fec5207&lt;/code&gt;, in the same push as &lt;code&gt;66cca8e&lt;/code&gt;. The platform page rewrite replaced the closing band on the products page and dropped the line.&lt;/p&gt;
&lt;p&gt;The line was still true. You can still point an application at the gateway with a key you create yourself. So the check was not crying wolf. A rewrite had lost a true sentence, and the check noticed.&lt;/p&gt;
&lt;p&gt;The fix, &lt;code&gt;c0fc295&lt;/code&gt;, put the sentence back as the closing title on the products page. The check stayed unchanged. That fix was the next successful deploy, running from 15:00:58 to 15:06:31. Its commit message records that the built bundle was scanned against every required sentence before the push.&lt;/p&gt;
&lt;h2 id=&quot;the-cost-of-pinning-a-phrasing&quot;&gt;The cost of pinning a phrasing&lt;/h2&gt;
&lt;p&gt;This time the check was right. It has also been wrong.&lt;/p&gt;
&lt;p&gt;Our own debugging notes record three earlier releases rolled back for telling the truth. In those cases the copy improved, the required sentence changed, and verify rejected a correct build.&lt;/p&gt;
&lt;p&gt;The check's source keeps one such case. A page correctly followed a change to how many days of usage it could read. The check went on demanding the old number. That line is now built from the same constant the page reads, so the two cannot disagree.&lt;/p&gt;
&lt;p&gt;A required sentence is a proxy for a property. Where you can assert the property directly, do that instead.&lt;/p&gt;
&lt;p&gt;Pin the property, not the phrasing, or your safety check will roll back the truth.&lt;/p&gt;
&lt;h2 id=&quot;why-the-schema-never-rolls-back&quot;&gt;Why the schema never rolls back&lt;/h2&gt;
&lt;p&gt;The rollback at 14:55:04 changed images and nothing else. That is deliberate.&lt;/p&gt;
&lt;p&gt;Migrations are additive for one release. A new column has a default, and a new table sits beside the old ones. So the previous images keep working against the newer schema, and a rollback is an image change alone.&lt;/p&gt;
&lt;p&gt;Reversing a migration would be far worse. It would delete rows written since the deploy. Nobody wants a tidy rollback that lost their usage records.&lt;/p&gt;
&lt;p&gt;The schema goes forward before the images, and it never comes back with them.&lt;/p&gt;
&lt;h2 id=&quot;a-rollback-is-a-second-release&quot;&gt;A rollback is a second release&lt;/h2&gt;
&lt;p&gt;The step people skip is the one after the rollback.&lt;/p&gt;
&lt;p&gt;A rollback is a change to production. Old images start again against a schema that has moved on. If nothing checks the result, you have shipped a second unverified change and called it safety.&lt;/p&gt;
&lt;p&gt;That is why verify runs again after every rollback. Then a final step writes which commit production is running into the run summary. A red tick with that line under it is worth more than a green tick without one.&lt;/p&gt;
&lt;p&gt;I do not trust a rollback until something outside production has looked at production afterwards.&lt;/p&gt;</content:encoded>
    </item>
    <item>
      <title>Serving Cursor's requests through the gateway</title>
      <link>https://vatan.one/blog/serving-cursor-requests-through-the-gateway</link>
      <guid isPermaLink="true">https://vatan.one/blog/serving-cursor-requests-through-the-gateway</guid>
      <pubDate>Mon, 14 Sep 2026 09:00:00 GMT</pubDate>
      <dc:creator>Ehsan Gazar</dc:creator>
      <category>In production</category>
      <description>Cursor on a custom base URL sent shapes our gateway refused or silently broke. How one canonical body at the door fixed the class, and what that proves.</description>
      <content:encoded>&lt;p&gt;&lt;em&gt;Three request shapes production refused or quietly broke, and why the quiet failure was the dangerous one.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;You point Cursor at a custom base URL and ask it to edit a file. The model replies with text and changes nothing. No error appears anywhere.&lt;/p&gt;
&lt;p&gt;You conclude the model chose not to use its tool. Or you conclude the gateway does not work with Cursor. Both conclusions are wrong, and the second one costs a customer.&lt;/p&gt;
&lt;h2 id=&quot;what-cursor-actually-sends&quot;&gt;What Cursor actually sends&lt;/h2&gt;
&lt;p&gt;With a custom base URL, Cursor calls &lt;code&gt;/chat/completions&lt;/code&gt; from its own servers. Cursor's staff said so on its forum on 13 August 2026. So the door that matters is the Chat Completions door on &lt;code&gt;gateway.vatan.one&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What Cursor puts inside that request is the harder part, because it has changed from release to release. On 14 September 2026, before any fix, I measured three shapes against production. I used a key in our own test workspace, with &lt;code&gt;anthropic/claude-haiku-4-5&lt;/code&gt; and &lt;code&gt;openai/gpt-5-nano&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Flat function tools.&lt;/strong&gt; Cursor sends &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;parameters&lt;/code&gt; directly on the tool, with no nested &lt;code&gt;function&lt;/code&gt; object. The Anthropic model answered 200 with no tool call. The OpenAI model answered 400.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;A custom tool.&lt;/strong&gt; Cursor's file-edit tool, &lt;code&gt;ApplyPatch&lt;/code&gt;, is declared as &lt;code&gt;type: &amp;quot;custom&amp;quot;&lt;/code&gt;. The results were the same: a 200 with no tool call, and a 400.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;A Responses body on the chat path.&lt;/strong&gt; A body carrying &lt;code&gt;input&lt;/code&gt;, &lt;code&gt;reasoning&lt;/code&gt; and &lt;code&gt;text&lt;/code&gt; instead of &lt;code&gt;messages&lt;/code&gt;. Both models answered 400.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;the-400s-were-the-easy-part&quot;&gt;The 400s were the easy part&lt;/h2&gt;
&lt;p&gt;A 400 is loud. Somebody sees it and files a bug.&lt;/p&gt;
&lt;p&gt;The two 200s were the real problem. A reply with &lt;code&gt;finish_reason: &amp;quot;stop&amp;quot;&lt;/code&gt; and no tool call is a valid answer. It is exactly what a model returns when it decides not to call a tool. An agent reading it moves on. A person reading it blames the model.&lt;/p&gt;
&lt;p&gt;Nothing throws and nothing turns red. The tool definition was lost on the way to the Anthropic wire, because the translation expected the nested shape and found nothing to translate.&lt;/p&gt;
&lt;p&gt;A success that did not do the work is worse than an error, because nobody goes looking for it.&lt;/p&gt;
&lt;h2 id=&quot;one-canonical-body-at-the-door&quot;&gt;One canonical body, at the door&lt;/h2&gt;
&lt;p&gt;The tempting fix is three patches, one per shape. I did not do that, because the next Cursor release can send a fourth.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;canonical form&lt;/strong&gt; is the one agreed shape that every other shape is converted into before real work starts. It is the old rule of normalising input at a system boundary, and it pays the same way: everything behind the boundary handles one case.&lt;/p&gt;
&lt;p&gt;Everything downstream of our chat handler already read OpenAI's nested tool shape. The Anthropic translation read it, and so did the OpenAI passthrough and the cache key. So the new file, &lt;code&gt;chat-shapes.ts&lt;/code&gt;, makes the body canonical once, at the door. Nothing below it learns that Cursor exists.&lt;/p&gt;
&lt;p&gt;Flat tools become nested tools. A Responses body on the chat path is translated into a chat body. A custom tool becomes a function with one string argument. A body with none of these shapes comes back as the same object, so every existing client takes exactly the path it took before.&lt;/p&gt;
&lt;p&gt;Normalise at the boundary, and prove the untouched path is really untouched.&lt;/p&gt;
&lt;h2 id=&quot;and-back-out-again&quot;&gt;And back out again&lt;/h2&gt;
&lt;p&gt;Converting the request is half the job. Cursor declared &lt;code&gt;ApplyPatch&lt;/code&gt; as a custom tool, so it expects a custom call back, not a function call.&lt;/p&gt;
&lt;p&gt;So a call to a tool the caller declared as custom goes back as a custom call. Its raw patch text is pulled out of the one-field wrapper first. The reverse also happens on later turns, when Cursor sends that call back in the conversation history.&lt;/p&gt;
&lt;p&gt;This had to work buffered and streamed, and those are separate code paths. Streaming is the fiddly one. The model streams its argument as fragments of JSON, and the wrapper around the text must never leak through. The stream rewriter tracks each call and sends only the new text since the last chunk. Every other event passes through byte for byte.&lt;/p&gt;
&lt;p&gt;I insisted on both paths because this repository has been here before. A streamed translation once dropped tool calls for days while the buffered one was correct.&lt;/p&gt;
&lt;h2 id=&quot;what-we-gave-up&quot;&gt;What we gave up&lt;/h2&gt;
&lt;p&gt;A custom tool can carry a grammar that constrains what the model writes. OpenAI's wire supports that natively. We still convert custom tools to functions on every wire, OpenAI's included.&lt;/p&gt;
&lt;p&gt;So the model is asked for a string and writes the patch as free text, unconstrained. In exchange, one translation means one set of tests and one shape coming back. I think that trade is right, and it is still a real loss. The gateway names it in the &lt;code&gt;x-vatan-responses-dropped&lt;/code&gt; header as &lt;code&gt;custom.format&lt;/code&gt;, so the loss is never silent.&lt;/p&gt;
&lt;p&gt;If you drop part of a request, say so in the response.&lt;/p&gt;
&lt;h2 id=&quot;how-it-was-proved-and-what-that-does-not-prove&quot;&gt;How it was proved, and what that does not prove&lt;/h2&gt;
&lt;p&gt;The proof is &lt;code&gt;client-compat.mjs&lt;/code&gt;, a script that sends requests to production and checks the answers. It gained Cursor's shapes in the same commit, &lt;code&gt;7d56156&lt;/code&gt;. Each shape runs on an Anthropic-wire model and an OpenAI-wire model, buffered and streamed.&lt;/p&gt;
&lt;p&gt;It checks that a flat tool is actually called. It checks that a custom tool comes back as a custom call carrying raw text. It checks that the call and its result are accepted on the next turn. It also checks that a streamed tool turn still reports usage, so the turn is not metered at zero.&lt;/p&gt;
&lt;p&gt;It is not the real Cursor app. Cursor sends requests from its own servers, and a custom key needs a paid Cursor plan. The script has neither. So this proves the gateway answers Cursor's published request shapes correctly. It does not prove that what Cursor sends today is one of those shapes.&lt;/p&gt;
&lt;p&gt;That gap is why the fix covers the class rather than three cases. When a new shape arrives, it should land on a door already built to be surprised.&lt;/p&gt;
&lt;p&gt;A gateway is judged by the client that fails quietly, so test the quiet failure first.&lt;/p&gt;</content:encoded>
    </item>
    <item>
      <title>Why our margin is on the pricing page</title>
      <link>https://vatan.one/blog/why-our-margin-is-on-the-pricing-page</link>
      <guid isPermaLink="true">https://vatan.one/blog/why-our-margin-is-on-the-pricing-page</guid>
      <pubDate>Mon, 14 Sep 2026 09:00:00 GMT</pubDate>
      <dc:creator>Ehsan Gazar</dc:creator>
      <category>Perspectives</category>
      <description>Vatan prints its markup on the pricing page. Why one published rate beats a negotiated one, and the checks that fail a release when it stops being true.</description>
      <content:encoded>&lt;p&gt;&lt;em&gt;A price a buyer can check beats a price a buyer negotiates, and keeping it true has to be mechanical.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;You open a vendor's pricing page and find &amp;quot;contact sales&amp;quot;. You book the call. Weeks later you have a number, and no idea whether the next buyer got a better one.&lt;/p&gt;
&lt;p&gt;I did not want Vatan to work like that. So the margin is on the page.&lt;/p&gt;
&lt;h2 id=&quot;the-number&quot;&gt;The number&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;markup&lt;/strong&gt; is the amount a seller adds on top of what something costs them. Most vendors fold it into one blended price. We print ours.&lt;/p&gt;
&lt;p&gt;A request through the Vatan gateway is charged at what the model costs us to buy, plus 5%. For a model we buy direct, our cost is the provider's published price. The rate is the same on every plan.&lt;/p&gt;
&lt;p&gt;Nothing else goes on the bill. There is no platform fee, no seat and no subscription. Pay as you go starts with a $5 top-up, and that is the only minimum.&lt;/p&gt;
&lt;h2 id=&quot;why-there-is-no-discount-for-bigger-plans&quot;&gt;Why there is no discount for bigger plans&lt;/h2&gt;
&lt;p&gt;On 4 September 2026 we considered a tiered markup, lower on bigger plans. We dropped it the same day.&lt;/p&gt;
&lt;p&gt;A tier sounds generous. In practice it turns the price into a conversation. Every buyer asks which tier they are in, and whether the next one down is open to them. The rate stops being something you read and becomes something you argue about.&lt;/p&gt;
&lt;p&gt;One rate gives the procurement question one answer, and the buyer can check it alone. I think that is worth more than a discount somebody has to earn.&lt;/p&gt;
&lt;p&gt;If a buyer has to ask what the price is, you do not have a price yet.&lt;/p&gt;
&lt;h2 id=&quot;a-published-number-drifts&quot;&gt;A published number drifts&lt;/h2&gt;
&lt;p&gt;Printing a percentage is easy. Keeping it true is the hard part, because a price lives in more places than you expect. Ours lives in four, and all four must agree:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;The rates table&lt;/strong&gt;, &lt;code&gt;routing.model_prices&lt;/code&gt;, which is the sell price itself.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;&lt;code&gt;deploy/invariants.json&lt;/code&gt;&lt;/strong&gt;, which records the multiplier &lt;code&gt;&amp;quot;1.05&amp;quot;&lt;/code&gt; that the post-deploy check recomputes against.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The console's billing constant&lt;/strong&gt;, 500 basis points, which every figure on the site is built from.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The internal pricing model document&lt;/strong&gt;, where the decision is written down.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;A &lt;strong&gt;basis point&lt;/strong&gt; is one hundredth of a percent, so 500 basis points is 5%. You never need to think in basis points. The point is that the site never types the figure; it derives it.&lt;/p&gt;
&lt;p&gt;The usual failure is not one wrong price. It is two places that were both right once and then drifted apart.&lt;/p&gt;
&lt;p&gt;That happened to us on 4 September 2026. The catalogue had moved to the new rate. The FAQ kept quoting an older, higher markup for a day. Nobody chose to mislead anyone. The figure had been typed into prose in more than one file, and the test guarding the plan cards never read the FAQ.&lt;/p&gt;
&lt;h2 id=&quot;never-update-a-price&quot;&gt;Never update a price&lt;/h2&gt;
&lt;p&gt;Every row in the rates table has a start date. A reprice inserts new rows and never rewrites old ones.&lt;/p&gt;
&lt;p&gt;That sounds like bookkeeping, and it is the part that protects you. Last week's charge still reconciles against the invoice it produced. Update a price in place and every past invoice disagrees with the table that supposedly produced it. One &lt;code&gt;UPDATE&lt;/code&gt; destroys your own audit trail.&lt;/p&gt;
&lt;p&gt;Never update a price. Insert the next one.&lt;/p&gt;
&lt;h2 id=&quot;the-checks-that-hold-the-number-still&quot;&gt;The checks that hold the number still&lt;/h2&gt;
&lt;p&gt;The FAQ was fixed by making a second figure impossible to ship, not by rewording it. A test now reads every shipped string in the site's data modules and marketing pages. It fails the build if any percentage presented as a markup differs from the one the meter charges.&lt;/p&gt;
&lt;p&gt;After each deploy, the release pipeline reads the live rates table. Every advertised rate on a model we buy direct must be exactly the recorded multiplier times a list figure. A model re-rated at any other markup fails the release. Models we buy through another provider are checked the same way, against their own recorded basis, because what they cost us is different.&lt;/p&gt;
&lt;p&gt;The pipeline then fetches the served site over real TLS. The release fails if the bundle does not contain the literal string &amp;quot;plus 5.00%&amp;quot;. Without that, a page that quietly lost the number would pass everything else.&lt;/p&gt;
&lt;p&gt;A failed release rolls back. A price change nobody decided is treated like an outage, because for the buyer it is one.&lt;/p&gt;
&lt;p&gt;Assert the price as a property of the data, never as a copy of somebody's price list.&lt;/p&gt;
&lt;h2 id=&quot;show-the-working&quot;&gt;Show the working&lt;/h2&gt;
&lt;p&gt;The /models page lists each model with the provider that serves it. It shows what the model costs us beside what you pay. Since 5 September 2026 it also marks the cheapest provider for each model.&lt;/p&gt;
&lt;p&gt;Marking the cheapest provider costs us something. It sends buyers to the cheapest route, where 5% is a smaller amount of money. I think that is the right trade. A buyer who watches us point at the cheaper option tends to believe the rest of the page.&lt;/p&gt;
&lt;h2 id=&quot;what-this-does-not-solve&quot;&gt;What this does not solve&lt;/h2&gt;
&lt;p&gt;A published markup tells you nothing about whether the underlying cost is fair. Providers set their own prices, and those prices move. When one moves, your bill moves with it, even though our 5% has not.&lt;/p&gt;
&lt;p&gt;I do not have a clean answer to that. The best I have is that every change is a new dated row, and the working is on the page.&lt;/p&gt;
&lt;p&gt;If your pricing page and your meter can disagree, one of them is wrong, and a buyer will find out which before you do.&lt;/p&gt;</content:encoded>
    </item>
  </channel>
</rss>
