<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Notes | Webitops</title><link>https://webitops.com/notes/</link><description>Engineering write-ups from products we run in production. Specific problems, the approach we took, and the trade-offs we accepted.</description><generator>Hugo</generator><language>en-US</language><copyright>&#169; 2026 Webitops</copyright><lastBuildDate>Thu, 03 Sep 2026 00:00:00 +0100</lastBuildDate><atom:link href="https://webitops.com/notes/index.xml" rel="self" type="application/rss+xml"/><item><title>Two-dimensional cache versioning</title><link>https://webitops.com/notes/two-dimensional-cache-versioning/</link><pubDate>Thu, 03 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/notes/two-dimensional-cache-versioning/</guid><description>Invalidating a graph of derived values with two counters instead of enumerating keys. A small technique that makes it structurally impossible to forget to invalidate something.</description><content:encoded><![CDATA[<p>If an application derives everything it displays from an event log, it will need a cache. And a
cache over derived data has an unpleasant property: <strong>the set of things that must be invalidated
when an input changes is not local to the change.</strong></p>
<p>Add one trade to a ledger and you have invalidated the position, the average cost, the cash
balance, the realised gain, the unrealised gain, the equity total, the tax report, the chart
series and the allocation breakdown. Update one stock price and you have invalidated a subset of
those for every user who holds it.</p>
<p>The usual approach is to enumerate:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">Cache</span><span class="o">::</span><span class="na">forget</span><span class="p">(</span><span class="s2">&#34;portfolio_summary_</span><span class="si">{</span><span class="nv">$userId</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">Cache</span><span class="o">::</span><span class="na">forget</span><span class="p">(</span><span class="s2">&#34;tax_report_</span><span class="si">{</span><span class="nv">$userId</span><span class="si">}</span><span class="s2">_</span><span class="si">{</span><span class="nv">$year</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">Cache</span><span class="o">::</span><span class="na">forget</span><span class="p">(</span><span class="s2">&#34;chart_data_</span><span class="si">{</span><span class="nv">$userId</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// ...and the one you forgot, which is why you are reading this
</span></span></span></code></pre></div><p>This works right up until someone adds a tenth derived value and updates four of the five places
that need to forget it. The bug that results is the worst kind: intermittent, user-specific,
invisible in tests, and it manifests as <em>wrong numbers</em> rather than an error.</p>
<h2 id="version-the-namespace-not-the-key">Version the namespace, not the key</h2>
<p>Instead of removing entries, make them unreachable. Every cache key carries the version counters
of everything it depends on:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nv">$key</span> <span class="o">=</span> <span class="nx">sprintf</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;portfolio_summary_u%d_v%d_m%d&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$user</span><span class="o">-&gt;</span><span class="na">id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">userVersion</span><span class="p">(</span><span class="nv">$user</span><span class="p">),</span>   <span class="c1">// bumped when this user writes an event
</span></span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">marketVersion</span><span class="p">(),</span>      <span class="c1">// bumped when any price updates
</span></span></span><span class="line"><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">Cache</span><span class="o">::</span><span class="na">remember</span><span class="p">(</span><span class="nv">$key</span><span class="p">,</span> <span class="nx">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="na">addDay</span><span class="p">(),</span> <span class="nx">fn</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">compute</span><span class="p">(</span><span class="nv">$user</span><span class="p">));</span>
</span></span></code></pre></div><p>Invalidation is then an increment:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="c1">// A new trade or capital event: only this user&#39;s derived data is stale.
</span></span></span><span class="line"><span class="cl"><span class="nx">Cache</span><span class="o">::</span><span class="na">increment</span><span class="p">(</span><span class="s2">&#34;user_version_</span><span class="si">{</span><span class="nv">$user</span><span class="o">-&gt;</span><span class="na">id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// A price update: every user&#39;s valuations are stale.
</span></span></span><span class="line"><span class="cl"><span class="nx">Cache</span><span class="o">::</span><span class="na">increment</span><span class="p">(</span><span class="s1">&#39;market_version&#39;</span><span class="p">);</span>
</span></span></code></pre></div><p>One increment retires <em>every</em> key in that namespace at once — summary, tax report, chart series,
allocation, the lot. Old entries are never deleted; they simply stop being addressed and expire on
their own TTL.</p>
<aside class="not-prose my-7 rounded-lg border-l-4 border-ink bg-surface px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">The property worth having</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>It is impossible to forget to invalidate something. A new derived value added to the same
namespace inherits correct invalidation by construction. Key enumeration has exactly the opposite
property: every new derived value is a fresh opportunity to miss one.</p>
</div>
</aside>

<p>The two dimensions matter because the invalidation sources are genuinely independent. Your own
writes affect only you. A market price change affects everyone holding that instrument. A single
counter would force you to bump the world every time one user recorded a trade.</p>
<h2 id="making-the-bump-automatic">Making the bump automatic</h2>
<p>The remaining hole is a developer updating a price without remembering to increment. Close it at
the model boundary rather than at the call sites:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="k">protected</span> <span class="k">static</span> <span class="k">function</span> <span class="nf">booted</span><span class="p">()</span><span class="o">:</span> <span class="nx">void</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">static</span><span class="o">::</span><span class="na">saved</span><span class="p">(</span><span class="nx">fn</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">Cache</span><span class="o">::</span><span class="na">increment</span><span class="p">(</span><span class="s1">&#39;market_version&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">    <span class="k">static</span><span class="o">::</span><span class="na">deleted</span><span class="p">(</span><span class="nx">fn</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">Cache</span><span class="o">::</span><span class="na">increment</span><span class="p">(</span><span class="s1">&#39;market_version&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Now the invariant is enforced by the thing being changed, not by everyone who changes it.</p>
<h2 id="what-it-costs">What it costs</h2>
<p><strong>It is deliberately coarse.</strong> Bumping the market version invalidates cached data for every user,
including those holding none of the affected instrument. That is over-invalidation, and you should
be clear-eyed that you are trading recomputation for correctness.</p>
<p>At small scale that trade is obviously right: recomputation is cheap, and wrong numbers in a
financial tool are not. At large scale — millions of users, prices ticking constantly — it becomes
obviously wrong, and you would want per-instrument versions with the dependency tracking that
implies. Somewhere in between there is a crossover point, and you should know roughly where yours
is before you adopt this.</p>
<p><strong>Old entries linger</strong> until TTL. Memory that would have been freed by an explicit <code>forget</code> is
held a while longer. In practice this is why a TTL is not optional — a day is generous, and it
bounds the accumulation.</p>
<p><strong>Requires an atomic increment.</strong> Redis and Memcached give you this. A database cache driver can
too, but check that your driver&rsquo;s <code>increment</code> is genuinely atomic and not a read-modify-write, or
concurrent bumps will be lost and you are back to serving stale data.</p>
<h2 id="where-else-it-fits">Where else it fits</h2>
<p>Anywhere a set of derived values shares a small number of independent invalidation sources: a
pricing engine keyed on customer tier and rate-card version, a permissions cache keyed on user and
role-definition version, a rendered report keyed on tenant and template version.</p>
<p>The test for whether it applies is simple. Ask: <em>when input X changes, can I confidently list
every cache key that is now wrong?</em> If the honest answer is &ldquo;probably, but I would want to grep
first&rdquo; — the enumeration approach has already failed, and you just have not been bitten yet.</p>
]]></content:encoded></item><item><title>Derive, don't store: a ledger you cannot corrupt</title><link>https://webitops.com/notes/derive-dont-store/</link><pubDate>Thu, 03 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/notes/derive-dont-store/</guid><description>Event sourcing discipline without an event-sourcing framework — what it buys, what it costs, and the honest test for whether your domain actually needs it.</description><content:encoded><![CDATA[<p>&ldquo;Event sourcing&rdquo; usually arrives attached to a framework, a projection engine, an event store and
a two-week ramp-up. That packaging has put a lot of teams off a good idea, because the idea itself
is much smaller than its usual delivery vehicle.</p>
<p>The idea is one rule:</p>
<blockquote>
<p>Store facts. Derive everything else. Never store a value you could recompute.</p>
</blockquote>
<p>You can adopt that rule in an ordinary relational schema, with ordinary models, on a Tuesday. No
event store required. What follows is what it bought us in a financial ledger, and what it cost.</p>
<h2 id="the-rule-concretely">The rule, concretely</h2>
<p>In a portfolio tracker there are exactly two kinds of fact:</p>
<ul>
<li><strong>Capital events</strong> — money entering or leaving: deposits, withdrawals, dividends.</li>
<li><strong>Trade events</strong> — a signed quantity of an instrument at a price, plus fees and taxes.</li>
</ul>
<p>That is the whole write model. Two append-only tables.</p>
<p>Everything a user actually looks at is absent from the schema: current position, weighted average
cost, cash balance, realised gain, unrealised gain, total equity, allocation, tax liability. All
of it is computed by replaying the log.</p>
<p><strong>Corrections are new events, never edits.</strong> A trade entered wrong three months ago is fixed by
recording a correcting event, not by updating the original row. The log is what happened,
including the mistakes.</p>
<h2 id="what-it-makes-impossible">What it makes impossible</h2>
<p>This is the part worth being precise about, because the benefit is not &ldquo;cleaner code&rdquo; — it is a
category of bug that stops existing.</p>
<p><strong>Drift.</strong> When a balance is stored, it is a second source of truth. Every code path that writes an
event must also update it, correctly, under concurrency, forever. Miss one — a bulk import, an
admin correction, a rollback — and the stored balance and the log disagree. There is no drift when
there is nothing to drift <em>from</em>.</p>
<p><strong>Migration corruption.</strong> Backfilling a stored aggregate is a one-shot operation you have to get
right against production data. Derived values need no backfill; change the function and every
answer changes with it.</p>
<p><strong>Untraceable numbers.</strong> Every figure on screen has an origin you can walk back to specific rows.
&ldquo;Why is my average cost 43.20?&rdquo; is answerable, exactly, every time. In a stored-balance design that
question frequently has no answer at all.</p>
<h2 id="what-falls-out-for-free">What falls out for free</h2>
<p>A good sign you picked the right model is that features you did not design for turn out to be
already built.</p>
<p><strong>Time travel.</strong> &ldquo;What did this look like in March?&rdquo; needs no snapshot table. It is the same
computation with the event stream filtered by date — a <code>where</code> clause, not a feature.</p>
<p><strong>Inferred properties.</strong> How much fresh capital went in versus how much came from trading gains is
derivable from the log alone. Nothing needed tagging at entry time, which matters enormously
because users do not reliably tag anything.</p>
<p><strong>Audit for free.</strong> The write model <em>is</em> the audit log. There is no separate table recording what
changed, because nothing ever changes.</p>
<h2 id="what-it-costs">What it costs</h2>
<p><strong>Recompute latency, on every read.</strong> This is the whole bill, and it is real. You pay it with
caching — which becomes the actual hard problem of the design, and which we wrote about separately
in <a href="/notes/two-dimensional-cache-versioning/">two-dimensional cache versioning</a>
.</p>
<p><strong>Replay order becomes load-bearing.</strong> Realised gain depends on the sequence sells happened in.
Events need a reliable ordering that is not &ldquo;whatever <code>id</code> came out as&rdquo;, and backdated entries have
to slot into the right position rather than the end.</p>
<p><strong>Discipline that the schema does not enforce.</strong> Nothing stops a future contributor adding a
<code>current_balance</code> column for a quick dashboard win. We wrote the rule down as a short
non-negotiable document in the repository and tested the invariants directly — cannot oversell,
cash must reconcile exactly. A rule that lives only in someone&rsquo;s head is not a rule.</p>
<p><strong>Some things genuinely are facts.</strong> A closing price on a given day is not derivable from anything;
it is an observation and it gets stored. The rule is &ldquo;derive what is derivable&rdquo;, not &ldquo;store
nothing&rdquo; — and confusing the two leads to some very silly conversations.</p>
<aside class="not-prose my-7 rounded-lg border-l-4 border-brand bg-red-50 px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">When not to do this</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>If your aggregates are expensive to compute and cheap to keep correct — a page-view counter, a
cached follower count — storing them is fine, and this is over-engineering. The rule earns its
cost when a wrong number is <em>expensive</em>: money, tax, inventory, medical dosing, compliance. If your
domain tolerates a value being slightly stale or slightly wrong, you do not need this.</p>
</div>
</aside>

<h2 id="the-test">The test</h2>
<p>Ask one question about the aggregate you are about to store:</p>
<p><strong>If this value and the records it summarises ever disagreed, how would you find out?</strong></p>
<p>If the answer is &ldquo;a customer would tell us&rdquo;, or &ldquo;we would not&rdquo;, you are looking at a value that
should be derived rather than stored. If the answer is &ldquo;it does not matter much either way&rdquo; — store
it, and get on with something more interesting.</p>
]]></content:encoded></item><item><title>Reserve the quota inside the transaction</title><link>https://webitops.com/notes/idempotent-notification-delivery/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/notes/idempotent-notification-delivery/</guid><description>Sending a metered notification from a queue worker has three distinct failure modes, and retry logic fixes none of them. Modelling delivery as a row — claimed and budgeted in one transaction — fixes all three.</description><content:encoded><![CDATA[<p>Here is a piece of code that appears in a great many applications, and is wrong in a way that
takes months to notice.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="c1">// Don&#39;t do this.
</span></span></span><span class="line"><span class="cl"><span class="k">public</span> <span class="k">function</span> <span class="nf">updated</span><span class="p">(</span><span class="nx">Ticket</span> <span class="nv">$ticket</span><span class="p">)</span><span class="o">:</span> <span class="nx">void</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="na">wasChanged</span><span class="p">(</span><span class="s1">&#39;status_id&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$ticket</span><span class="o">-&gt;</span><span class="na">business</span><span class="o">-&gt;</span><span class="na">decrement</span><span class="p">(</span><span class="s1">&#39;email_credits&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="nx">SendStatusEmail</span><span class="o">::</span><span class="na">dispatch</span><span class="p">(</span><span class="nv">$ticket</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>It sends an email when a ticket changes status and charges the tenant for it. It has three
separate bugs, and only one of them is the one people usually look for.</p>
<h2 id="three-failure-modes-not-one">Three failure modes, not one</h2>
<p><strong>Double-send.</strong> The job runs, times out after handing off to the mail provider, and is retried.
The customer gets two &ldquo;your repair is ready&rdquo; messages. The usual fix — a <code>sent_at</code> column checked
at the top of the job — narrows the window but does not close it: two workers can both read
<code>null</code> before either writes.</p>
<p><strong>Phantom quota burn.</strong> The credit is decremented, then the send fails terminally on an invalid
address. The tenant paid for nothing. Or worse: the decrement and the send are in different
transactions, one commits and the other does not, and the ledger and reality part company
permanently.</p>
<p><strong>Lost delivery on rollback.</strong> <code>dispatch()</code> inside a transaction hands the job to the queue
immediately. If the surrounding transaction then rolls back, a worker picks up a job pointing at a
status change that no longer exists. On a fast queue this races even without a rollback: the
worker can start before the transaction commits and find nothing there.</p>
<p>Retry configuration addresses none of these. They are not transport problems.</p>
<h2 id="model-the-delivery-not-the-send">Model the delivery, not the send</h2>
<p>The fix is to stop thinking of sending as an action and start thinking of it as a <strong>claim</strong> that
gets recorded, budgeted, and only then executed.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">DB</span><span class="o">::</span><span class="na">transaction</span><span class="p">(</span><span class="k">function</span> <span class="p">()</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$ticket</span><span class="p">,</span> <span class="nv">$status</span><span class="p">,</span> <span class="nv">$channel</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// 1. The row IS the claim. The unique index is the concurrency control.
</span></span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$delivery</span> <span class="o">=</span> <span class="nx">NotificationDelivery</span><span class="o">::</span><span class="na">create</span><span class="p">([</span>
</span></span><span class="line"><span class="cl">            <span class="s1">&#39;ticket_status_id&#39;</span> <span class="o">=&gt;</span> <span class="nv">$status</span><span class="o">-&gt;</span><span class="na">id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="s1">&#39;channel&#39;</span>          <span class="o">=&gt;</span> <span class="nv">$channel</span><span class="o">-&gt;</span><span class="na">name</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">            <span class="s1">&#39;state&#39;</span>            <span class="o">=&gt;</span> <span class="nx">DeliveryState</span><span class="o">::</span><span class="na">Pending</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">]);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">QueryException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">isUniqueViolation</span><span class="p">(</span><span class="nv">$e</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">throw</span> <span class="nv">$e</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span><span class="p">;</span> <span class="c1">// Someone else already claimed this exact send. Nothing to do.
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// 2. Budget it in the same transaction. Out of credit =&gt; the claim
</span></span></span><span class="line"><span class="cl">    <span class="c1">//    rolls back too, and there is no orphan row to retry later.
</span></span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">quotas</span><span class="o">-&gt;</span><span class="na">reserve</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="na">business</span><span class="p">,</span> <span class="nv">$channel</span><span class="o">-&gt;</span><span class="na">quotaKey</span><span class="p">(),</span> <span class="nv">$delivery</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// 3. Dispatch last, and only if the transaction survives.
</span></span></span><span class="line"><span class="cl">    <span class="nx">SendNotificationDelivery</span><span class="o">::</span><span class="na">dispatch</span><span class="p">(</span><span class="nv">$delivery</span><span class="p">)</span><span class="o">-&gt;</span><span class="na">afterCommit</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span></code></pre></div><p>Three properties, each fixing one of the bugs above:</p>
<ol>
<li><strong>The unique index on <code>(ticket_status_id, channel)</code> is the idempotency key.</strong> Not a flag that gets checked, a constraint that cannot be violated. Concurrent attempts race for the same key; the loser catches the violation and returns. This is the only version that is actually safe under concurrency, because the database is doing the mutual exclusion rather than your application logic.</li>
<li><strong>The reservation shares the transaction with the claim.</strong> Both happen or neither does. There is no window in which one exists without the other.</li>
<li><strong><code>afterCommit()</code> moves dispatch after the commit.</strong> No job can reference a row that was rolled back, and no worker can start before the data it needs is visible.</li>
</ol>
<aside class="not-prose my-7 rounded-lg border-l-4 border-ink bg-surface px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">The general shape</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>Claim, budget, and record — atomically. Execute afterwards, idempotently, against the record.
Any step after the transaction can then be retried freely, because retrying re-reads a row that
already states what was claimed. This is not specific to notifications: it applies to any
metered, externally-visible side effect.</p>
</div>
</aside>

<h2 id="terminal-and-transient-are-different-failures">Terminal and transient are different failures</h2>
<p>Once delivery is a row with a state, the worker needs to say <em>why</em> it failed, and there are exactly
two answers that matter:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$channel</span><span class="o">-&gt;</span><span class="na">send</span><span class="p">(</span><span class="nv">$delivery</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$delivery</span><span class="o">-&gt;</span><span class="na">markSent</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">TransientDeliveryException</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span><span class="p">;</span>                       <span class="c1">// let the queue retry; reservation stands
</span></span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">TerminalDeliveryException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$delivery</span><span class="o">-&gt;</span><span class="na">markFailed</span><span class="p">(</span><span class="nv">$e</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">quotas</span><span class="o">-&gt;</span><span class="na">release</span><span class="p">(</span><span class="nv">$delivery</span><span class="p">);</span>   <span class="c1">// give the credit back
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>A provider timeout or a 5xx is <strong>transient</strong>: retry, keep the reservation held. An invalid phone
number or a rejected template is <strong>terminal</strong>: stop, mark it failed, and release the budget.</p>
<p>Collapse those into a generic catch-and-retry and you get a queue that spends the night retrying an
address that will never be valid while the tenant&rsquo;s allowance stays locked. This distinction is
worth encoding as two exception types rather than a boolean, because the classification lives with
the channel adapter that actually understands the provider&rsquo;s error codes.</p>
<h2 id="you-still-need-a-reconciler">You still need a reconciler</h2>
<p>The uncomfortable case: a worker dies between reserving and sending. No exception is thrown, so
nothing is classified. The row sits <code>Pending</code> forever with budget held against it.</p>
<p>No amount of transactional care removes this — the process can always vanish. So a scheduled
command sweeps deliveries left pending beyond a threshold and either re-queues or fails them.</p>
<p>That sweeper is only safe to run <em>because</em> of the idempotency work. Re-queuing a delivery that
actually did send is harmless when the send is claimed by a unique row. In a design where sending
is an action rather than a record, a reconciler is a double-send generator.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>An extra table with a unique index on a hot write path, a scheduled command, two exception types,
and tests that have to exercise concurrency rather than a happy path.</p>
<p>What you get is that the awkward questions have boring answers. <em>Can a customer get the same
message twice?</em> No — the constraint prevents it. <em>Can a tenant be billed for a send that never
happened?</em> No — terminal failures release the reservation. <em>What if we deploy mid-send?</em> It is
retried, and finds the work already claimed.</p>
<p>Those are good answers to have ready when the customer asking is the one being billed.</p>
]]></content:encoded></item><item><title>Making a public data site legible to AI agents</title><link>https://webitops.com/notes/agent-native-public-data-service/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/notes/agent-native-public-data-service/</guid><description>MCP servers, llms.txt, RFC 9727 API catalogues and well-known discovery documents — what we actually shipped to make a public data service consumable by machines, and which parts earned their keep.</description><content:encoded><![CDATA[<p>There is a reasonable chance that a meaningful share of your site&rsquo;s readers are no longer people.</p>
<p>Not crawlers indexing you for a results page — that has been true for thirty years — but agents
retrieving your content to answer a question right now, on behalf of someone who will never see
your page. If your site publishes <em>data</em>, that audience needs something other than HTML, and the
usual arrangement is unpleasant for everyone: they scrape, you get load and no attribution, and
they get a number with no provenance.</p>
<p>We had a service where this was worth solving properly, and no commercial reason to keep the data
scarce. Here is what we shipped, in the order it mattered.</p>
<h2 id="1-a-machine-readable-answer-to-the-actual-question">1. A machine-readable answer to the actual question</h2>
<p>Start here, because everything else is signposting to it.</p>
<p>Ours was an MCP server: stateless JSON-RPC 2.0 over Streamable HTTP, exposing a single tool that
answers the one question the site exists to answer, in any requested currency, with the timestamp
and source of the underlying data attached.</p>
<p>A few things we would tell anyone building one:</p>
<ul>
<li><strong>Stateless is the right default.</strong> Streamable HTTP without a session layer means the server is an ordinary rate-limited HTTP endpoint, deployable and scalable exactly like the rest of the app. Sessions buy you very little for a read-only tool and cost you a lot of operational surface.</li>
<li><strong>Negotiate protocol versions explicitly, and support more than one.</strong> The specification is moving. Clients in the wild pin different revisions. Handle a version you do not know by responding with one you do, rather than failing.</li>
<li><strong>One tool that answers the real question beats five that expose your schema.</strong> The temptation is to publish <code>get_metals_price</code>, <code>get_fx_rate</code>, <code>list_currencies</code> and let the agent assemble the answer. Do not. Every join you push onto the caller is a chance for them to compute something wrong and attribute it to you.</li>
<li><strong>Return provenance in the payload.</strong> Not just the number — when it was computed, what it was derived from, what the caveats are. This is the single biggest advantage you have over being scraped, and it costs three extra fields.</li>
</ul>
<aside class="not-prose my-7 rounded-lg border-l-4 border-ink bg-surface px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">Test it like an endpoint, not like a demo</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>An MCP server is a public API with an unusual envelope. Ours has test coverage for version
negotiation, malformed JSON-RPC, unknown tool names and unknown currency codes. The failure mode
you care about is not &ldquo;it does not work&rdquo; — it is &ldquo;it returns something plausible and wrong to a
model that will state it confidently.&rdquo;</p>
</div>
</aside>

<h2 id="2-making-it-findable-without-being-told">2. Making it findable without being told</h2>
<p>An endpoint nobody can discover is a private API with extra steps. This is the part that is
genuinely new, and it is mostly cheap static documents.</p>
<ul>
<li><strong><code>/.well-known/mcp.json</code></strong> — a server card. An MCP client that knows only your domain can find the server, learn what it exposes, and connect.</li>
<li><strong><code>/.well-known/api-catalog</code></strong> — the RFC 9727 linkset. A general-purpose way to say &ldquo;here are my machine interfaces&rdquo;, not specific to any one protocol, which matters because MCP will not be the last of these.</li>
<li><strong><code>Link</code> headers</strong> on ordinary responses, advertising the sitemap, the catalogue and <code>llms.txt</code>. Consumers that fetch a page and never parse the body still see them.</li>
<li><strong><code>llms.txt</code> and <code>llms-full.txt</code></strong> — a plain-language map of the site, and a fuller dump for consumers that want everything in one request.</li>
</ul>
<p>The principle underneath: <strong>a machine should be able to get from your bare domain name to a typed
answer without a human reading your docs.</strong> Test that path end to end, because it is easy to ship
four correct documents that do not actually chain.</p>
<h2 id="3-saying-what-you-allow-on-purpose">3. Saying what you allow, on purpose</h2>
<p>Most <code>robots.txt</code> files are ambiguous about AI use by accident — they were written before the
question existed, and silence gets interpreted by whoever is doing the interpreting.</p>
<p>We name the major AI user agents explicitly and declare content signals separating indexing,
inference-time retrieval and training. For this project all three are permitted, because the point
of the service is to be used. That is not the right answer for everyone. The recommendation is not
&ldquo;allow everything&rdquo; — it is <strong>decide, and write it down</strong>, because the alternative is having it
decided for you.</p>
<h2 id="what-we-would-skip">What we would skip</h2>
<p>Being honest about the parts that have not paid off yet:</p>
<ul>
<li><strong><code>llms-full.txt</code> has seen little use.</strong> Cheap to generate, so it stays, but we would not build it first.</li>
<li><strong>Discovery specifications are young.</strong> The RFC is stable; the MCP-adjacent conventions are not, and some of this will need revisiting. If you have limited time, ship the tool and the server card, and add the catalogue later.</li>
<li><strong>Nobody has a good analytics story here.</strong> Agent traffic is hard to distinguish from ordinary API traffic, so &ldquo;did this work?&rdquo; remains partly a matter of faith. If you need a measurable return before you build, this is not yet that.</li>
</ul>
<h2 id="was-it-worth-it">Was it worth it?</h2>
<p>For a public-interest service where being used correctly <em>is</em> the goal, clearly yes — the marginal
cost over an already-public JSON API was small, and it replaced being scraped badly with being
consumed properly.</p>
<p>For a commercial product where the data is the moat, the calculus is entirely different, and the
honest answer may be that you want the opposite of all of this.</p>
]]></content:encoded></item></channel></rss>